Guides
Odoo 17 scheduled update
Bring the cheapest competitor price for every product into Odoo 17 once a day. This guide builds a small custom module with five product fields and a scheduled action.
Note: This is an example, not a supported GetPrice module. Adapt the fields, the "cheapest" rule and the schedule to your own processes. Test it on a copy of your database first.
It assumes each product's Internal Reference (default_code) is a SKU linked in GetPrice, and that each
reference belongs to one product variant. Store your key under Settings > Technical > System Parameters as
getprice.api_key; you need developer mode to see that menu.
The module adds these fields to each product:
| Field | Type | Holds |
|---|---|---|
x_gp_cheapest_price | Float | The cheapest fresh, confirmed competitor price, or 0 when there is none |
x_gp_cheapest_was | Float | That price before discount when it's a promotion, otherwise 0 |
x_gp_cheapest_competitor | Char | The competitor selling at that price |
x_gp_last_collected | Datetime | When GetPrice collected that price |
x_gp_competitor_count | Integer | How many competitors have a fresh, confirmed price |
Add the model#
Save this as models/product_product.py in a custom module that depends on product.
import logging
import time
from datetime import datetime, timezone
from decimal import Decimal
import requests
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
API = "https://api.getprice.ai/v1"
BATCH = 100 # up to 100 sku values per request
RETRYABLE = (429, 502, 503, 504) # never retry 500
class ProductProduct(models.Model):
_inherit = "product.product"
x_gp_cheapest_price = fields.Float(
"Cheapest competitor price", readonly=True
)
x_gp_cheapest_was = fields.Float(
"Its price before discount", readonly=True
)
x_gp_cheapest_competitor = fields.Char(
"Cheapest competitor", readonly=True
)
x_gp_last_collected = fields.Datetime(
"Competitor price collected", readonly=True
)
x_gp_competitor_count = fields.Integer(
"Competitors with a price", readonly=True
)
@api.model
def _cron_getprice(self):
icp = self.env["ir.config_parameter"].sudo()
key = icp.get_param("getprice.api_key")
if not key:
_logger.warning("No getprice.api_key set")
return
session = requests.Session()
auth = f"Bearer {key}"
session.headers["Authorization"] = auth
products = self.search(
[("default_code", "!=", False)]
)
by_sku = {
p.default_code.strip().upper(): p
for p in products
}
skus = list(by_sku)
for start in range(0, len(skus), BATCH):
chunk = skus[start : start + BATCH]
found = {}
sets = self._getprice_sets(session, chunk)
for cset in sets:
if cset["sku"]:
sku = cset["sku"].strip().upper()
# A SKU in two sets: keep the first.
found.setdefault(sku, cset)
for sku in chunk:
cset = found.get(sku)
values = self._getprice_values(cset)
by_sku[sku].write(values)
# Keep finished batches if one fails.
self.env.cr.commit()
@api.model
def _getprice_sets(self, session, skus):
"""All sets for these SKUs (every page)."""
filters = [("sku", sku) for sku in skus]
page = self._getprice_get(session, filters)
sets = page["data"]
while page["next_cursor"]:
cursor = [("cursor", page["next_cursor"])]
page = self._getprice_get(
session, filters + cursor
)
sets += page["data"]
return sets
@api.model
def _getprice_values(self, cset):
"""Field values; cleared if there's no set."""
offers = [
e
for e in (cset or {}).get("competitors", [])
if e["price"]
and not e["stale"]
and e["match"]["review"] == "same"
]
if not offers:
return {
"x_gp_cheapest_price": 0.0,
"x_gp_cheapest_was": 0.0,
"x_gp_cheapest_competitor": False,
"x_gp_last_collected": False,
"x_gp_competitor_count": 0,
}
best = min(
offers,
key=lambda e: Decimal(e["price"]["amount"]),
)
stamp = best["last_collected_at"].replace(
"Z", "+00:00"
)
collected = datetime.fromisoformat(stamp)
# Odoo stores datetimes as naive UTC.
utc = collected.astimezone(timezone.utc)
amount = Decimal(best["price"]["amount"])
was = Decimal(best["price"]["was"] or 0)
return {
"x_gp_cheapest_price": float(amount),
"x_gp_cheapest_was": float(was),
"x_gp_cheapest_competitor": (
best["competitor"]["name"]
),
"x_gp_last_collected": utc.replace(
tzinfo=None
),
"x_gp_competitor_count": len(offers),
}
@api.model
def _getprice_get(self, session, params, tries=6):
url = f"{API}/comparison-sets"
for attempt in range(tries):
backoff = min(2**attempt, 60)
try:
resp = session.get(
url, params=params, timeout=60
)
except requests.RequestException:
time.sleep(backoff)
continue
if resp.status_code in RETRYABLE:
wait = resp.headers.get("Retry-After")
time.sleep(int(wait or backoff))
continue
if resp.status_code >= 400:
p = resp.json()
raise UserError(
f"GetPrice {p['code']}: "
f"{p['detail']} ({p['request_id']})"
)
return resp.json()
raise UserError("GetPrice API unavailable")Schedule it#
Save this as data/ir_cron.xml and list it under data in your module's manifest. It runs every day at 08:00
UTC, which is 10:00 SAST.
<odoo>
<record id="ir_cron_getprice_sync"
model="ir.cron">
<field name="name">GetPrice: update prices</field>
<field name="model_id"
ref="product.model_product_product"/>
<field name="state">code</field>
<field name="code">model._cron_getprice()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<!-- First run: tomorrow at 08:00 UTC. -->
<field name="nextcall" eval="
(DateTime.now() + timedelta(days=1))
.strftime('%Y-%m-%d 08:00:00')"/>
<field name="active" eval="True"/>
</record>
</odoo>After a run, each product's fields hold the cheapest fresh competitor price. For the beans in these examples:
x_gp_cheapest_price: 17.99
x_gp_cheapest_was: 19.99
x_gp_cheapest_competitor: Harbour Market
x_gp_last_collected: 2026-11-02 03:05:11
x_gp_competitor_count: 2Things to know#
- The fields appear once you install the module. Add them to the product form or list view as you like.
- The
Floatfield is for display and reporting. If you calculate prices from it, convert the API's decimal string withDecimal, as the example does. - The example keeps only fresh, confirmed (
same) prices. For a set markedretailer_conflict, it takes the lowest price. - A product whose SKU isn't linked gets its fields cleared. Link a new SKU shows how to link it.
- 3,000 products take 30 requests of 100 SKUs, costing 120 units. On a 60-unit plan the run takes about a
minute: the first 60 units go at once, and the code waits out each
429for the rest.