Skip to content

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:

FieldTypeHolds
x_gp_cheapest_priceFloatThe cheapest fresh, confirmed competitor price, or 0 when there is none
x_gp_cheapest_wasFloatThat price before discount when it's a promotion, otherwise 0
x_gp_cheapest_competitorCharThe competitor selling at that price
x_gp_last_collectedDatetimeWhen GetPrice collected that price
x_gp_competitor_countIntegerHow 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.

Python
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.

XML
<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:

Output
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: 2

Things to know#

  • The fields appear once you install the module. Add them to the product form or list view as you like.
  • The Float field is for display and reporting. If you calculate prices from it, convert the API's decimal string with Decimal, as the example does.
  • The example keeps only fresh, confirmed (same) prices. For a set marked retailer_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 429 for the rest.