Vendease Odoo Service¶
Overview¶
The Vendease Odoo service is a Python application that synchronises
procurement and marketplace data into the Odoo ERP. The repository describes
itself in README.md as the Procurement Odoo Sync project: a framework for
"synchronizing procurement data — such as invoices and products — with Odoo,
utilizing Python." It is distinct from the Odoo addons workspace; this
service runs outside Odoo and talks to it through Odoo's external XML-RPC
interface.
The service has three concurrent jobs running inside a single process:
- expose a small FastAPI HTTP surface for product and vehicle operations;
- listen to Postgres
LISTEN/NOTIFYchannels on the in-house E-Procurement and Marketplace databases and translate those events into Odoo writes; and - run a periodic cron task that backfills any invoices that the listeners missed during downtime windows.
A persistent Redis instance is used both for caching the Odoo authenticated
UID and for holding job-deduplication keys. The requirements.txt confirms
the runtime stack: FastAPI, Uvicorn, psycopg2, Redis, the Slack SDK, the
Google API Python client, python-magic and pytest. The README lists
Python 3.12 as the target, while the shipped Dockerfile uses
python:3.11-slim.
Architecture & Technology¶
The runtime is composed of three concurrent units owned by a single process:
- HTTP server —
app.pybuilds the FastAPI app, attaches aGZipMiddlewarewith a 1 KB minimum size, registers a globalvalidate_client_iddependency for client authentication, and mounts versioned routers under/api/v1for theproductandvehiclescapability areas. Theuvicornserver is started insiderun_http_server()on the port pulled fromConfig.PORT. - Database listeners —
start_listeners()instantiates anEProcurementListenerand aMarketplaceListenerand runs them concurrently withasyncio.gather. Each listener usespsycopg2against the source-system database, sets the connection to autocommit, and issuesLISTENagainst five channels:new_invoice_record_update,new_payment_request_record_update,product_record_event,new_branch_record_updateanddispatch_record_event(the marketplace listener also listens ondelete_invoice_record). - Cron scheduler —
cron/migrate_invoice.pydefinesCronSchedulerwith a 60-secondinterval_ms. The_migrate_invoicetask pulls the last seven days of invoices from both source databases and re-runs the hydrate flow, using a Redis-cached marker key to avoid duplicate work in the same window.app.run_taskswiresrun_hydration,start_listenersandCronScheduler.startinto a singleasyncio.gathercall.
The two units are launched on separate threads in main(): one thread runs
the asyncio event loop containing the listeners, hydration and cron, and the
other runs the synchronous Uvicorn server. Both threads are join()ed so
the process stays alive as long as either is running.
The Odoo client is implemented once in connectors/odoo_connector.py. It
constructs xmlrpc.client.ServerProxy instances against /xmlrpc/2/common
and /xmlrpc/2/object using the configured Odoo URL, database, user and
password from config.config.Config. The authenticated UID is cached in
Redis to avoid re-authentication on every call. The connector exposes the
standard CRUD verbs plus an action helper, and is consumed by every model
wrapper.
The model layer (models/) holds typed wrappers around the Odoo entities the
service has to read or write: invoice, invoice_item, partner, expense,
product, product_variant, product_template_value, product_attribute,
product_attribute_line, product_packaging, purchase_order_line,
sales_order, sale_order_line, stock_move_picking and
unit_of_measure. The wrappers stay focused on the field mapping; business
rules live in services/.
Service classes (services/) carry the actual hydrate / sync logic and are
named after their Odoo target: contact_service.py,
dispatch_service.py, expense_service.py, invoice_service.py,
product_service.py, product_variant_service.py,
sales_order_service.py, vehicle_service.py. Each is the orchestration
point that the listeners and cron call into.
Source-system adapters live under apps/:
apps/eprocurement/shipscompany_db.py,dispatch.py,invoice_db.py,product_db.pyandproduct_service.py— Postgres-backed reads against the E-Procurement schema.apps/marketplace/mirrors the structure for the Marketplace database (company_db.py,dispatch.py,invoice_db.py).apps/base_db.pyfactors out the common Postgres wiring.
Auxiliary subsystems include notifications/ (Slack notifications, plus a
sale_order.py notifier and send_notification.py dispatcher), vis/ and
vtp/ (additional pricing / availability adapters used by the product
routes), purchase_order_inherit/ (custom purchase-order behaviours),
utils/redis_client.py (the singleton Redis client), common/,
types_r/, and infra/. The tests/ directory and connectors/tests/,
services/tests/, models/tests/ carry pytest suites; the project ships a
coverage.xml and a .coverage file from the last run.
The combination of FastAPI for the synchronous surface, Postgres
LISTEN/NOTIFY for change capture, Redis for cross-cycle deduplication,
threads for HTTP-versus-async-loop separation, and Odoo XML-RPC for the
write side defines the architecture.
How It Fits With Other Systems¶
Inputs:
- E-Procurement Postgres database — primary source. The
EProcurementListenerreacts to invoice, payment request, product, branch and dispatch events;apps/eprocurementexposes paginated reads for hydration and cron backfills. - Marketplace Postgres database — the
MarketplaceListenermirrors the same channels (with an extradelete_invoice_recordchannel); the invoice migration cron also pulls Marketplace invoices. - HTTP clients — internal services hit
/api/v1/productand/api/v1/vehicles. Thevalidate_client_iddependency inconfig/client.pygates every endpoint by client ID. - VTP / VIS — the
vtp/module exposes price-and-availability lookups driven by city, product name and unit of measure;vis/plays an analogous role for additional inventory data.
Outputs:
- Odoo ERP — the dominant target. Sales orders, purchase orders, partners,
products, product variants, packagings, attributes, stock-move pickings,
invoices, invoice items, expenses and units of measure are all written
through
OdooConnector. - Slack — the
notifications/package and theslack_sdkdependency inrequirements.txtcover operational alerting. - Google APIs —
google-api-python-clientandprotobufare present for Sheets / Drive integrations used in the auxiliary flows.
The service is a one-way pipeline from the in-house Postgres systems into Odoo, with a synchronous HTTP path used for ad-hoc product and vehicle operations and a cron safety-net to recover from any missed events.
Repository Layout¶
Top-level files:
app.py— FastAPI bootstrap, listener / cron orchestration, threaded entry point.requirements.txt— pinned Python dependencies.Dockerfile—python:3.11-slimbase, installslibpq-dev,gcc,build-essential,postgresql-clientandfile, copies the source, exposes port8000, runs as the unprivilegedappuser, and starts the app withpython app.py.docker-compose.yml— local dev stack withweb(FastAPI),db(postgres:15-alpine) andredis(redis:7-alpine) services.env-prod,env-staging,.env,.env-sample— environment files for the three runtime targets. They are referenced by Docker Compose / the runtime loader; their contents are not reproduced here.README.md— short project description and quickstart.coverage.xml,.coverage— last test-run coverage artefacts.
Application packages:
apps/— source-system adapters split intoeprocurement/andmarketplace/, with a sharedbase_db.py.connectors/— the singleodoo_connector.pyXML-RPC client and its tests.listeners/—eprocurement.pyandmarketplace.py, each owning the PostgresLISTEN/NOTIFYloop and dispatching to services.cron/—migrate_invoice.pywith theCronSchedulerperiodic backfill.services/— domain services, one per Odoo target area.models/— Odoo entity wrappers used by the services.routes/— FastAPI routers (product_routes.py,vehicle_routes.py).config/—Configloader (config/config.py),client.pyfor client-ID validation,pagination.pyhelpers.notifications/— Slack and sale-order notifiers.purchase_order_inherit/— custom purchase-order behaviour bundled as its own package.vis/,vtp/— auxiliary product/availability lookups consumed by the product routes.utils/— shared helpers includingredis_client.py.common/,types_r/— shared types and helpers.infra/— deployment manifests.tests/,services/tests/,models/tests/,connectors/tests/— pytest suites.log/— local log directory.venv/— a checked-in virtual environment directory, present in the working copy.
Running, Building & Deploying¶
The README documents the local workflow:
- Create a Python 3.12 environment.
- Install dependencies:
pip3 install -r requirements.txt. - Set
PYTHONPATH=$(pwd)before running tests. - Run tests with
pytest-watch.
Application startup is python app.py, which is what both the README's
implicit flow and the Dockerfile's CMD do. app.main() spawns one thread
for the HTTP server (uvicorn.run(app, host="0.0.0.0", port=int(Config.PORT)))
and one thread for the asyncio loop that owns hydration, listeners and the
cron scheduler.
Containerised execution:
docker-compose upstarts the full local stack: awebcontainer runninguvicorn main:app --host 0.0.0.0 --port 8000 --reload, a Postgres 15 database (vis_dev), and a Redis 7 cache. The compose file mounts the working directory into/appso live edits are picked up.- The
Dockerfilebuilds a slimmer image for non-dev environments and runs the application asappuser.
Configuration is environment-driven. config/config.py reads Config.ODOO_URL,
Config.ODOO_DB, Config.ODOO_USER, Config.ODOO_PASSWORD, the
E-Procurement Postgres credentials (EPROC_DB_NAME, EPROC_DB_USER,
EPROC_DB_PASSWORD, EPROC_DB_HOST, EPROC_DB_PORT), the equivalent
Marketplace ("PIB") credentials, the Redis connection details, the Slack
token and Config.PORT. The env-prod and env-staging files supply
those values for production and staging respectively; both are present in
the working tree but are not read in the course of producing this document.
The .env-sample file is the template to use when populating a new
environment.
Database migrations are not part of this service: the source-system schemas are owned by E-Procurement and Marketplace, and the Odoo schema is managed by Odoo itself.
Operational Notes & Risks¶
- Single Odoo gateway — every Odoo write goes through one
OdooConnectorper model. If Odoo authentication fails at boot, the process logs the error but the listeners still start; calls then fail at use-time. Re-authentication caches the UID in Redis. - Threaded HTTP + asyncio model — the HTTP server and the asyncio loop run on separate threads with no shared cancellation. If one thread dies the other continues, which can leave the service half-functional until it is restarted by the supervisor.
printof credentials in the connector —connectors/odoo_connector.pycontainsprintstatements that emitConfig.ODOO_DB,Config.ODOO_URL,Config.ODOO_USERandConfig.ODOO_PASSWORDon every instantiation. This exposes the Odoo password in container stdout and any aggregated log pipeline; it should be removed.- Event-driven plus cron safety net — the listeners are the primary
signal, but the 60-second
_migrate_invoicecron and the Redis-keyed deduplication is what catches anything that was missed during a listener outage. The Redis cache key is dated and tagged, so flushing Redis can cause the cron to redo a window of work; that work is expected to be idempotent on the Odoo side. - Hydration toggles are commented out —
app.run_hydration()ships with most of its body commented (product_variant_service.clean_up_old_product, product fetch / hydrate, invoice fetch / hydrate). The function still runs, but at present it only logs intent and returns. The active backfill path is the cron, not the boot-time hydration. - Postgres
LISTEN/NOTIFYreconnect — both listeners attempt to re-establish the connection onOperationalError; production reliability depends on this loop continuing to retry. Long-lived NOTIFY connections can also be dropped silently by network middleboxes. - Multiple environments share the same code path — runtime selection is
purely environment-driven through
env-prodandenv-staging. Any misconfiguration of which env file is loaded would point the service at the wrong source database or the wrong Odoo instance. - Local artefacts in repo —
venv/,coverage.xml,.coverage,log/and__pycache__/directories are committed alongside the source. They should be excluded from container images to keep the image small and to avoid leaking historical state. READMEandDockerfilePython versions disagree — theREADMEcalls out Python 3.12 while theDockerfileusespython:3.11-slim. Tested behaviour in production is whatever 3.11 produces; the discrepancy should be reconciled.- Port mismatch in compose vs. application —
docker-compose.ymllaunchesuvicorn main:appon port8000, but the application entry point isapp.py(notmain.py) and binds toConfig.PORT. The documented compose command works only ifmain.pyexists andConfig.PORTmatches the published port; otherwise the container won't serve. TheDockerfile'sCMD ["python", "app.py"]is the authoritative way to run the service. - Sensitive env files present —
env-prodandenv-stagingcarry runtime configuration including credentials. They should not leave the controlled environment, and they were intentionally not opened or reproduced when preparing this document. - Listener channel surface is fixed in code — the five Postgres
channels the listeners subscribe to (
new_invoice_record_update,new_payment_request_record_update,product_record_event,new_branch_record_update,dispatch_record_event, plus the marketplace-onlydelete_invoice_record) are hard-coded constants inlisteners/eprocurement.pyandlisteners/marketplace.py. Adding a new event type requires both aNOTIFYemitter on the source side and a corresponding constant + dispatch branch on this side; missing one side silently drops the events. - Hydration is gated by date math —
get_default_created_at()inapp.pyreturns "today minus four months" as the start cursor for the invoice fetch, and_migrate_invoiceuses a fixed seven-day window. Restoring data older than those windows requires either a manual run with explicit dates or a temporary code change. - Reliance on Redis for deduplication — the cron uses Redis-keyed
deduplication (
cache_key = f"invoice-migration-job-...") to avoid re-running the same window. If Redis is unavailable the cron will re-do work; if Redis is shared with other services, key collisions could mistakenly skip a window. Keys are namespaced loosely in code and would benefit from a tighter prefix. - Per-call connection cost — both the listeners and the cron
instantiate fresh
OdooConnectorinstances per call. The XML-RPC proxies are cheap to construct, but each call still incurs network round-trips for authentication when the cached UID is missing. Production stability therefore depends on the Redis UID cache staying warm. - Idle / disabled hydration code paths — several body lines in
app.run_hydrationare commented out (product variants cleanup, product fetch + hydrate, invoice hydrate). Anyone re-enabling these needs to consider the load they will place on Odoo and on the source databases, and to confirm whether the underlying service methods are still idempotent against the current schema.