How-to
Weekly Inventory Email in Odoo: Send On-Hand Stock Automatically
Use a native Odoo Scheduled Action to look up on-hand quantities for a set of products and email them every week. No external script or cron server needed.
KalvyAI Engineering · · 6 min read

You want Odoo to email a list of products and their on-hand quantities to someone every week. You can do this inside Odoo with a Scheduled Action. You do not need an external Python script, the External API or a cron job on another server.
This guide covers Odoo 16, 17 and 18, in both Community and Enterprise. The tools used here (Scheduled Actions, server action code and outgoing email) come from Odoo's base and mail modules, so both editions work the same way. Where a field name or behaviour differs between versions, this guide says so.
Server Actions, Automated Actions and Scheduled Actions
Odoo has three related tools. Picking the wrong one is the most common source of confusion.
Server Actions
A server action is a reusable piece of logic. It can update a record, create a record, send an email, or run Python code. On its own, a server action does nothing until something triggers it. Possible triggers are a button, the Action menu on a record, an automation rule or a scheduled action.
Automated Actions (called Automation Rules from Odoo 17)
These run a server action when something happens to a record. Examples are when a record is created, when a field changes, or a set time before or after a date field on the record. They react to data. They are not built for "every Monday at 7:00". From Odoo 17 they need the Automation Rules module (base_automation) installed.
Scheduled Actions
A scheduled action (technical model ir.cron) runs code on a fixed timetable, for example every 1 week. This is Odoo's built-in cron, and it is the right tool for a weekly report.
Summary: for a weekly inventory email, use a Scheduled Action that runs Python code.
What you need before you start
- Administrator access to Odoo.
- Developer mode turned on. To turn it on, go to Settings and use Activate the developer mode at the bottom of the page.
- Working outgoing email. On self-hosted or Odoo.sh databases, check Settings > Technical > Outgoing Mail Servers. [CHECK: outgoing mail behaviour on your hosting type, especially Odoo Online]
- A way to pick the products. The easiest option is a dedicated product category, for example
Weekly Stock Report. You can also use a product tag or any other domain. The code below uses a category.
Step 1: Group the products
- Go to Inventory > Configuration > Product Categories.
- Create a category named
Weekly Stock Report. You can make it a child of an existing category. - Open each product you want in the report. Set its Product Category to the new category.
The code uses child_of. Products in sub-categories of Weekly Stock Report are therefore included too.
Step 2: Create the Scheduled Action
- Go to Settings > Technical > Automation > Scheduled Actions.
- Click New.
- Fill in the fields:
- Name:
Weekly on-hand stock email - Model:
Product Variant(product.product) - Execute Every:
1Weeks - Next Execution Date: the first date and time you want it to run, for example next Monday at 07:00. This also fixes the weekday for later runs.
- Action To Do / Type:
Execute Python Code(shown asExecute Codein Odoo 17 and later) - Scheduler User: the user the job runs as. [CHECK: default scheduler user in your version] Use a user who can read inventory data and who belongs to the right company.
- Paste the code from Step 3 into the Python Code tab.
- Save.
Step 3: The Python code
This code finds the products and reads their on-hand quantity (qty_available). It builds an HTML table and sends one email.
# --- Settings: change these ---
CATEGORY_NAME = "Weekly Stock Report"
RECIPIENTS = "warehouse@example.com, owner@example.com"
category = env['product.category'].search([('name', '=', CATEGORY_NAME)], limit=1)
if not category:
log("Weekly stock email: category '" + CATEGORY_NAME + "' not found", level='warning')
else:
products = env['product.product'].search(
[('categ_id', 'child_of', category.id)],
order='default_code, name',
)
cell = "padding:4px 8px;border-bottom:1px solid #ddd;"
rows = ""
for p in products:
rows += (
"<tr>"
"<td style='" + cell + "'>" + (p.default_code or "") + "</td>"
"<td style='" + cell + "'>" + p.display_name + "</td>"
"<td style='" + cell + "text-align:right;'>" + ("%.2f" % p.qty_available) + "</td>"
"<td style='" + cell + "'>" + p.uom_id.name + "</td>"
"</tr>"
)
today = datetime.datetime.now().strftime('%Y-%m-%d')
body = (
"<p>On-hand stock as of " + today + ":</p>"
"<table style='border-collapse:collapse;'>"
"<tr><th style='" + cell + "text-align:left;'>Ref</th>"
"<th style='" + cell + "text-align:left;'>Product</th>"
"<th style='" + cell + "text-align:right;'>On hand</th>"
"<th style='" + cell + "text-align:left;'>UoM</th></tr>"
+ rows +
"</table>"
"<p>" + str(len(products)) + " products listed.</p>"
)
mail = env['mail.mail'].sudo().create({
'subject': "Weekly stock report - " + today,
'body_html': body,
'email_to': RECIPIENTS,
'email_from': env.company.partner_id.email_formatted or env.user.email_formatted,
'auto_delete': False,
})
mail.send()
log("Weekly stock email sent for " + str(len(products)) + " products")Notes on the code:
- No imports. Server action code runs in a restricted sandbox. You cannot use
import. You can use variables Odoo provides, such asenv,datetime,logandUserError. The code above uses only those. - String building. The code joins strings with
+and%. It does not use f-strings, so it works across the sandbox versions covered here. qty_availableis Odoo's on-hand quantity. It is the same number shown as On Hand on the product form.auto_delete: Falsekeeps a copy of each sent email under Settings > Technical > Emails. That makes it easy to check later what was sent.mail.send()sends the email right away. If you remove that line, the email waits in the outgoing queue and the standard mail queue scheduled action sends it later.
Step 4: Test it
- Open your scheduled action.
- Click Run Manually.
- Check the recipient's inbox.
- If nothing arrives, open Settings > Technical > Emails and look for your email and its status. A failed email shows the error from the mail server.
- For code errors, check the server log on self-hosted or Odoo.sh. The
log()lines also show under Settings > Technical > Logging. [CHECK: logging menu location in your version]
Once the manual run works, leave the action Active. It will run at the Next Execution Date and every week after that.
Common variations
Only storable products
If the category also holds services or consumables, filter them out. The field differs by version:
# Odoo 16 and 17
domain = [('categ_id', 'child_of', category.id), ('type', '=', 'product')]
# Odoo 18
domain = [('categ_id', 'child_of', category.id), ('is_storable', '=', True)]Pass domain to the search() call in place of the category-only domain.
Stock for one warehouse only
By default, qty_available covers all internal locations the user's companies can see. To limit it to one warehouse, read the quantity with a warehouse in the context:
warehouse = env['stock.warehouse'].search([('code', '=', 'WH')], limit=1)
products = products.with_context(warehouse=warehouse.id)The context key name has changed between versions. [CHECK: context key for warehouse filtering in your Odoo version, e.g. warehouse vs warehouse_id] Compare the result with the product form for that warehouse before you rely on it.
Flag low stock
Add a simple threshold and highlight rows below it:
LOW = 10
# inside the loop, before building the row:
style = cell + ("color:#b00;font-weight:bold;" if p.qty_available < LOW else "")Then use style in place of cell for the quantity cell. If you already use reordering rules, you can compare against each product's rule minimum instead of a fixed number.
Pick products by tag instead of category
If you would rather not change categories, filter by a product tag or any other field. Change the search() domain to match. [CHECK: product tag field name on product.product in your version]
Multi-company databases
The job runs as the Scheduler User, inside that user's current company. If you need one report per company, create one scheduled action per company, each with a user set to that company. Another option is to loop over companies in the code with with_company().
Troubleshooting
- The action never runs on its own. Check that it is Active and that Next Execution Date is in the future. On self-hosted servers, scheduled actions need cron threads enabled. The
max_cron_threadsoption must not be0. - The email is in the queue but not sent. Check the outgoing mail server and the error on the email record.
- Quantities look wrong. Check which company and warehouses the Scheduler User can see. Quantities follow that user's access.
- "forbidden opcode" or similar errors. You used something the sandbox blocks. Common causes are
import, names that start with an underscore, or unsupported syntax. Rewrite the code using the provided variables only.
When to move this into a custom module
The scheduled action approach is good for a single, simple report. Consider a small custom module if you need any of these:
- an Excel or CSV attachment
- a proper mail template that non-developers can edit
- version control
- tests
- the same report deployed to several databases
The logic stays the same. It moves into a Python file and an XML data file, so it survives upgrades more predictably.
How KalvyAI can help
KalvyAI implements and customises Odoo. We build scheduled reports, stock alerts and automations like the one in this guide. We can also turn them into maintained modules with templates, attachments and multi-company support. If you want a weekly report built, reviewed or extended, contact us.
- Odoo
- Inventory
- Scheduled Actions
- Server Actions
- Automation
KalvyAI Engineering · Engineering team
The engineers who build, connect and fix business systems at KalvyAI: Odoo, ERPNext, Shopify and AI agents that work inside them with approvals, budgets and an audit trail.



Comments
No comments yet. Questions and corrections are welcome.
Rate this post or leave a comment