From a01ecfb74c0de7fe8ce95b4cd4e8960bc2b7d8f4 Mon Sep 17 00:00:00 2001 From: alireza Sharzeh Date: Thu, 23 Jul 2026 17:15:32 +0330 Subject: [PATCH 1/6] initialized project with basic implementation of odoo sync --- fastapi-odoo/.dockerignore | 11 + fastapi-odoo/.env.example | 27 ++ fastapi-odoo/.gitignore | 11 + fastapi-odoo/Dockerfile | 23 ++ fastapi-odoo/README.md | 96 +++++ fastapi-odoo/alembic.ini | 42 +++ fastapi-odoo/alembic/env.py | 49 +++ fastapi-odoo/alembic/script.py.mako | 25 ++ .../alembic/versions/0001_create_items.py | 47 +++ .../alembic/versions/0002_sync_entities.py | 160 ++++++++ fastapi-odoo/app/__init__.py | 1 + fastapi-odoo/app/api/__init__.py | 1 + fastapi-odoo/app/api/deps.py | 27 ++ fastapi-odoo/app/api/router.py | 13 + fastapi-odoo/app/api/routes/__init__.py | 1 + fastapi-odoo/app/api/routes/items.py | 59 +++ fastapi-odoo/app/api/routes/odoo.py | 34 ++ fastapi-odoo/app/api/routes/sync.py | 95 +++++ fastapi-odoo/app/core/__init__.py | 1 + fastapi-odoo/app/core/config.py | 28 ++ fastapi-odoo/app/core/database.py | 26 ++ fastapi-odoo/app/integrations/__init__.py | 1 + fastapi-odoo/app/integrations/odoo_client.py | 346 ++++++++++++++++++ fastapi-odoo/app/main.py | 18 + fastapi-odoo/app/models/__init__.py | 12 + fastapi-odoo/app/models/contact.py | 36 ++ fastapi-odoo/app/models/item.py | 26 ++ fastapi-odoo/app/models/product.py | 35 ++ fastapi-odoo/app/models/sale_order.py | 64 ++++ fastapi-odoo/app/repositories/__init__.py | 1 + .../app/repositories/contact_repository.py | 39 ++ .../app/repositories/item_repository.py | 41 +++ .../app/repositories/product_repository.py | 37 ++ .../app/repositories/sale_order_repository.py | 76 ++++ fastapi-odoo/app/schemas/__init__.py | 1 + fastapi-odoo/app/schemas/item.py | 27 ++ fastapi-odoo/app/schemas/odoo.py | 21 ++ fastapi-odoo/app/schemas/odoo_sync.py | 113 ++++++ fastapi-odoo/app/schemas/sync.py | 77 ++++ fastapi-odoo/app/services/__init__.py | 1 + fastapi-odoo/app/services/item_service.py | 48 +++ fastapi-odoo/app/services/odoo_service.py | 28 ++ fastapi-odoo/app/services/sync_service.py | 141 +++++++ fastapi-odoo/docker-compose.yml | 68 ++++ fastapi-odoo/docker/odoo/addons/.gitkeep | 1 + fastapi-odoo/docker/odoo/odoo.conf | 11 + .../docker/postgres/init-odoo-user.sql | 8 + fastapi-odoo/requirements.txt | 9 + 48 files changed, 2063 insertions(+) create mode 100644 fastapi-odoo/.dockerignore create mode 100644 fastapi-odoo/.env.example create mode 100644 fastapi-odoo/.gitignore create mode 100644 fastapi-odoo/Dockerfile create mode 100644 fastapi-odoo/README.md create mode 100644 fastapi-odoo/alembic.ini create mode 100644 fastapi-odoo/alembic/env.py create mode 100644 fastapi-odoo/alembic/script.py.mako create mode 100644 fastapi-odoo/alembic/versions/0001_create_items.py create mode 100644 fastapi-odoo/alembic/versions/0002_sync_entities.py create mode 100644 fastapi-odoo/app/__init__.py create mode 100644 fastapi-odoo/app/api/__init__.py create mode 100644 fastapi-odoo/app/api/deps.py create mode 100644 fastapi-odoo/app/api/router.py create mode 100644 fastapi-odoo/app/api/routes/__init__.py create mode 100644 fastapi-odoo/app/api/routes/items.py create mode 100644 fastapi-odoo/app/api/routes/odoo.py create mode 100644 fastapi-odoo/app/api/routes/sync.py create mode 100644 fastapi-odoo/app/core/__init__.py create mode 100644 fastapi-odoo/app/core/config.py create mode 100644 fastapi-odoo/app/core/database.py create mode 100644 fastapi-odoo/app/integrations/__init__.py create mode 100644 fastapi-odoo/app/integrations/odoo_client.py create mode 100644 fastapi-odoo/app/main.py create mode 100644 fastapi-odoo/app/models/__init__.py create mode 100644 fastapi-odoo/app/models/contact.py create mode 100644 fastapi-odoo/app/models/item.py create mode 100644 fastapi-odoo/app/models/product.py create mode 100644 fastapi-odoo/app/models/sale_order.py create mode 100644 fastapi-odoo/app/repositories/__init__.py create mode 100644 fastapi-odoo/app/repositories/contact_repository.py create mode 100644 fastapi-odoo/app/repositories/item_repository.py create mode 100644 fastapi-odoo/app/repositories/product_repository.py create mode 100644 fastapi-odoo/app/repositories/sale_order_repository.py create mode 100644 fastapi-odoo/app/schemas/__init__.py create mode 100644 fastapi-odoo/app/schemas/item.py create mode 100644 fastapi-odoo/app/schemas/odoo.py create mode 100644 fastapi-odoo/app/schemas/odoo_sync.py create mode 100644 fastapi-odoo/app/schemas/sync.py create mode 100644 fastapi-odoo/app/services/__init__.py create mode 100644 fastapi-odoo/app/services/item_service.py create mode 100644 fastapi-odoo/app/services/odoo_service.py create mode 100644 fastapi-odoo/app/services/sync_service.py create mode 100644 fastapi-odoo/docker-compose.yml create mode 100644 fastapi-odoo/docker/odoo/addons/.gitkeep create mode 100644 fastapi-odoo/docker/odoo/odoo.conf create mode 100644 fastapi-odoo/docker/postgres/init-odoo-user.sql create mode 100644 fastapi-odoo/requirements.txt diff --git a/fastapi-odoo/.dockerignore b/fastapi-odoo/.dockerignore new file mode 100644 index 0000000..87b47dd --- /dev/null +++ b/fastapi-odoo/.dockerignore @@ -0,0 +1,11 @@ +.git +.env +.venv +__pycache__ +*.pyc +*.pyo +.pytest_cache +.mypy_cache +.ruff_cache +*.egg-info +.DS_Store diff --git a/fastapi-odoo/.env.example b/fastapi-odoo/.env.example new file mode 100644 index 0000000..d927a6e --- /dev/null +++ b/fastapi-odoo/.env.example @@ -0,0 +1,27 @@ +# PostgreSQL +POSTGRES_USER=app +POSTGRES_PASSWORD=app +POSTGRES_DB=fastapi_app +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +# FastAPI +APP_NAME=FastAPI Odoo Service +APP_ENV=development +SECRET_KEY=change-me-in-production +DATABASE_URL=postgresql+psycopg2://app:app@db:5432/fastapi_app +API_HOST=0.0.0.0 +API_PORT=8000 + +# Odoo +ODOO_HOST=odoo +ODOO_PORT=8069 +ODOO_DB=odoo +ODOO_USER=admin +ODOO_PASSWORD=admin +ODOO_MASTER_PASSWORD=odoo + +# Odoo Postgres (separate database on same server) +ODOO_PG_USER=odoo +ODOO_PG_PASSWORD=odoo +ODOO_PG_DB=postgres diff --git a/fastapi-odoo/.gitignore b/fastapi-odoo/.gitignore new file mode 100644 index 0000000..05a3e26 --- /dev/null +++ b/fastapi-odoo/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.env +.idea/ +.vscode/ +*.log diff --git a/fastapi-odoo/Dockerfile b/fastapi-odoo/Dockerfile new file mode 100644 index 0000000..e5ac23c --- /dev/null +++ b/fastapi-odoo/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.14-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY alembic.ini . +COPY alembic ./alembic +COPY app ./app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/fastapi-odoo/README.md b/fastapi-odoo/README.md new file mode 100644 index 0000000..13d7488 --- /dev/null +++ b/fastapi-odoo/README.md @@ -0,0 +1,96 @@ +# FastAPI + Odoo Multi-Layer Project + +FastAPI with layered architecture, PostgreSQL, SQLAlchemy/Alembic, and Odoo — all via Docker Compose. + +## Services + +| Service | URL | Role | +|---------|-----|------| +| `api` | http://localhost:8000/docs | FastAPI app | +| `db` | localhost:5432 | PostgreSQL (shared) | +| `odoo` | http://localhost:8069 | Odoo 17 | + +## Architecture + +``` +app/ +├── api/ # Routers + DI +├── schemas/ # Pydantic models +├── services/ # Business logic +├── repositories/ # SQLAlchemy data access +├── models/ # ORM entities +├── integrations/ # Odoo XML-RPC client +├── core/ # Settings + DB session +└── main.py +``` + +## Start everything + +```bash +docker compose up --build +``` + +1. Open **http://localhost:8069** and create an Odoo database named `odoo` (master password: `odoo` from `.env`). +2. Set admin email/password (defaults expected by the API: `admin` / `admin` — or update `ODOO_USER` / `ODOO_PASSWORD` in `.env`). +3. Open **http://localhost:8000/docs** and try: + - `GET /api/v1/health` + - `GET /api/v1/odoo/health` + - `POST /api/v1/items` then `POST /api/v1/items/{id}/sync-odoo` + +## Odoo → FastAPI sync + +Entities are synced from Odoo by `odoo_id`. Existing records are **updated**, new ones are **created**. + +| Odoo model | Local entity | +|------------|--------------| +| `res.partner` | `contacts` | +| `product.product` | `products` | +| `sale.order` | `sale_orders` | +| `sale.order.line` | `sale_order_lines` | + +### Workflow + +1. Start stack: `docker compose up --build` +2. Create Odoo DB at http://localhost:8069 (name: `odoo`, master pwd: `odoo`) +3. Install the **Sales** app in Odoo (Apps → Sales → Activate) +4. Seed demo data in Odoo: `POST /api/v1/odoo/seed-demo` +5. Sync into Postgres: `POST /api/v1/sync` +6. Read local data: + - `GET /api/v1/contacts` + - `GET /api/v1/products` + - `GET /api/v1/sale-orders` + +Re-run `POST /api/v1/sync` anytime — changed Odoo records will update existing rows. + +Partial sync endpoints: `/api/v1/sync/contacts`, `/sync/products`, `/sync/sale-orders`. + +## Useful API routes + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/health` | API liveness | +| GET | `/api/v1/odoo/health` | Odoo XML-RPC connectivity | +| POST | `/api/v1/odoo/seed-demo` | Create sample Odoo records | +| POST | `/api/v1/sync` | Full sync (upsert all entities) | +| GET | `/api/v1/contacts` | Local contacts | +| GET | `/api/v1/products` | Local products | +| GET | `/api/v1/sale-orders` | Local sale orders with lines | + +## Migrations + +```bash +docker compose exec api alembic revision --autogenerate -m "describe change" +docker compose exec api alembic upgrade head +``` + +## Local API (optional) + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +copy .env.example .env +# Point DATABASE_URL at localhost, ODOO_HOST at localhost +alembic upgrade head +uvicorn app.main:app --reload +``` diff --git a/fastapi-odoo/alembic.ini b/fastapi-odoo/alembic.ini new file mode 100644 index 0000000..cd64a16 --- /dev/null +++ b/fastapi-odoo/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/fastapi-odoo/alembic/env.py b/fastapi-odoo/alembic/env.py new file mode 100644 index 0000000..2f28d31 --- /dev/null +++ b/fastapi-odoo/alembic/env.py @@ -0,0 +1,49 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.core.database import Base +from app.models import Contact, Item, Product, SaleOrder, SaleOrderLine # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/fastapi-odoo/alembic/script.py.mako b/fastapi-odoo/alembic/script.py.mako new file mode 100644 index 0000000..958df87 --- /dev/null +++ b/fastapi-odoo/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/fastapi-odoo/alembic/versions/0001_create_items.py b/fastapi-odoo/alembic/versions/0001_create_items.py new file mode 100644 index 0000000..0657c38 --- /dev/null +++ b/fastapi-odoo/alembic/versions/0001_create_items.py @@ -0,0 +1,47 @@ +"""create items table + +Revision ID: 0001_create_items +Revises: +Create Date: 2026-07-23 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001_create_items" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("odoo_partner_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_items_id"), "items", ["id"], unique=False) + op.create_index(op.f("ix_items_name"), "items", ["name"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_items_name"), table_name="items") + op.drop_index(op.f("ix_items_id"), table_name="items") + op.drop_table("items") diff --git a/fastapi-odoo/alembic/versions/0002_sync_entities.py b/fastapi-odoo/alembic/versions/0002_sync_entities.py new file mode 100644 index 0000000..0d6bf4e --- /dev/null +++ b/fastapi-odoo/alembic/versions/0002_sync_entities.py @@ -0,0 +1,160 @@ +"""create sync entities from odoo + +Revision ID: 0002_sync_entities +Revises: 0001_create_items +Create Date: 2026-07-23 13:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002_sync_entities" +down_revision: Union[str, None] = "0001_create_items" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "contacts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("odoo_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=64), nullable=True), + sa.Column("street", sa.String(length=255), nullable=True), + sa.Column("city", sa.String(length=128), nullable=True), + sa.Column("country", sa.String(length=128), nullable=True), + sa.Column("is_company", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("odoo_id"), + ) + op.create_index(op.f("ix_contacts_id"), "contacts", ["id"], unique=False) + op.create_index(op.f("ix_contacts_odoo_id"), "contacts", ["odoo_id"], unique=True) + + op.create_table( + "products", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("odoo_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("default_code", sa.String(length=64), nullable=True), + sa.Column("list_price", sa.Numeric(precision=16, scale=2), nullable=False, server_default="0"), + sa.Column("uom_name", sa.String(length=64), nullable=True), + sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("odoo_id"), + ) + op.create_index(op.f("ix_products_default_code"), "products", ["default_code"], unique=False) + op.create_index(op.f("ix_products_id"), "products", ["id"], unique=False) + op.create_index(op.f("ix_products_odoo_id"), "products", ["odoo_id"], unique=True) + + op.create_table( + "sale_orders", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("odoo_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("contact_id", sa.Integer(), nullable=False), + sa.Column("state", sa.String(length=32), nullable=False), + sa.Column("amount_total", sa.Numeric(precision=16, scale=2), nullable=False, server_default="0"), + sa.Column("date_order", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["contact_id"], ["contacts.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("odoo_id"), + ) + op.create_index(op.f("ix_sale_orders_contact_id"), "sale_orders", ["contact_id"], unique=False) + op.create_index(op.f("ix_sale_orders_id"), "sale_orders", ["id"], unique=False) + op.create_index(op.f("ix_sale_orders_name"), "sale_orders", ["name"], unique=False) + op.create_index(op.f("ix_sale_orders_odoo_id"), "sale_orders", ["odoo_id"], unique=True) + + op.create_table( + "sale_order_lines", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("odoo_id", sa.Integer(), nullable=False), + sa.Column("sale_order_id", sa.Integer(), nullable=False), + sa.Column("product_id", sa.Integer(), nullable=True), + sa.Column("name", sa.String(length=512), nullable=False), + sa.Column("product_uom_qty", sa.Numeric(precision=16, scale=4), nullable=False, server_default="0"), + sa.Column("price_unit", sa.Numeric(precision=16, scale=2), nullable=False, server_default="0"), + sa.Column("price_subtotal", sa.Numeric(precision=16, scale=2), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["product_id"], ["products.id"]), + sa.ForeignKeyConstraint(["sale_order_id"], ["sale_orders.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("odoo_id"), + ) + op.create_index(op.f("ix_sale_order_lines_id"), "sale_order_lines", ["id"], unique=False) + op.create_index(op.f("ix_sale_order_lines_odoo_id"), "sale_order_lines", ["odoo_id"], unique=True) + op.create_index(op.f("ix_sale_order_lines_product_id"), "sale_order_lines", ["product_id"], unique=False) + op.create_index(op.f("ix_sale_order_lines_sale_order_id"), "sale_order_lines", ["sale_order_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_sale_order_lines_sale_order_id"), table_name="sale_order_lines") + op.drop_index(op.f("ix_sale_order_lines_product_id"), table_name="sale_order_lines") + op.drop_index(op.f("ix_sale_order_lines_odoo_id"), table_name="sale_order_lines") + op.drop_index(op.f("ix_sale_order_lines_id"), table_name="sale_order_lines") + op.drop_table("sale_order_lines") + + op.drop_index(op.f("ix_sale_orders_odoo_id"), table_name="sale_orders") + op.drop_index(op.f("ix_sale_orders_name"), table_name="sale_orders") + op.drop_index(op.f("ix_sale_orders_id"), table_name="sale_orders") + op.drop_index(op.f("ix_sale_orders_contact_id"), table_name="sale_orders") + op.drop_table("sale_orders") + + op.drop_index(op.f("ix_products_odoo_id"), table_name="products") + op.drop_index(op.f("ix_products_id"), table_name="products") + op.drop_index(op.f("ix_products_default_code"), table_name="products") + op.drop_table("products") + + op.drop_index(op.f("ix_contacts_odoo_id"), table_name="contacts") + op.drop_index(op.f("ix_contacts_id"), table_name="contacts") + op.drop_table("contacts") diff --git a/fastapi-odoo/app/__init__.py b/fastapi-odoo/app/__init__.py new file mode 100644 index 0000000..18b665e --- /dev/null +++ b/fastapi-odoo/app/__init__.py @@ -0,0 +1 @@ +"""Application package.""" diff --git a/fastapi-odoo/app/api/__init__.py b/fastapi-odoo/app/api/__init__.py new file mode 100644 index 0000000..48f7373 --- /dev/null +++ b/fastapi-odoo/app/api/__init__.py @@ -0,0 +1 @@ +"""HTTP API layer.""" diff --git a/fastapi-odoo/app/api/deps.py b/fastapi-odoo/app/api/deps.py new file mode 100644 index 0000000..1b058b8 --- /dev/null +++ b/fastapi-odoo/app/api/deps.py @@ -0,0 +1,27 @@ +from collections.abc import Generator + +from fastapi import Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.integrations.odoo_client import OdooClient, get_odoo_client +from app.services.item_service import ItemService +from app.services.odoo_service import OdooService +from app.services.sync_service import SyncService + + +def get_item_service(db: Session = Depends(get_db)) -> Generator[ItemService, None, None]: + yield ItemService(db=db) + + +def get_odoo_service( + client: OdooClient = Depends(get_odoo_client), +) -> Generator[OdooService, None, None]: + yield OdooService(client=client) + + +def get_sync_service( + db: Session = Depends(get_db), + client: OdooClient = Depends(get_odoo_client), +) -> Generator[SyncService, None, None]: + yield SyncService(db=db, odoo=client) diff --git a/fastapi-odoo/app/api/router.py b/fastapi-odoo/app/api/router.py new file mode 100644 index 0000000..d410645 --- /dev/null +++ b/fastapi-odoo/app/api/router.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from app.api.routes import items, odoo, sync + +api_router = APIRouter() +api_router.include_router(odoo.router) +api_router.include_router(sync.router) +api_router.include_router(items.router) + + +@api_router.get("/health", tags=["health"]) +def api_health() -> dict[str, str]: + return {"status": "ok"} diff --git a/fastapi-odoo/app/api/routes/__init__.py b/fastapi-odoo/app/api/routes/__init__.py new file mode 100644 index 0000000..fb0a2f8 --- /dev/null +++ b/fastapi-odoo/app/api/routes/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/fastapi-odoo/app/api/routes/items.py b/fastapi-odoo/app/api/routes/items.py new file mode 100644 index 0000000..49fa66a --- /dev/null +++ b/fastapi-odoo/app/api/routes/items.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Depends, Query, status + +from app.api.deps import get_item_service +from app.schemas.item import ItemCreate, ItemRead, ItemUpdate +from app.services.item_service import ItemService + +router = APIRouter(prefix="/items", tags=["items"]) + + +@router.get("", response_model=list[ItemRead]) +def list_items( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + service: ItemService = Depends(get_item_service), +) -> list[ItemRead]: + return service.list_items(skip=skip, limit=limit) + + +@router.post("", response_model=ItemRead, status_code=status.HTTP_201_CREATED) +def create_item( + payload: ItemCreate, + service: ItemService = Depends(get_item_service), +) -> ItemRead: + return service.create_item(payload) + + +@router.get("/{item_id}", response_model=ItemRead) +def get_item( + item_id: int, + service: ItemService = Depends(get_item_service), +) -> ItemRead: + return service.get_item(item_id) + + +@router.patch("/{item_id}", response_model=ItemRead) +def update_item( + item_id: int, + payload: ItemUpdate, + service: ItemService = Depends(get_item_service), +) -> ItemRead: + return service.update_item(item_id, payload) + + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_item( + item_id: int, + service: ItemService = Depends(get_item_service), +) -> None: + service.delete_item(item_id) + + +@router.post("/{item_id}/sync-odoo", response_model=ItemRead) +def sync_item_to_odoo( + item_id: int, + email: str | None = None, + service: ItemService = Depends(get_item_service), +) -> ItemRead: + """Create a matching res.partner in Odoo and store its id on the item.""" + return service.sync_item_to_odoo(item_id, email=email) diff --git a/fastapi-odoo/app/api/routes/odoo.py b/fastapi-odoo/app/api/routes/odoo.py new file mode 100644 index 0000000..e55172d --- /dev/null +++ b/fastapi-odoo/app/api/routes/odoo.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.api.deps import get_odoo_service +from app.schemas.odoo import OdooHealth, OdooPartnerCreate, OdooPartnerRead +from app.services.odoo_service import OdooService + +router = APIRouter(prefix="/odoo", tags=["odoo"]) + + +@router.get("/health", response_model=OdooHealth) +def odoo_health(service: OdooService = Depends(get_odoo_service)) -> OdooHealth: + return service.health() + + +@router.get("/partners", response_model=list[OdooPartnerRead]) +def list_partners( + limit: int = Query(20, ge=1, le=100), + service: OdooService = Depends(get_odoo_service), +) -> list[OdooPartnerRead]: + try: + return service.list_partners(limit=limit) + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +@router.post("/partners", response_model=OdooPartnerRead, status_code=status.HTTP_201_CREATED) +def create_partner( + payload: OdooPartnerCreate, + service: OdooService = Depends(get_odoo_service), +) -> OdooPartnerRead: + try: + return service.create_partner(payload) + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc diff --git a/fastapi-odoo/app/api/routes/sync.py b/fastapi-odoo/app/api/routes/sync.py new file mode 100644 index 0000000..24ca951 --- /dev/null +++ b/fastapi-odoo/app/api/routes/sync.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.api.deps import get_db, get_odoo_service, get_sync_service +from app.repositories.contact_repository import ContactRepository +from app.repositories.product_repository import ProductRepository +from app.repositories.sale_order_repository import SaleOrderRepository +from app.schemas.sync import ( + ContactRead, + ProductRead, + SaleOrderRead, + SyncEntityResult, + SyncResult, +) +from app.services.odoo_service import OdooService +from app.services.sync_service import SyncService + +router = APIRouter(tags=["sync"]) + + +@router.post("/sync", response_model=SyncResult) +def sync_all_from_odoo(service: SyncService = Depends(get_sync_service)) -> SyncResult: + """Pull contacts, products, sale orders and lines from Odoo (create or update).""" + try: + return service.sync_all() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +@router.post("/sync/contacts", response_model=SyncEntityResult) +def sync_contacts(service: SyncService = Depends(get_sync_service)) -> SyncEntityResult: + try: + return service.sync_contacts() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +@router.post("/sync/products", response_model=SyncEntityResult) +def sync_products(service: SyncService = Depends(get_sync_service)) -> SyncEntityResult: + try: + return service.sync_products() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +@router.post("/sync/sale-orders", response_model=SyncResult) +def sync_sale_orders(service: SyncService = Depends(get_sync_service)) -> SyncResult: + try: + return service.sync_sale_orders() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +@router.get("/contacts", response_model=list[ContactRead]) +def list_contacts( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[ContactRead]: + return ContactRepository(db).list(skip=skip, limit=limit) + + +@router.get("/products", response_model=list[ProductRead]) +def list_products( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[ProductRead]: + return ProductRepository(db).list(skip=skip, limit=limit) + + +@router.get("/sale-orders", response_model=list[SaleOrderRead]) +def list_sale_orders( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[SaleOrderRead]: + return SaleOrderRepository(db).list(skip=skip, limit=limit) + + +@router.get("/sale-orders/{order_id}", response_model=SaleOrderRead) +def get_sale_order(order_id: int, db: Session = Depends(get_db)) -> SaleOrderRead: + order = SaleOrderRepository(db).get(order_id) + if not order: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sale order not found") + return order + + +@router.post("/odoo/seed-demo") +def seed_odoo_demo(odoo_service: OdooService = Depends(get_odoo_service)) -> dict: + """Create sample contacts, products, and sale orders in Odoo.""" + try: + return odoo_service.seed_demo_data() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc diff --git a/fastapi-odoo/app/core/__init__.py b/fastapi-odoo/app/core/__init__.py new file mode 100644 index 0000000..5c958ec --- /dev/null +++ b/fastapi-odoo/app/core/__init__.py @@ -0,0 +1 @@ +"""Core configuration and infrastructure.""" diff --git a/fastapi-odoo/app/core/config.py b/fastapi-odoo/app/core/config.py new file mode 100644 index 0000000..2da481e --- /dev/null +++ b/fastapi-odoo/app/core/config.py @@ -0,0 +1,28 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + APP_NAME: str = "FastAPI Odoo Service" + APP_ENV: str = "development" + SECRET_KEY: str = "change-me" + + DATABASE_URL: str = "postgresql+psycopg2://app:app@localhost:5432/fastapi_app" + + ODOO_HOST: str = "localhost" + ODOO_PORT: int = 8069 + ODOO_DB: str = "odoo" + ODOO_USER: str = "admin" + ODOO_PASSWORD: str = "admin" + + @property + def odoo_url(self) -> str: + return f"http://{self.ODOO_HOST}:{self.ODOO_PORT}" + + +settings = Settings() diff --git a/fastapi-odoo/app/core/database.py b/fastapi-odoo/app/core/database.py new file mode 100644 index 0000000..72cd7aa --- /dev/null +++ b/fastapi-odoo/app/core/database.py @@ -0,0 +1,26 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.core.config import settings + + +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/fastapi-odoo/app/integrations/__init__.py b/fastapi-odoo/app/integrations/__init__.py new file mode 100644 index 0000000..bc8e8fc --- /dev/null +++ b/fastapi-odoo/app/integrations/__init__.py @@ -0,0 +1 @@ +"""External system integrations.""" diff --git a/fastapi-odoo/app/integrations/odoo_client.py b/fastapi-odoo/app/integrations/odoo_client.py new file mode 100644 index 0000000..287e8f8 --- /dev/null +++ b/fastapi-odoo/app/integrations/odoo_client.py @@ -0,0 +1,346 @@ +from xmlrpc import client as xmlrpc_client + +from app.core.config import settings +from app.schemas.odoo import OdooHealth, OdooPartnerCreate, OdooPartnerRead +from app.schemas.odoo_sync import ( + OdooContactData, + OdooProductData, + OdooSaleOrderData, + OdooSaleOrderLineData, +) + + +class OdooClientError(Exception): + """Raised when an Odoo XML-RPC call fails.""" + + +CONTACT_FIELDS = [ + "id", + "name", + "email", + "phone", + "street", + "city", + "country_id", + "is_company", +] + +PRODUCT_FIELDS = [ + "id", + "name", + "default_code", + "list_price", + "uom_id", + "active", +] + +SALE_ORDER_FIELDS = [ + "id", + "name", + "partner_id", + "state", + "amount_total", + "date_order", + "order_line", +] + +SALE_ORDER_LINE_FIELDS = [ + "id", + "order_id", + "product_id", + "name", + "product_uom_qty", + "price_unit", + "price_subtotal", +] + + +class OdooClient: + """Thin XML-RPC client for communicating with the Odoo server.""" + + def __init__( + self, + host: str | None = None, + port: int | None = None, + db: str | None = None, + user: str | None = None, + password: str | None = None, + ) -> None: + self.host = host or settings.ODOO_HOST + self.port = port or settings.ODOO_PORT + self.db = db or settings.ODOO_DB + self.user = user or settings.ODOO_USER + self.password = password or settings.ODOO_PASSWORD + self._uid: int | None = None + + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}" + + def _common(self) -> xmlrpc_client.ServerProxy: + return xmlrpc_client.ServerProxy(f"{self.base_url}/xmlrpc/2/common", allow_none=True) + + def _object(self) -> xmlrpc_client.ServerProxy: + return xmlrpc_client.ServerProxy(f"{self.base_url}/xmlrpc/2/object", allow_none=True) + + def authenticate(self) -> int: + try: + uid = self._common().authenticate(self.db, self.user, self.password, {}) + except Exception as exc: # noqa: BLE001 + raise OdooClientError(f"Odoo authentication failed: {exc}") from exc + if not uid: + raise OdooClientError( + "Odoo authentication returned no uid. " + "Create the Odoo database and admin user first (http://localhost:8069)." + ) + self._uid = int(uid) + return self._uid + + @property + def uid(self) -> int: + if self._uid is None: + return self.authenticate() + return self._uid + + def execute_kw( + self, + model: str, + method: str, + args: list | None = None, + kwargs: dict | None = None, + ): + try: + return self._object().execute_kw( + self.db, + self.uid, + self.password, + model, + method, + args or [], + kwargs or {}, + ) + except OdooClientError: + raise + except Exception as exc: # noqa: BLE001 + raise OdooClientError(f"Odoo call {model}.{method} failed: {exc}") from exc + + def search_read( + self, + model: str, + domain: list | None = None, + fields: list[str] | None = None, + limit: int | None = None, + order: str | None = None, + ) -> list[dict]: + kwargs: dict = {} + if fields: + kwargs["fields"] = fields + if limit is not None: + kwargs["limit"] = limit + if order: + kwargs["order"] = order + return self.execute_kw(model, "search_read", [domain or []], kwargs) + + def health(self) -> OdooHealth: + try: + version = self._common().version() + uid = self.authenticate() + return OdooHealth( + connected=True, + version=version.get("server_version") if isinstance(version, dict) else str(version), + uid=uid, + ) + except Exception as exc: # noqa: BLE001 + return OdooHealth(connected=False, detail=str(exc)) + + def fetch_contacts(self, limit: int | None = None) -> list[OdooContactData]: + records = self.search_read( + "res.partner", + domain=[("customer_rank", ">", 0)], + fields=CONTACT_FIELDS, + limit=limit, + order="id asc", + ) + return [OdooContactData.from_odoo(r) for r in records] + + def fetch_products(self, limit: int | None = None) -> list[OdooProductData]: + records = self.search_read( + "product.product", + domain=[("sale_ok", "=", True)], + fields=PRODUCT_FIELDS, + limit=limit, + order="id asc", + ) + return [OdooProductData.from_odoo(r) for r in records] + + def fetch_product_by_id(self, product_id: int) -> OdooProductData | None: + records = self.execute_kw( + "product.product", + "read", + [[product_id]], + {"fields": PRODUCT_FIELDS}, + ) + if not records: + return None + return OdooProductData.from_odoo(records[0]) + + def fetch_sale_orders(self, limit: int | None = None) -> list[OdooSaleOrderData]: + records = self.search_read( + "sale.order", + domain=[], + fields=SALE_ORDER_FIELDS, + limit=limit, + order="id asc", + ) + return [OdooSaleOrderData.from_odoo(r) for r in records] + + def fetch_sale_order_lines(self, line_ids: list[int]) -> list[OdooSaleOrderLineData]: + if not line_ids: + return [] + records = self.execute_kw( + "sale.order.line", + "read", + [line_ids], + {"fields": SALE_ORDER_LINE_FIELDS}, + ) + return [OdooSaleOrderLineData.from_odoo(r) for r in records] + + def fetch_contact_by_id(self, partner_id: int) -> OdooContactData | None: + records = self.execute_kw( + "res.partner", + "read", + [[partner_id]], + {"fields": CONTACT_FIELDS}, + ) + if not records: + return None + return OdooContactData.from_odoo(records[0]) + + def create_partner(self, payload: OdooPartnerCreate) -> OdooPartnerRead: + values = {"name": payload.name, "customer_rank": 1} + if payload.email: + values["email"] = payload.email + if payload.phone: + values["phone"] = payload.phone + + partner_id = self.execute_kw("res.partner", "create", [values]) + records = self.execute_kw( + "res.partner", + "read", + [[partner_id]], + {"fields": ["id", "name", "email", "phone"]}, + ) + record = records[0] + return OdooPartnerRead( + id=record["id"], + name=record["name"], + email=record.get("email") or None, + phone=record.get("phone") or None, + ) + + def list_partners(self, limit: int = 20) -> list[OdooPartnerRead]: + ids = self.execute_kw( + "res.partner", + "search", + [[("customer_rank", ">", 0)]], + {"limit": limit, "order": "id desc"}, + ) + if not ids: + return [] + records = self.execute_kw( + "res.partner", + "read", + [ids], + {"fields": ["id", "name", "email", "phone"]}, + ) + return [ + OdooPartnerRead( + id=r["id"], + name=r["name"], + email=r.get("email") or None, + phone=r.get("phone") or None, + ) + for r in records + ] + + def _create_sale_product(self, name: str, code: str, price: float) -> int: + template_id = self.execute_kw( + "product.template", + "create", + [{ + "name": name, + "default_code": code, + "list_price": price, + "type": "service", + "sale_ok": True, + }], + ) + variants = self.search_read( + "product.product", + domain=[("product_tmpl_id", "=", template_id)], + fields=["id"], + limit=1, + ) + return variants[0]["id"] + + def seed_demo_data(self) -> dict[str, int | list[int]]: + """Create sample contacts, products, and sale orders in Odoo.""" + partner_a = self.execute_kw( + "res.partner", + "create", + [{ + "name": "Acme Corp", + "email": "contact@acme.example", + "phone": "+1-555-0100", + "street": "123 Main St", + "city": "New York", + "customer_rank": 1, + "is_company": True, + }], + ) + partner_b = self.execute_kw( + "res.partner", + "create", + [{ + "name": "Jane Smith", + "email": "jane@example.com", + "phone": "+1-555-0200", + "customer_rank": 1, + }], + ) + + product_a = self._create_sale_product("Consulting Hours", "CONS-001", 150.0) + product_b = self._create_sale_product("Software License", "LIC-001", 999.0) + + order_id = self.execute_kw( + "sale.order", + "create", + [{ + "partner_id": partner_a, + "order_line": [ + (0, 0, {"product_id": product_a, "product_uom_qty": 10}), + (0, 0, {"product_id": product_b, "product_uom_qty": 2}), + ], + }], + ) + + order_b_id = self.execute_kw( + "sale.order", + "create", + [{ + "partner_id": partner_b, + "order_line": [ + (0, 0, {"product_id": product_a, "product_uom_qty": 5}), + ], + }], + ) + + return { + "partners": [partner_a, partner_b], + "products": [product_a, product_b], + "sale_orders": [order_id, order_b_id], + } + + +def get_odoo_client() -> OdooClient: + return OdooClient() diff --git a/fastapi-odoo/app/main.py b/fastapi-odoo/app/main.py new file mode 100644 index 0000000..a748696 --- /dev/null +++ b/fastapi-odoo/app/main.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI + +from app.api.router import api_router +from app.core.config import settings + + +def create_app() -> FastAPI: + application = FastAPI( + title=settings.APP_NAME, + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", + ) + application.include_router(api_router, prefix="/api/v1") + return application + + +app = create_app() diff --git a/fastapi-odoo/app/models/__init__.py b/fastapi-odoo/app/models/__init__.py new file mode 100644 index 0000000..e8c6191 --- /dev/null +++ b/fastapi-odoo/app/models/__init__.py @@ -0,0 +1,12 @@ +from app.models.contact import Contact +from app.models.item import Item +from app.models.product import Product +from app.models.sale_order import SaleOrder, SaleOrderLine + +__all__ = [ + "Contact", + "Item", + "Product", + "SaleOrder", + "SaleOrderLine", +] diff --git a/fastapi-odoo/app/models/contact.py b/fastapi-odoo/app/models/contact.py new file mode 100644 index 0000000..deb1240 --- /dev/null +++ b/fastapi-odoo/app/models/contact.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Contact(Base): + __tablename__ = "contacts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + odoo_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(64), nullable=True) + street: Mapped[str | None] = mapped_column(String(255), nullable=True) + city: Mapped[str | None] = mapped_column(String(128), nullable=True) + country: Mapped[str | None] = mapped_column(String(128), nullable=True) + is_company: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + sale_orders: Mapped[list["SaleOrder"]] = relationship( + "SaleOrder", + back_populates="contact", + ) diff --git a/fastapi-odoo/app/models/item.py b/fastapi-odoo/app/models/item.py new file mode 100644 index 0000000..8786914 --- /dev/null +++ b/fastapi-odoo/app/models/item.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + odoo_partner_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/fastapi-odoo/app/models/product.py b/fastapi-odoo/app/models/product.py new file mode 100644 index 0000000..f4c1b60 --- /dev/null +++ b/fastapi-odoo/app/models/product.py @@ -0,0 +1,35 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import Boolean, DateTime, Integer, Numeric, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Product(Base): + __tablename__ = "products" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + odoo_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + default_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + list_price: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0, nullable=False) + uom_name: Mapped[str | None] = mapped_column(String(64), nullable=True) + active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + sale_order_lines: Mapped[list["SaleOrderLine"]] = relationship( + "SaleOrderLine", + back_populates="product", + ) diff --git a/fastapi-odoo/app/models/sale_order.py b/fastapi-odoo/app/models/sale_order.py new file mode 100644 index 0000000..b2f6507 --- /dev/null +++ b/fastapi-odoo/app/models/sale_order.py @@ -0,0 +1,64 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class SaleOrder(Base): + __tablename__ = "sale_orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + odoo_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + contact_id: Mapped[int] = mapped_column(ForeignKey("contacts.id"), nullable=False, index=True) + state: Mapped[str] = mapped_column(String(32), nullable=False) + amount_total: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0, nullable=False) + date_order: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + contact: Mapped["Contact"] = relationship("Contact", back_populates="sale_orders") + lines: Mapped[list["SaleOrderLine"]] = relationship( + "SaleOrderLine", + back_populates="sale_order", + cascade="all, delete-orphan", + ) + + +class SaleOrderLine(Base): + __tablename__ = "sale_order_lines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + odoo_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False, index=True) + sale_order_id: Mapped[int] = mapped_column(ForeignKey("sale_orders.id"), nullable=False, index=True) + product_id: Mapped[int | None] = mapped_column(ForeignKey("products.id"), nullable=True, index=True) + name: Mapped[str] = mapped_column(String(512), nullable=False) + product_uom_qty: Mapped[Decimal] = mapped_column(Numeric(16, 4), default=0, nullable=False) + price_unit: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0, nullable=False) + price_subtotal: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + sale_order: Mapped["SaleOrder"] = relationship("SaleOrder", back_populates="lines") + product: Mapped["Product | None"] = relationship("Product", back_populates="sale_order_lines") diff --git a/fastapi-odoo/app/repositories/__init__.py b/fastapi-odoo/app/repositories/__init__.py new file mode 100644 index 0000000..0db88ae --- /dev/null +++ b/fastapi-odoo/app/repositories/__init__.py @@ -0,0 +1 @@ +"""Data access layer.""" diff --git a/fastapi-odoo/app/repositories/contact_repository.py b/fastapi-odoo/app/repositories/contact_repository.py new file mode 100644 index 0000000..9850cdd --- /dev/null +++ b/fastapi-odoo/app/repositories/contact_repository.py @@ -0,0 +1,39 @@ +from sqlalchemy.orm import Session + +from app.models.contact import Contact +from app.schemas.odoo_sync import OdooContactData + + +class ContactRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_by_odoo_id(self, odoo_id: int) -> Contact | None: + return self.db.query(Contact).filter(Contact.odoo_id == odoo_id).first() + + def list(self, skip: int = 0, limit: int = 100) -> list[Contact]: + return ( + self.db.query(Contact) + .order_by(Contact.id.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def upsert(self, data: OdooContactData) -> tuple[Contact, bool]: + contact = self.get_by_odoo_id(data.odoo_id) + created = contact is None + if created: + contact = Contact(odoo_id=data.odoo_id) + + contact.name = data.name + contact.email = data.email + contact.phone = data.phone + contact.street = data.street + contact.city = data.city + contact.country = data.country + contact.is_company = data.is_company + + self.db.add(contact) + self.db.flush() + return contact, created diff --git a/fastapi-odoo/app/repositories/item_repository.py b/fastapi-odoo/app/repositories/item_repository.py new file mode 100644 index 0000000..d4404d7 --- /dev/null +++ b/fastapi-odoo/app/repositories/item_repository.py @@ -0,0 +1,41 @@ +from sqlalchemy.orm import Session + +from app.models.item import Item +from app.schemas.item import ItemCreate, ItemUpdate + + +class ItemRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list(self, skip: int = 0, limit: int = 50) -> list[Item]: + return ( + self.db.query(Item) + .order_by(Item.id.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get(self, item_id: int) -> Item | None: + return self.db.get(Item, item_id) + + def create(self, payload: ItemCreate) -> Item: + item = Item(name=payload.name, description=payload.description) + self.db.add(item) + self.db.commit() + self.db.refresh(item) + return item + + def update(self, item: Item, payload: ItemUpdate) -> Item: + data = payload.model_dump(exclude_unset=True) + for key, value in data.items(): + setattr(item, key, value) + self.db.add(item) + self.db.commit() + self.db.refresh(item) + return item + + def delete(self, item: Item) -> None: + self.db.delete(item) + self.db.commit() diff --git a/fastapi-odoo/app/repositories/product_repository.py b/fastapi-odoo/app/repositories/product_repository.py new file mode 100644 index 0000000..e9fac46 --- /dev/null +++ b/fastapi-odoo/app/repositories/product_repository.py @@ -0,0 +1,37 @@ +from sqlalchemy.orm import Session + +from app.models.product import Product +from app.schemas.odoo_sync import OdooProductData + + +class ProductRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_by_odoo_id(self, odoo_id: int) -> Product | None: + return self.db.query(Product).filter(Product.odoo_id == odoo_id).first() + + def list(self, skip: int = 0, limit: int = 100) -> list[Product]: + return ( + self.db.query(Product) + .order_by(Product.id.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def upsert(self, data: OdooProductData) -> tuple[Product, bool]: + product = self.get_by_odoo_id(data.odoo_id) + created = product is None + if created: + product = Product(odoo_id=data.odoo_id) + + product.name = data.name + product.default_code = data.default_code + product.list_price = data.list_price + product.uom_name = data.uom_name + product.active = data.active + + self.db.add(product) + self.db.flush() + return product, created diff --git a/fastapi-odoo/app/repositories/sale_order_repository.py b/fastapi-odoo/app/repositories/sale_order_repository.py new file mode 100644 index 0000000..3f6de2a --- /dev/null +++ b/fastapi-odoo/app/repositories/sale_order_repository.py @@ -0,0 +1,76 @@ +from sqlalchemy.orm import Session, joinedload + +from app.models.sale_order import SaleOrder, SaleOrderLine +from app.schemas.odoo_sync import OdooSaleOrderData, OdooSaleOrderLineData + + +class SaleOrderRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_by_odoo_id(self, odoo_id: int) -> SaleOrder | None: + return self.db.query(SaleOrder).filter(SaleOrder.odoo_id == odoo_id).first() + + def list(self, skip: int = 0, limit: int = 100) -> list[SaleOrder]: + return ( + self.db.query(SaleOrder) + .options(joinedload(SaleOrder.lines)) + .order_by(SaleOrder.id.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get(self, order_id: int) -> SaleOrder | None: + return ( + self.db.query(SaleOrder) + .options(joinedload(SaleOrder.lines)) + .filter(SaleOrder.id == order_id) + .first() + ) + + def upsert(self, data: OdooSaleOrderData, contact_id: int) -> tuple[SaleOrder, bool]: + order = self.get_by_odoo_id(data.odoo_id) + created = order is None + if created: + order = SaleOrder(odoo_id=data.odoo_id) + + order.name = data.name + order.contact_id = contact_id + order.state = data.state + order.amount_total = data.amount_total + order.date_order = data.date_order + + self.db.add(order) + self.db.flush() + return order, created + + +class SaleOrderLineRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def get_by_odoo_id(self, odoo_id: int) -> SaleOrderLine | None: + return self.db.query(SaleOrderLine).filter(SaleOrderLine.odoo_id == odoo_id).first() + + def upsert( + self, + data: OdooSaleOrderLineData, + sale_order_id: int, + product_id: int | None, + ) -> tuple[SaleOrderLine, bool]: + line = self.get_by_odoo_id(data.odoo_id) + created = line is None + if created: + line = SaleOrderLine(odoo_id=data.odoo_id) + + line.sale_order_id = sale_order_id + line.product_id = product_id + line.name = data.name + line.product_uom_qty = data.product_uom_qty + line.price_unit = data.price_unit + line.price_subtotal = data.price_subtotal + + self.db.add(line) + self.db.flush() + return line, created diff --git a/fastapi-odoo/app/schemas/__init__.py b/fastapi-odoo/app/schemas/__init__.py new file mode 100644 index 0000000..f391682 --- /dev/null +++ b/fastapi-odoo/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas.""" diff --git a/fastapi-odoo/app/schemas/item.py b/fastapi-odoo/app/schemas/item.py new file mode 100644 index 0000000..f3d0de1 --- /dev/null +++ b/fastapi-odoo/app/schemas/item.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class ItemBase(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: str | None = None + + +class ItemCreate(ItemBase): + pass + + +class ItemUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + odoo_partner_id: int | None = None + + +class ItemRead(ItemBase): + model_config = ConfigDict(from_attributes=True) + + id: int + odoo_partner_id: int | None = None + created_at: datetime + updated_at: datetime diff --git a/fastapi-odoo/app/schemas/odoo.py b/fastapi-odoo/app/schemas/odoo.py new file mode 100644 index 0000000..159c47e --- /dev/null +++ b/fastapi-odoo/app/schemas/odoo.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + + +class OdooPartnerCreate(BaseModel): + name: str + email: str | None = None + phone: str | None = None + + +class OdooPartnerRead(BaseModel): + id: int + name: str + email: str | None = None + phone: str | None = None + + +class OdooHealth(BaseModel): + connected: bool + version: str | None = None + uid: int | None = None + detail: str | None = None diff --git a/fastapi-odoo/app/schemas/odoo_sync.py b/fastapi-odoo/app/schemas/odoo_sync.py new file mode 100644 index 0000000..3a68fc5 --- /dev/null +++ b/fastapi-odoo/app/schemas/odoo_sync.py @@ -0,0 +1,113 @@ +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, Field + + +def _many2one_id(value) -> int | None: + if not value: + return None + if isinstance(value, (list, tuple)): + return int(value[0]) + return int(value) + + +def _many2one_name(value) -> str | None: + if not value: + return None + if isinstance(value, (list, tuple)) and len(value) > 1: + return str(value[1]) + return None + + +class OdooContactData(BaseModel): + odoo_id: int + name: str + email: str | None = None + phone: str | None = None + street: str | None = None + city: str | None = None + country: str | None = None + is_company: bool = False + + @classmethod + def from_odoo(cls, record: dict) -> "OdooContactData": + return cls( + odoo_id=record["id"], + name=record.get("name") or "", + email=record.get("email") or None, + phone=record.get("phone") or None, + street=record.get("street") or None, + city=record.get("city") or None, + country=_many2one_name(record.get("country_id")), + is_company=bool(record.get("is_company")), + ) + + +class OdooProductData(BaseModel): + odoo_id: int + name: str + default_code: str | None = None + list_price: Decimal = Decimal("0") + uom_name: str | None = None + active: bool = True + + @classmethod + def from_odoo(cls, record: dict) -> "OdooProductData": + return cls( + odoo_id=record["id"], + name=record.get("name") or "", + default_code=record.get("default_code") or None, + list_price=Decimal(str(record.get("list_price") or 0)), + uom_name=_many2one_name(record.get("uom_id")), + active=bool(record.get("active", True)), + ) + + +class OdooSaleOrderData(BaseModel): + odoo_id: int + name: str + partner_odoo_id: int + state: str + amount_total: Decimal = Decimal("0") + date_order: datetime | None = None + line_odoo_ids: list[int] = Field(default_factory=list) + + @classmethod + def from_odoo(cls, record: dict) -> "OdooSaleOrderData": + date_order = record.get("date_order") + parsed_date = None + if date_order: + parsed_date = datetime.fromisoformat(str(date_order).replace("Z", "+00:00")) + + return cls( + odoo_id=record["id"], + name=record.get("name") or "", + partner_odoo_id=_many2one_id(record.get("partner_id")) or 0, + state=record.get("state") or "draft", + amount_total=Decimal(str(record.get("amount_total") or 0)), + date_order=parsed_date, + line_odoo_ids=list(record.get("order_line") or []), + ) + + +class OdooSaleOrderLineData(BaseModel): + odoo_id: int + order_odoo_id: int + product_odoo_id: int | None = None + name: str + product_uom_qty: Decimal = Decimal("0") + price_unit: Decimal = Decimal("0") + price_subtotal: Decimal = Decimal("0") + + @classmethod + def from_odoo(cls, record: dict) -> "OdooSaleOrderLineData": + return cls( + odoo_id=record["id"], + order_odoo_id=_many2one_id(record.get("order_id")) or 0, + product_odoo_id=_many2one_id(record.get("product_id")), + name=record.get("name") or "", + product_uom_qty=Decimal(str(record.get("product_uom_qty") or 0)), + price_unit=Decimal(str(record.get("price_unit") or 0)), + price_subtotal=Decimal(str(record.get("price_subtotal") or 0)), + ) diff --git a/fastapi-odoo/app/schemas/sync.py b/fastapi-odoo/app/schemas/sync.py new file mode 100644 index 0000000..42eb830 --- /dev/null +++ b/fastapi-odoo/app/schemas/sync.py @@ -0,0 +1,77 @@ +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + + +class ContactRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + odoo_id: int + name: str + email: str | None = None + phone: str | None = None + street: str | None = None + city: str | None = None + country: str | None = None + is_company: bool + created_at: datetime + updated_at: datetime + + +class ProductRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + odoo_id: int + name: str + default_code: str | None = None + list_price: Decimal + uom_name: str | None = None + active: bool + created_at: datetime + updated_at: datetime + + +class SaleOrderLineRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + odoo_id: int + sale_order_id: int + product_id: int | None = None + name: str + product_uom_qty: Decimal + price_unit: Decimal + price_subtotal: Decimal + created_at: datetime + updated_at: datetime + + +class SaleOrderRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + odoo_id: int + name: str + contact_id: int + state: str + amount_total: Decimal + date_order: datetime | None = None + created_at: datetime + updated_at: datetime + lines: list[SaleOrderLineRead] = Field(default_factory=list) + + +class SyncEntityResult(BaseModel): + created: int = 0 + updated: int = 0 + total: int = 0 + + +class SyncResult(BaseModel): + contacts: SyncEntityResult + products: SyncEntityResult + sale_orders: SyncEntityResult + sale_order_lines: SyncEntityResult diff --git a/fastapi-odoo/app/services/__init__.py b/fastapi-odoo/app/services/__init__.py new file mode 100644 index 0000000..7af21b5 --- /dev/null +++ b/fastapi-odoo/app/services/__init__.py @@ -0,0 +1 @@ +"""Business logic layer.""" diff --git a/fastapi-odoo/app/services/item_service.py b/fastapi-odoo/app/services/item_service.py new file mode 100644 index 0000000..8f5fde3 --- /dev/null +++ b/fastapi-odoo/app/services/item_service.py @@ -0,0 +1,48 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.integrations.odoo_client import OdooClient, OdooClientError +from app.models.item import Item +from app.repositories.item_repository import ItemRepository +from app.schemas.item import ItemCreate, ItemUpdate +from app.schemas.odoo import OdooPartnerCreate + + +class ItemService: + def __init__(self, db: Session, odoo: OdooClient | None = None) -> None: + self.repo = ItemRepository(db) + self.odoo = odoo or OdooClient() + + def list_items(self, skip: int = 0, limit: int = 50) -> list[Item]: + return self.repo.list(skip=skip, limit=limit) + + def get_item(self, item_id: int) -> Item: + item = self.repo.get(item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + return item + + def create_item(self, payload: ItemCreate) -> Item: + return self.repo.create(payload) + + def update_item(self, item_id: int, payload: ItemUpdate) -> Item: + item = self.get_item(item_id) + return self.repo.update(item, payload) + + def delete_item(self, item_id: int) -> None: + item = self.get_item(item_id) + self.repo.delete(item) + + def sync_item_to_odoo(self, item_id: int, email: str | None = None) -> Item: + item = self.get_item(item_id) + try: + partner = self.odoo.create_partner( + OdooPartnerCreate(name=item.name, email=email) + ) + except OdooClientError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + return self.repo.update(item, ItemUpdate(odoo_partner_id=partner.id)) diff --git a/fastapi-odoo/app/services/odoo_service.py b/fastapi-odoo/app/services/odoo_service.py new file mode 100644 index 0000000..5356eda --- /dev/null +++ b/fastapi-odoo/app/services/odoo_service.py @@ -0,0 +1,28 @@ +from app.integrations.odoo_client import OdooClient, OdooClientError +from app.schemas.odoo import OdooHealth, OdooPartnerCreate, OdooPartnerRead + + +class OdooService: + def __init__(self, client: OdooClient | None = None) -> None: + self.client = client or OdooClient() + + def health(self) -> OdooHealth: + return self.client.health() + + def list_partners(self, limit: int = 20) -> list[OdooPartnerRead]: + try: + return self.client.list_partners(limit=limit) + except OdooClientError as exc: + raise RuntimeError(str(exc)) from exc + + def create_partner(self, payload: OdooPartnerCreate) -> OdooPartnerRead: + try: + return self.client.create_partner(payload) + except OdooClientError as exc: + raise RuntimeError(str(exc)) from exc + + def seed_demo_data(self) -> dict[str, int | list[int]]: + try: + return self.client.seed_demo_data() + except OdooClientError as exc: + raise RuntimeError(str(exc)) from exc diff --git a/fastapi-odoo/app/services/sync_service.py b/fastapi-odoo/app/services/sync_service.py new file mode 100644 index 0000000..ec96120 --- /dev/null +++ b/fastapi-odoo/app/services/sync_service.py @@ -0,0 +1,141 @@ +from sqlalchemy.orm import Session + +from app.integrations.odoo_client import OdooClient, OdooClientError +from app.repositories.contact_repository import ContactRepository +from app.repositories.product_repository import ProductRepository +from app.repositories.sale_order_repository import SaleOrderLineRepository, SaleOrderRepository +from app.schemas.sync import SyncEntityResult, SyncResult + + +class SyncService: + def __init__(self, db: Session, odoo: OdooClient | None = None) -> None: + self.db = db + self.odoo = odoo or OdooClient() + self.contact_repo = ContactRepository(db) + self.product_repo = ProductRepository(db) + self.order_repo = SaleOrderRepository(db) + self.line_repo = SaleOrderLineRepository(db) + + def sync_all(self) -> SyncResult: + try: + contact_result = self._sync_contacts() + product_result = self._sync_products() + order_result, line_result = self._sync_sale_orders_and_lines() + self.db.commit() + return SyncResult( + contacts=contact_result, + products=product_result, + sale_orders=order_result, + sale_order_lines=line_result, + ) + except Exception: + self.db.rollback() + raise + + def _sync_contacts(self) -> SyncEntityResult: + created = updated = 0 + for data in self.odoo.fetch_contacts(): + _, is_created = self.contact_repo.upsert(data) + if is_created: + created += 1 + else: + updated += 1 + total = created + updated + return SyncEntityResult(created=created, updated=updated, total=total) + + def _sync_products(self) -> SyncEntityResult: + created = updated = 0 + for data in self.odoo.fetch_products(): + _, is_created = self.product_repo.upsert(data) + if is_created: + created += 1 + else: + updated += 1 + total = created + updated + return SyncEntityResult(created=created, updated=updated, total=total) + + def _sync_sale_orders_and_lines(self) -> tuple[SyncEntityResult, SyncEntityResult]: + order_created = order_updated = 0 + line_created = line_updated = 0 + + for order_data in self.odoo.fetch_sale_orders(): + contact = self.contact_repo.get_by_odoo_id(order_data.partner_odoo_id) + if not contact: + fetched = self.odoo.fetch_contact_by_id(order_data.partner_odoo_id) + if fetched: + contact, _ = self.contact_repo.upsert(fetched) + else: + continue + + _, is_order_created = self.order_repo.upsert(order_data, contact_id=contact.id) + if is_order_created: + order_created += 1 + else: + order_updated += 1 + + local_order = self.order_repo.get_by_odoo_id(order_data.odoo_id) + if not local_order: + continue + + lines = self.odoo.fetch_sale_order_lines(order_data.line_odoo_ids) + for line_data in lines: + product_id = None + if line_data.product_odoo_id: + product = self.product_repo.get_by_odoo_id(line_data.product_odoo_id) + if not product: + fetched_product = self.odoo.fetch_product_by_id(line_data.product_odoo_id) + if fetched_product: + product, _ = self.product_repo.upsert(fetched_product) + if product: + product_id = product.id + + _, is_line_created = self.line_repo.upsert( + line_data, + sale_order_id=local_order.id, + product_id=product_id, + ) + if is_line_created: + line_created += 1 + else: + line_updated += 1 + + order_total = order_created + order_updated + line_total = line_created + line_updated + return ( + SyncEntityResult(created=order_created, updated=order_updated, total=order_total), + SyncEntityResult(created=line_created, updated=line_updated, total=line_total), + ) + + def sync_contacts(self) -> SyncEntityResult: + try: + result = self._sync_contacts() + self.db.commit() + return result + except Exception: + self.db.rollback() + raise + + def sync_products(self) -> SyncEntityResult: + try: + result = self._sync_products() + self.db.commit() + return result + except Exception: + self.db.rollback() + raise + + def sync_sale_orders(self) -> SyncResult: + try: + contact_result = self._sync_contacts() + product_result = self._sync_products() + order_result, line_result = self._sync_sale_orders_and_lines() + self.db.commit() + return SyncResult( + contacts=contact_result, + products=product_result, + sale_orders=order_result, + sale_order_lines=line_result, + ) + except Exception: + self.db.rollback() + raise diff --git a/fastapi-odoo/docker-compose.yml b/fastapi-odoo/docker-compose.yml new file mode 100644 index 0000000..b3edcb5 --- /dev/null +++ b/fastapi-odoo/docker-compose.yml @@ -0,0 +1,68 @@ +services: + db: + image: postgres:16-alpine + container_name: fastapi_odoo_db + restart: unless-stopped + env_file: .env + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/postgres/init-odoo-user.sql:/docker-entrypoint-initdb.d/init-odoo-user.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: + context: . + dockerfile: Dockerfile + container_name: fastapi_odoo_api + restart: unless-stopped + env_file: .env + ports: + - "8000:8000" + volumes: + - ./app:/app/app + - ./alembic:/app/alembic + - ./alembic.ini:/app/alembic.ini + depends_on: + db: + condition: service_healthy + command: > + sh -c "alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" + + odoo: + image: odoo:17.0 + container_name: fastapi_odoo_erp + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "8069:8069" + environment: + HOST: db + USER: ${ODOO_PG_USER} + PASSWORD: ${ODOO_PG_PASSWORD} + volumes: + - odoo_data:/var/lib/odoo + - ./docker/odoo/odoo.conf:/etc/odoo/odoo.conf:ro + - ./docker/odoo/addons:/mnt/extra-addons + command: > + odoo + --db_host=db + --db_user=${ODOO_PG_USER} + --db_password=${ODOO_PG_PASSWORD} + --admin-passwd=${ODOO_MASTER_PASSWORD} + +volumes: + postgres_data: + odoo_data: diff --git a/fastapi-odoo/docker/odoo/addons/.gitkeep b/fastapi-odoo/docker/odoo/addons/.gitkeep new file mode 100644 index 0000000..ce51110 --- /dev/null +++ b/fastapi-odoo/docker/odoo/addons/.gitkeep @@ -0,0 +1 @@ +# Place custom Odoo addons here. diff --git a/fastapi-odoo/docker/odoo/odoo.conf b/fastapi-odoo/docker/odoo/odoo.conf new file mode 100644 index 0000000..71efeb4 --- /dev/null +++ b/fastapi-odoo/docker/odoo/odoo.conf @@ -0,0 +1,11 @@ +[options] +admin_passwd = odoo +db_host = db +db_port = 5432 +db_user = odoo +db_password = odoo +addons_path = /mnt/extra-addons +data_dir = /var/lib/odoo +proxy_mode = False +list_db = True +xmlrpc_port = 8069 diff --git a/fastapi-odoo/docker/postgres/init-odoo-user.sql b/fastapi-odoo/docker/postgres/init-odoo-user.sql new file mode 100644 index 0000000..a452196 --- /dev/null +++ b/fastapi-odoo/docker/postgres/init-odoo-user.sql @@ -0,0 +1,8 @@ +-- Create a dedicated PostgreSQL role for Odoo (matches .env defaults). +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'odoo') THEN + CREATE ROLE odoo WITH LOGIN PASSWORD 'odoo' CREATEDB; + END IF; +END +$$; diff --git a/fastapi-odoo/requirements.txt b/fastapi-odoo/requirements.txt new file mode 100644 index 0000000..191c46f --- /dev/null +++ b/fastapi-odoo/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +alembic==1.14.0 +psycopg2-binary==2.9.10 +pydantic==2.10.3 +pydantic-settings==2.6.1 +python-dotenv==1.0.1 +httpx==0.28.1 From e3f7fa485c7d6575b88deefefa4a7a916d61ac96 Mon Sep 17 00:00:00 2001 From: alireza Sharzeh Date: Fri, 24 Jul 2026 01:40:14 +0330 Subject: [PATCH 2/6] fixed some docker setups and remove old models --- fastapi-odoo/Dockerfile | 2 +- fastapi-odoo/README.md | 75 ++++++++++++------- .../alembic/versions/0002_sync_entities.py | 4 + fastapi-odoo/app/api/deps.py | 5 -- fastapi-odoo/app/api/router.py | 3 +- fastapi-odoo/app/api/routes/items.py | 59 --------------- fastapi-odoo/app/api/routes/odoo.py | 24 +----- fastapi-odoo/app/integrations/odoo_client.py | 49 +----------- fastapi-odoo/app/models/__init__.py | 2 - fastapi-odoo/app/models/item.py | 26 ------- .../app/repositories/item_repository.py | 41 ---------- fastapi-odoo/app/schemas/item.py | 27 ------- fastapi-odoo/app/schemas/odoo.py | 13 ---- fastapi-odoo/app/services/item_service.py | 48 ------------ fastapi-odoo/app/services/odoo_service.py | 14 +--- fastapi-odoo/docker-compose.yml | 9 +-- 16 files changed, 59 insertions(+), 342 deletions(-) delete mode 100644 fastapi-odoo/app/api/routes/items.py delete mode 100644 fastapi-odoo/app/models/item.py delete mode 100644 fastapi-odoo/app/repositories/item_repository.py delete mode 100644 fastapi-odoo/app/schemas/item.py delete mode 100644 fastapi-odoo/app/services/item_service.py diff --git a/fastapi-odoo/Dockerfile b/fastapi-odoo/Dockerfile index e5ac23c..8caae3e 100644 --- a/fastapi-odoo/Dockerfile +++ b/fastapi-odoo/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim +FROM python:3.12-slim WORKDIR /app diff --git a/fastapi-odoo/README.md b/fastapi-odoo/README.md index 13d7488..c5c051f 100644 --- a/fastapi-odoo/README.md +++ b/fastapi-odoo/README.md @@ -4,11 +4,13 @@ FastAPI with layered architecture, PostgreSQL, SQLAlchemy/Alembic, and Odoo — ## Services -| Service | URL | Role | -|---------|-----|------| -| `api` | http://localhost:8000/docs | FastAPI app | -| `db` | localhost:5432 | PostgreSQL (shared) | -| `odoo` | http://localhost:8069 | Odoo 17 | + +| Service | URL | Role | +| ------- | -------------------------------------------------------- | ------------------- | +| `api` | [http://localhost:8000/docs](http://localhost:8000/docs) | FastAPI app | +| `db` | localhost:5432 | PostgreSQL (shared) | +| `odoo` | [http://localhost:8069](http://localhost:8069) | Odoo 17 | + ## Architecture @@ -24,41 +26,49 @@ app/ └── main.py ``` + + ## Start everything ```bash docker compose up --build ``` -1. Open **http://localhost:8069** and create an Odoo database named `odoo` (master password: `odoo` from `.env`). +1. Open **[http://localhost:8069](http://localhost:8069)** and create an Odoo database named `odoo` (master password: `odoo` from `.env`). 2. Set admin email/password (defaults expected by the API: `admin` / `admin` — or update `ODOO_USER` / `ODOO_PASSWORD` in `.env`). -3. Open **http://localhost:8000/docs** and try: - - `GET /api/v1/health` - - `GET /api/v1/odoo/health` - - `POST /api/v1/items` then `POST /api/v1/items/{id}/sync-odoo` +3. Open **[http://localhost:8080/docs](http://localhost:8000/docs)** and try: + - `GET /api/v1/health` + - `GET /api/v1/odoo/health` + - `POST /api/v1/odoo/seed-demo` then `/api/v1/sync` + + ## Odoo → FastAPI sync Entities are synced from Odoo by `odoo_id`. Existing records are **updated**, new ones are **created**. -| Odoo model | Local entity | -|------------|--------------| -| `res.partner` | `contacts` | -| `product.product` | `products` | -| `sale.order` | `sale_orders` | + +| Odoo model | Local entity | +| ----------------- | ------------------ | +| `res.partner` | `contacts` | +| `product.product` | `products` | +| `sale.order` | `sale_orders` | | `sale.order.line` | `sale_order_lines` | + + + ### Workflow 1. Start stack: `docker compose up --build` -2. Create Odoo DB at http://localhost:8069 (name: `odoo`, master pwd: `odoo`) -3. Install the **Sales** app in Odoo (Apps → Sales → Activate) +2. Create Odoo DB at [http://localhost:8069](http://localhost:8069) (name: `odoo`, master pwd: `odoo`) +3. Install the **Sales** app(or eCommerce app) in Odoo (Apps → Sales → Activate) 4. Seed demo data in Odoo: `POST /api/v1/odoo/seed-demo` 5. Sync into Postgres: `POST /api/v1/sync` 6. Read local data: - - `GET /api/v1/contacts` - - `GET /api/v1/products` - - `GET /api/v1/sale-orders` + - `GET /api/v1/contacts` + - `GET /api/v1/products` + - `GET /api/v1/sale-orders` Re-run `POST /api/v1/sync` anytime — changed Odoo records will update existing rows. @@ -66,15 +76,19 @@ Partial sync endpoints: `/api/v1/sync/contacts`, `/sync/products`, `/sync/sale-o ## Useful API routes -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/v1/health` | API liveness | -| GET | `/api/v1/odoo/health` | Odoo XML-RPC connectivity | -| POST | `/api/v1/odoo/seed-demo` | Create sample Odoo records | -| POST | `/api/v1/sync` | Full sync (upsert all entities) | -| GET | `/api/v1/contacts` | Local contacts | -| GET | `/api/v1/products` | Local products | -| GET | `/api/v1/sale-orders` | Local sale orders with lines | + +| Method | Path | Description | +| ------ | ------------------------ | ------------------------------- | +| GET | `/api/v1/health` | API liveness | +| GET | `/api/v1/odoo/health` | Odoo XML-RPC connectivity | +| POST | `/api/v1/odoo/seed-demo` | Create sample Odoo records | +| POST | `/api/v1/sync` | Full sync (upsert all entities) | +| GET | `/api/v1/contacts` | Local contacts | +| GET | `/api/v1/products` | Local products | +| GET | `/api/v1/sale-orders` | Local sale orders with lines | + + + ## Migrations @@ -83,6 +97,8 @@ docker compose exec api alembic revision --autogenerate -m "describe change" docker compose exec api alembic upgrade head ``` + + ## Local API (optional) ```bash @@ -94,3 +110,4 @@ copy .env.example .env alembic upgrade head uvicorn app.main:app --reload ``` + diff --git a/fastapi-odoo/alembic/versions/0002_sync_entities.py b/fastapi-odoo/alembic/versions/0002_sync_entities.py index 0d6bf4e..0201cc5 100644 --- a/fastapi-odoo/alembic/versions/0002_sync_entities.py +++ b/fastapi-odoo/alembic/versions/0002_sync_entities.py @@ -135,6 +135,10 @@ def upgrade() -> None: op.create_index(op.f("ix_sale_order_lines_odoo_id"), "sale_order_lines", ["odoo_id"], unique=True) op.create_index(op.f("ix_sale_order_lines_product_id"), "sale_order_lines", ["product_id"], unique=False) op.create_index(op.f("ix_sale_order_lines_sale_order_id"), "sale_order_lines", ["sale_order_id"], unique=False) + + op.drop_index(op.f("ix_items_name"), table_name="items") + op.drop_index(op.f("ix_items_id"), table_name="items") + op.drop_table("items") def downgrade() -> None: diff --git a/fastapi-odoo/app/api/deps.py b/fastapi-odoo/app/api/deps.py index 1b058b8..eb6cf1c 100644 --- a/fastapi-odoo/app/api/deps.py +++ b/fastapi-odoo/app/api/deps.py @@ -5,15 +5,10 @@ from app.core.database import get_db from app.integrations.odoo_client import OdooClient, get_odoo_client -from app.services.item_service import ItemService from app.services.odoo_service import OdooService from app.services.sync_service import SyncService -def get_item_service(db: Session = Depends(get_db)) -> Generator[ItemService, None, None]: - yield ItemService(db=db) - - def get_odoo_service( client: OdooClient = Depends(get_odoo_client), ) -> Generator[OdooService, None, None]: diff --git a/fastapi-odoo/app/api/router.py b/fastapi-odoo/app/api/router.py index d410645..bdee2e0 100644 --- a/fastapi-odoo/app/api/router.py +++ b/fastapi-odoo/app/api/router.py @@ -1,11 +1,10 @@ from fastapi import APIRouter -from app.api.routes import items, odoo, sync +from app.api.routes import odoo, sync api_router = APIRouter() api_router.include_router(odoo.router) api_router.include_router(sync.router) -api_router.include_router(items.router) @api_router.get("/health", tags=["health"]) diff --git a/fastapi-odoo/app/api/routes/items.py b/fastapi-odoo/app/api/routes/items.py deleted file mode 100644 index 49fa66a..0000000 --- a/fastapi-odoo/app/api/routes/items.py +++ /dev/null @@ -1,59 +0,0 @@ -from fastapi import APIRouter, Depends, Query, status - -from app.api.deps import get_item_service -from app.schemas.item import ItemCreate, ItemRead, ItemUpdate -from app.services.item_service import ItemService - -router = APIRouter(prefix="/items", tags=["items"]) - - -@router.get("", response_model=list[ItemRead]) -def list_items( - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=100), - service: ItemService = Depends(get_item_service), -) -> list[ItemRead]: - return service.list_items(skip=skip, limit=limit) - - -@router.post("", response_model=ItemRead, status_code=status.HTTP_201_CREATED) -def create_item( - payload: ItemCreate, - service: ItemService = Depends(get_item_service), -) -> ItemRead: - return service.create_item(payload) - - -@router.get("/{item_id}", response_model=ItemRead) -def get_item( - item_id: int, - service: ItemService = Depends(get_item_service), -) -> ItemRead: - return service.get_item(item_id) - - -@router.patch("/{item_id}", response_model=ItemRead) -def update_item( - item_id: int, - payload: ItemUpdate, - service: ItemService = Depends(get_item_service), -) -> ItemRead: - return service.update_item(item_id, payload) - - -@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_item( - item_id: int, - service: ItemService = Depends(get_item_service), -) -> None: - service.delete_item(item_id) - - -@router.post("/{item_id}/sync-odoo", response_model=ItemRead) -def sync_item_to_odoo( - item_id: int, - email: str | None = None, - service: ItemService = Depends(get_item_service), -) -> ItemRead: - """Create a matching res.partner in Odoo and store its id on the item.""" - return service.sync_item_to_odoo(item_id, email=email) diff --git a/fastapi-odoo/app/api/routes/odoo.py b/fastapi-odoo/app/api/routes/odoo.py index e55172d..ba6c308 100644 --- a/fastapi-odoo/app/api/routes/odoo.py +++ b/fastapi-odoo/app/api/routes/odoo.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from app.api.deps import get_odoo_service -from app.schemas.odoo import OdooHealth, OdooPartnerCreate, OdooPartnerRead +from app.schemas.odoo import OdooHealth from app.services.odoo_service import OdooService router = APIRouter(prefix="/odoo", tags=["odoo"]) @@ -10,25 +10,3 @@ @router.get("/health", response_model=OdooHealth) def odoo_health(service: OdooService = Depends(get_odoo_service)) -> OdooHealth: return service.health() - - -@router.get("/partners", response_model=list[OdooPartnerRead]) -def list_partners( - limit: int = Query(20, ge=1, le=100), - service: OdooService = Depends(get_odoo_service), -) -> list[OdooPartnerRead]: - try: - return service.list_partners(limit=limit) - except RuntimeError as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc - - -@router.post("/partners", response_model=OdooPartnerRead, status_code=status.HTTP_201_CREATED) -def create_partner( - payload: OdooPartnerCreate, - service: OdooService = Depends(get_odoo_service), -) -> OdooPartnerRead: - try: - return service.create_partner(payload) - except RuntimeError as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc diff --git a/fastapi-odoo/app/integrations/odoo_client.py b/fastapi-odoo/app/integrations/odoo_client.py index 287e8f8..6c9c693 100644 --- a/fastapi-odoo/app/integrations/odoo_client.py +++ b/fastapi-odoo/app/integrations/odoo_client.py @@ -1,7 +1,7 @@ from xmlrpc import client as xmlrpc_client from app.core.config import settings -from app.schemas.odoo import OdooHealth, OdooPartnerCreate, OdooPartnerRead +from app.schemas.odoo import OdooHealth from app.schemas.odoo_sync import ( OdooContactData, OdooProductData, @@ -216,53 +216,6 @@ def fetch_contact_by_id(self, partner_id: int) -> OdooContactData | None: return None return OdooContactData.from_odoo(records[0]) - def create_partner(self, payload: OdooPartnerCreate) -> OdooPartnerRead: - values = {"name": payload.name, "customer_rank": 1} - if payload.email: - values["email"] = payload.email - if payload.phone: - values["phone"] = payload.phone - - partner_id = self.execute_kw("res.partner", "create", [values]) - records = self.execute_kw( - "res.partner", - "read", - [[partner_id]], - {"fields": ["id", "name", "email", "phone"]}, - ) - record = records[0] - return OdooPartnerRead( - id=record["id"], - name=record["name"], - email=record.get("email") or None, - phone=record.get("phone") or None, - ) - - def list_partners(self, limit: int = 20) -> list[OdooPartnerRead]: - ids = self.execute_kw( - "res.partner", - "search", - [[("customer_rank", ">", 0)]], - {"limit": limit, "order": "id desc"}, - ) - if not ids: - return [] - records = self.execute_kw( - "res.partner", - "read", - [ids], - {"fields": ["id", "name", "email", "phone"]}, - ) - return [ - OdooPartnerRead( - id=r["id"], - name=r["name"], - email=r.get("email") or None, - phone=r.get("phone") or None, - ) - for r in records - ] - def _create_sale_product(self, name: str, code: str, price: float) -> int: template_id = self.execute_kw( "product.template", diff --git a/fastapi-odoo/app/models/__init__.py b/fastapi-odoo/app/models/__init__.py index e8c6191..f896c0a 100644 --- a/fastapi-odoo/app/models/__init__.py +++ b/fastapi-odoo/app/models/__init__.py @@ -1,11 +1,9 @@ from app.models.contact import Contact -from app.models.item import Item from app.models.product import Product from app.models.sale_order import SaleOrder, SaleOrderLine __all__ = [ "Contact", - "Item", "Product", "SaleOrder", "SaleOrderLine", diff --git a/fastapi-odoo/app/models/item.py b/fastapi-odoo/app/models/item.py deleted file mode 100644 index 8786914..0000000 --- a/fastapi-odoo/app/models/item.py +++ /dev/null @@ -1,26 +0,0 @@ -from datetime import datetime - -from sqlalchemy import DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - -from app.core.database import Base - - -class Item(Base): - __tablename__ = "items" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) - name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - odoo_partner_id: Mapped[int | None] = mapped_column(Integer, nullable=True) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - nullable=False, - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) diff --git a/fastapi-odoo/app/repositories/item_repository.py b/fastapi-odoo/app/repositories/item_repository.py deleted file mode 100644 index d4404d7..0000000 --- a/fastapi-odoo/app/repositories/item_repository.py +++ /dev/null @@ -1,41 +0,0 @@ -from sqlalchemy.orm import Session - -from app.models.item import Item -from app.schemas.item import ItemCreate, ItemUpdate - - -class ItemRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def list(self, skip: int = 0, limit: int = 50) -> list[Item]: - return ( - self.db.query(Item) - .order_by(Item.id.desc()) - .offset(skip) - .limit(limit) - .all() - ) - - def get(self, item_id: int) -> Item | None: - return self.db.get(Item, item_id) - - def create(self, payload: ItemCreate) -> Item: - item = Item(name=payload.name, description=payload.description) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def update(self, item: Item, payload: ItemUpdate) -> Item: - data = payload.model_dump(exclude_unset=True) - for key, value in data.items(): - setattr(item, key, value) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def delete(self, item: Item) -> None: - self.db.delete(item) - self.db.commit() diff --git a/fastapi-odoo/app/schemas/item.py b/fastapi-odoo/app/schemas/item.py deleted file mode 100644 index f3d0de1..0000000 --- a/fastapi-odoo/app/schemas/item.py +++ /dev/null @@ -1,27 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field - - -class ItemBase(BaseModel): - name: str = Field(..., min_length=1, max_length=255) - description: str | None = None - - -class ItemCreate(ItemBase): - pass - - -class ItemUpdate(BaseModel): - name: str | None = Field(default=None, min_length=1, max_length=255) - description: str | None = None - odoo_partner_id: int | None = None - - -class ItemRead(ItemBase): - model_config = ConfigDict(from_attributes=True) - - id: int - odoo_partner_id: int | None = None - created_at: datetime - updated_at: datetime diff --git a/fastapi-odoo/app/schemas/odoo.py b/fastapi-odoo/app/schemas/odoo.py index 159c47e..b161bf6 100644 --- a/fastapi-odoo/app/schemas/odoo.py +++ b/fastapi-odoo/app/schemas/odoo.py @@ -1,19 +1,6 @@ from pydantic import BaseModel -class OdooPartnerCreate(BaseModel): - name: str - email: str | None = None - phone: str | None = None - - -class OdooPartnerRead(BaseModel): - id: int - name: str - email: str | None = None - phone: str | None = None - - class OdooHealth(BaseModel): connected: bool version: str | None = None diff --git a/fastapi-odoo/app/services/item_service.py b/fastapi-odoo/app/services/item_service.py deleted file mode 100644 index 8f5fde3..0000000 --- a/fastapi-odoo/app/services/item_service.py +++ /dev/null @@ -1,48 +0,0 @@ -from fastapi import HTTPException, status -from sqlalchemy.orm import Session - -from app.integrations.odoo_client import OdooClient, OdooClientError -from app.models.item import Item -from app.repositories.item_repository import ItemRepository -from app.schemas.item import ItemCreate, ItemUpdate -from app.schemas.odoo import OdooPartnerCreate - - -class ItemService: - def __init__(self, db: Session, odoo: OdooClient | None = None) -> None: - self.repo = ItemRepository(db) - self.odoo = odoo or OdooClient() - - def list_items(self, skip: int = 0, limit: int = 50) -> list[Item]: - return self.repo.list(skip=skip, limit=limit) - - def get_item(self, item_id: int) -> Item: - item = self.repo.get(item_id) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") - return item - - def create_item(self, payload: ItemCreate) -> Item: - return self.repo.create(payload) - - def update_item(self, item_id: int, payload: ItemUpdate) -> Item: - item = self.get_item(item_id) - return self.repo.update(item, payload) - - def delete_item(self, item_id: int) -> None: - item = self.get_item(item_id) - self.repo.delete(item) - - def sync_item_to_odoo(self, item_id: int, email: str | None = None) -> Item: - item = self.get_item(item_id) - try: - partner = self.odoo.create_partner( - OdooPartnerCreate(name=item.name, email=email) - ) - except OdooClientError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc - - return self.repo.update(item, ItemUpdate(odoo_partner_id=partner.id)) diff --git a/fastapi-odoo/app/services/odoo_service.py b/fastapi-odoo/app/services/odoo_service.py index 5356eda..8555764 100644 --- a/fastapi-odoo/app/services/odoo_service.py +++ b/fastapi-odoo/app/services/odoo_service.py @@ -1,5 +1,5 @@ from app.integrations.odoo_client import OdooClient, OdooClientError -from app.schemas.odoo import OdooHealth, OdooPartnerCreate, OdooPartnerRead +from app.schemas.odoo import OdooHealth class OdooService: @@ -9,18 +9,6 @@ def __init__(self, client: OdooClient | None = None) -> None: def health(self) -> OdooHealth: return self.client.health() - def list_partners(self, limit: int = 20) -> list[OdooPartnerRead]: - try: - return self.client.list_partners(limit=limit) - except OdooClientError as exc: - raise RuntimeError(str(exc)) from exc - - def create_partner(self, payload: OdooPartnerCreate) -> OdooPartnerRead: - try: - return self.client.create_partner(payload) - except OdooClientError as exc: - raise RuntimeError(str(exc)) from exc - def seed_demo_data(self) -> dict[str, int | list[int]]: try: return self.client.seed_demo_data() diff --git a/fastapi-odoo/docker-compose.yml b/fastapi-odoo/docker-compose.yml index b3edcb5..0548606 100644 --- a/fastapi-odoo/docker-compose.yml +++ b/fastapi-odoo/docker-compose.yml @@ -1,7 +1,7 @@ services: db: image: postgres:16-alpine - container_name: fastapi_odoo_db + container_name: db restart: unless-stopped env_file: .env environment: @@ -23,11 +23,11 @@ services: build: context: . dockerfile: Dockerfile - container_name: fastapi_odoo_api + container_name: fastapi restart: unless-stopped env_file: .env ports: - - "8000:8000" + - "8080:8000" volumes: - ./app:/app/app - ./alembic:/app/alembic @@ -41,7 +41,7 @@ services: odoo: image: odoo:17.0 - container_name: fastapi_odoo_erp + container_name: odoo_erp restart: unless-stopped depends_on: db: @@ -61,7 +61,6 @@ services: --db_host=db --db_user=${ODOO_PG_USER} --db_password=${ODOO_PG_PASSWORD} - --admin-passwd=${ODOO_MASTER_PASSWORD} volumes: postgres_data: From 102b14f283996b1aeb6f1ebe8c6e9631fc0eb950 Mon Sep 17 00:00:00 2001 From: alireza Sharzeh Date: Fri, 24 Jul 2026 01:51:20 +0330 Subject: [PATCH 3/6] initialize odoo with eCommerce module(no setup action needed) --- fastapi-odoo/alembic/env.py | 2 +- fastapi-odoo/docker-compose.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/fastapi-odoo/alembic/env.py b/fastapi-odoo/alembic/env.py index 2f28d31..6a7f04c 100644 --- a/fastapi-odoo/alembic/env.py +++ b/fastapi-odoo/alembic/env.py @@ -5,7 +5,7 @@ from app.core.config import settings from app.core.database import Base -from app.models import Contact, Item, Product, SaleOrder, SaleOrderLine # noqa: F401 +from app.models import Contact, Product, SaleOrder, SaleOrderLine # noqa: F401 config = context.config config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) diff --git a/fastapi-odoo/docker-compose.yml b/fastapi-odoo/docker-compose.yml index 0548606..8cc84c7 100644 --- a/fastapi-odoo/docker-compose.yml +++ b/fastapi-odoo/docker-compose.yml @@ -61,6 +61,9 @@ services: --db_host=db --db_user=${ODOO_PG_USER} --db_password=${ODOO_PG_PASSWORD} + -d odoo + -i website_sale + --without-demo=all volumes: postgres_data: From ec44abe41f55d18bac84503a265788a0f6f6cc40 Mon Sep 17 00:00:00 2001 From: alireza Sharzeh Date: Fri, 24 Jul 2026 15:47:25 +0330 Subject: [PATCH 4/6] added sync run table with its implementation --- fastapi-odoo/alembic/env.py | 2 +- ...275916f973dd_add_sync_run_and_sync_logs.py | 64 +++++++++++++++++++ fastapi-odoo/app/core/database.py | 4 +- fastapi-odoo/app/models/sync_run.py | 44 +++++++++++++ .../app/repositories/sync_run_repository.py | 34 ++++++++++ fastapi-odoo/app/schemas/sync.py | 11 ++++ fastapi-odoo/app/services/sync_service.py | 51 ++++++++++++++- 7 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 fastapi-odoo/alembic/versions/275916f973dd_add_sync_run_and_sync_logs.py create mode 100644 fastapi-odoo/app/models/sync_run.py create mode 100644 fastapi-odoo/app/repositories/sync_run_repository.py diff --git a/fastapi-odoo/alembic/env.py b/fastapi-odoo/alembic/env.py index 6a7f04c..c133069 100644 --- a/fastapi-odoo/alembic/env.py +++ b/fastapi-odoo/alembic/env.py @@ -5,7 +5,7 @@ from app.core.config import settings from app.core.database import Base -from app.models import Contact, Product, SaleOrder, SaleOrderLine # noqa: F401 +from app.models import Contact, Product, SaleOrder, SaleOrderLine, sync_run # noqa: F401 config = context.config config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) diff --git a/fastapi-odoo/alembic/versions/275916f973dd_add_sync_run_and_sync_logs.py b/fastapi-odoo/alembic/versions/275916f973dd_add_sync_run_and_sync_logs.py new file mode 100644 index 0000000..e16e940 --- /dev/null +++ b/fastapi-odoo/alembic/versions/275916f973dd_add_sync_run_and_sync_logs.py @@ -0,0 +1,64 @@ +"""Add sync_run and sync_logs + +Revision ID: 275916f973dd +Revises: 0002_sync_entities +Create Date: 2026-07-24 11:46:46.976764 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '275916f973dd' +down_revision: Union[str, None] = '0002_sync_entities' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sync_runs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sync_type', sa.String(length=255), nullable=False), + sa.Column('sync_start_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('sync_end_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('fetched_records', sa.Integer(), nullable=False), + sa.Column('stored_records', sa.Integer(), nullable=False), + sa.Column('updated_records', sa.Integer(), nullable=False), + sa.Column('error_records', sa.Integer(), nullable=False), + sa.Column('sync_error', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sync_runs_id'), 'sync_runs', ['id'], unique=False) + op.create_table('sync_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sync_run_id', sa.Integer(), nullable=False), + sa.Column('level', sa.String(length=255), nullable=False), + sa.Column('message', sa.String(length=255), nullable=False), + sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['sync_run_id'], ['sync_runs.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sync_logs_id'), 'sync_logs', ['id'], unique=False) + op.drop_constraint('contacts_odoo_id_key', 'contacts', type_='unique') + op.drop_constraint('products_odoo_id_key', 'products', type_='unique') + op.drop_constraint('sale_order_lines_odoo_id_key', 'sale_order_lines', type_='unique') + op.drop_constraint('sale_orders_odoo_id_key', 'sale_orders', type_='unique') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('sale_orders_odoo_id_key', 'sale_orders', ['odoo_id']) + op.create_unique_constraint('sale_order_lines_odoo_id_key', 'sale_order_lines', ['odoo_id']) + op.create_unique_constraint('products_odoo_id_key', 'products', ['odoo_id']) + op.create_unique_constraint('contacts_odoo_id_key', 'contacts', ['odoo_id']) + op.drop_index(op.f('ix_sync_logs_id'), table_name='sync_logs') + op.drop_table('sync_logs') + op.drop_index(op.f('ix_sync_runs_id'), table_name='sync_runs') + op.drop_table('sync_runs') + # ### end Alembic commands ### diff --git a/fastapi-odoo/app/core/database.py b/fastapi-odoo/app/core/database.py index 72cd7aa..24aa329 100644 --- a/fastapi-odoo/app/core/database.py +++ b/fastapi-odoo/app/core/database.py @@ -18,9 +18,9 @@ class Base(DeclarativeBase): pass -def get_db() -> Generator[Session, None, None]: +def get_db() -> Session: db = SessionLocal() try: - yield db + return db finally: db.close() diff --git a/fastapi-odoo/app/models/sync_run.py b/fastapi-odoo/app/models/sync_run.py new file mode 100644 index 0000000..e14cd68 --- /dev/null +++ b/fastapi-odoo/app/models/sync_run.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import JSONB + +from app.core.database import Base + + +class SyncRun(Base): + __tablename__ = "sync_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + sync_type: Mapped[str] = mapped_column(String(255), nullable=False) + sync_start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + sync_end_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True) + fetched_records: Mapped[int] = mapped_column(Integer, nullable=False) + stored_records: Mapped[int] = mapped_column(Integer, nullable=False) + updated_records: Mapped[int] = mapped_column(Integer, nullable=False) + error_records: Mapped[int] = mapped_column(Integer, nullable=False) + sync_error: Mapped[str | None] = mapped_column(String(255), nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + logs: Mapped[list["SyncLog"]] = relationship( + "SyncLog", + back_populates="sync_run", + ) + +class SyncLog(Base): + __tablename__ = "sync_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + sync_run_id: Mapped[int] = mapped_column(Integer, ForeignKey("sync_runs.id"), nullable=False) + sync_run: Mapped["SyncRun"] = relationship("SyncRun", back_populates="logs") + level: Mapped[str] = mapped_column(String(255), nullable=False) + message: Mapped[str] = mapped_column(String(255), nullable=False) + data: Mapped[dict] = mapped_column(JSONB, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/fastapi-odoo/app/repositories/sync_run_repository.py b/fastapi-odoo/app/repositories/sync_run_repository.py new file mode 100644 index 0000000..c05209d --- /dev/null +++ b/fastapi-odoo/app/repositories/sync_run_repository.py @@ -0,0 +1,34 @@ +from collections.abc import Generator +from fastapi import Depends +from sqlalchemy.orm import Session + + +from app.core.database import get_db +from app.models.sync_run import SyncRun +from app.schemas.sync import SyncRunCreate + + +class SyncRunRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list(self, skip: int = 0, limit: int = 50) -> list[SyncRun]: + return ( + self.db.query(SyncRun) + .order_by(SyncRun.id.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get(self, sync_run_id: int) -> SyncRun | None: + return self.db.get(SyncRun, sync_run_id) + + def create(self, payload: SyncRunCreate) -> SyncRun: + sync_run = SyncRun(sync_type=payload.sync_type, sync_start_time=payload.sync_start_time ,sync_end_time=payload.sync_end_time ,fetched_records=payload.fetched_records ,stored_records= payload.stored_records ,updated_records=payload.updated_records ,error_records=payload.error_records ,sync_error=payload.sync_error,) + self.db.add(sync_run) + self.db.commit() + self.db.refresh(sync_run) + return sync_run + +get_sync_run_repo = SyncRunRepository(db=get_db()) \ No newline at end of file diff --git a/fastapi-odoo/app/schemas/sync.py b/fastapi-odoo/app/schemas/sync.py index 42eb830..37e27b6 100644 --- a/fastapi-odoo/app/schemas/sync.py +++ b/fastapi-odoo/app/schemas/sync.py @@ -67,6 +67,7 @@ class SaleOrderRead(BaseModel): class SyncEntityResult(BaseModel): created: int = 0 updated: int = 0 + errored: int = 0 total: int = 0 @@ -75,3 +76,13 @@ class SyncResult(BaseModel): products: SyncEntityResult sale_orders: SyncEntityResult sale_order_lines: SyncEntityResult + +class SyncRunCreate(BaseModel): + sync_type: str = "sync_all" + sync_start_time: datetime + sync_end_time: datetime + fetched_records: int = 0 + stored_records: int = 0 + updated_records: int = 0 + error_records: int = 0 + sync_error: str | None = None diff --git a/fastapi-odoo/app/services/sync_service.py b/fastapi-odoo/app/services/sync_service.py index ec96120..af8b178 100644 --- a/fastapi-odoo/app/services/sync_service.py +++ b/fastapi-odoo/app/services/sync_service.py @@ -1,11 +1,52 @@ +from datetime import datetime +import functools +import time +from fastapi import Depends from sqlalchemy.orm import Session from app.integrations.odoo_client import OdooClient, OdooClientError from app.repositories.contact_repository import ContactRepository from app.repositories.product_repository import ProductRepository from app.repositories.sale_order_repository import SaleOrderLineRepository, SaleOrderRepository -from app.schemas.sync import SyncEntityResult, SyncResult - +from app.repositories.sync_run_repository import SyncRunRepository, get_sync_run_repo +from app.schemas.sync import SyncEntityResult, SyncResult, SyncRunCreate + + +def sync_run_decorator(sync_run_repo: SyncRunRepository = get_sync_run_repo): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + start = datetime.now() + sync_run = SyncRunCreate( + sync_type=func.__qualname__, + sync_start_time=start, + sync_end_time=start, + ) + try: + result = func(*args, **kwargs) + if isinstance(result, SyncEntityResult): + sync_run.fetched_records += result.total + sync_run.stored_records += result.created + sync_run.updated_records += result.updated + sync_run.error_records += result.errored + elif isinstance(result, SyncResult): + sync_run.fetched_records = result.contacts.total + result.products.total + result.sale_orders.total + result.sale_order_lines.total + sync_run.stored_records = result.contacts.created + result.products.created + result.sale_orders.created + result.sale_order_lines.created + sync_run.updated_records = result.contacts.updated + result.products.updated + result.sale_orders.updated + result.sale_order_lines.updated + sync_run.error_records = result.contacts.errored + result.products.errored + result.sale_orders.errored + result.sale_order_lines.errored + return result + + except Exception as e: + sync_run.sync_error = str(e) + raise + + finally: + sync_run.sync_end_time = datetime.now() + # save to sync_run repository + sync_run_repo.create(sync_run) + return wrapper + + return decorator class SyncService: def __init__(self, db: Session, odoo: OdooClient | None = None) -> None: @@ -15,7 +56,9 @@ def __init__(self, db: Session, odoo: OdooClient | None = None) -> None: self.product_repo = ProductRepository(db) self.order_repo = SaleOrderRepository(db) self.line_repo = SaleOrderLineRepository(db) + # self.sync_run_repo = SyncRunRepository(db) + @sync_run_decorator() def sync_all(self) -> SyncResult: try: contact_result = self._sync_contacts() @@ -28,6 +71,7 @@ def sync_all(self) -> SyncResult: sale_orders=order_result, sale_order_lines=line_result, ) + except Exception: self.db.rollback() raise @@ -106,6 +150,7 @@ def _sync_sale_orders_and_lines(self) -> tuple[SyncEntityResult, SyncEntityResul SyncEntityResult(created=line_created, updated=line_updated, total=line_total), ) + @sync_run_decorator() def sync_contacts(self) -> SyncEntityResult: try: result = self._sync_contacts() @@ -115,6 +160,7 @@ def sync_contacts(self) -> SyncEntityResult: self.db.rollback() raise + @sync_run_decorator() def sync_products(self) -> SyncEntityResult: try: result = self._sync_products() @@ -124,6 +170,7 @@ def sync_products(self) -> SyncEntityResult: self.db.rollback() raise + @sync_run_decorator() def sync_sale_orders(self) -> SyncResult: try: contact_result = self._sync_contacts() From 9b7907f8adb038ebeb220f113d75684c6497350c Mon Sep 17 00:00:00 2001 From: alireza Sharzeh Date: Fri, 24 Jul 2026 18:41:03 +0330 Subject: [PATCH 5/6] implemented sync log presist in DB --- ...275916f973dd_add_sync_run_and_sync_logs.py | 2 +- fastapi-odoo/app/models/sync_run.py | 6 +- .../app/repositories/sync_log_repository.py | 31 +++ .../app/repositories/sync_run_repository.py | 14 + fastapi-odoo/app/schemas/sync.py | 6 + fastapi-odoo/app/services/sync_service.py | 248 +++++++++++++----- 6 files changed, 237 insertions(+), 70 deletions(-) create mode 100644 fastapi-odoo/app/repositories/sync_log_repository.py diff --git a/fastapi-odoo/alembic/versions/275916f973dd_add_sync_run_and_sync_logs.py b/fastapi-odoo/alembic/versions/275916f973dd_add_sync_run_and_sync_logs.py index e16e940..9f69f32 100644 --- a/fastapi-odoo/alembic/versions/275916f973dd_add_sync_run_and_sync_logs.py +++ b/fastapi-odoo/alembic/versions/275916f973dd_add_sync_run_and_sync_logs.py @@ -39,7 +39,7 @@ def upgrade() -> None: sa.Column('level', sa.String(length=255), nullable=False), sa.Column('message', sa.String(length=255), nullable=False), sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.ForeignKeyConstraint(['sync_run_id'], ['sync_runs.id'], ), sa.PrimaryKeyConstraint('id') ) diff --git a/fastapi-odoo/app/models/sync_run.py b/fastapi-odoo/app/models/sync_run.py index e14cd68..80bd7ea 100644 --- a/fastapi-odoo/app/models/sync_run.py +++ b/fastapi-odoo/app/models/sync_run.py @@ -41,4 +41,8 @@ class SyncLog(Base): message: Mapped[str] = mapped_column(String(255), nullable=False) data: Mapped[dict] = mapped_column(JSONB, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) diff --git a/fastapi-odoo/app/repositories/sync_log_repository.py b/fastapi-odoo/app/repositories/sync_log_repository.py new file mode 100644 index 0000000..7d66950 --- /dev/null +++ b/fastapi-odoo/app/repositories/sync_log_repository.py @@ -0,0 +1,31 @@ +from sqlalchemy.orm import Session +from fastapi.encoders import jsonable_encoder + + +from app.core.database import get_db +from app.models.sync_run import SyncLog +from app.schemas.sync import SyncLogCreate + + +class SyncLogRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list(self, skip: int = 0, limit: int = 50) -> list[SyncLog]: + return ( + self.db.query(SyncLog) + .order_by(SyncLog.id.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get(self, sync_log_id: int) -> SyncLog | None: + return self.db.get(SyncLog, sync_log_id) + + def create(self, payload: SyncLogCreate) -> SyncLog: + sync_log = SyncLog(sync_run_id=payload.sync_run_id, level=payload.level ,message=payload.message ,data=jsonable_encoder(payload.data)) + self.db.add(sync_log) + self.db.commit() + self.db.refresh(sync_log) + return sync_log diff --git a/fastapi-odoo/app/repositories/sync_run_repository.py b/fastapi-odoo/app/repositories/sync_run_repository.py index c05209d..f4decde 100644 --- a/fastapi-odoo/app/repositories/sync_run_repository.py +++ b/fastapi-odoo/app/repositories/sync_run_repository.py @@ -30,5 +30,19 @@ def create(self, payload: SyncRunCreate) -> SyncRun: self.db.commit() self.db.refresh(sync_run) return sync_run + + def update(self, sync_run_id: int, payload: SyncRunCreate) -> SyncRun: + sync_run = self.db.get(SyncRun, sync_run_id) + + sync_run.sync_type = payload.sync_type + sync_run.sync_start_time = payload.sync_start_time + sync_run.sync_end_time = payload.sync_end_time + sync_run.fetched_records = payload.fetched_records + sync_run.stored_records = payload.stored_records + sync_run.updated_records = payload.updated_records + sync_run.error_records = payload.error_records + sync_run.sync_error = payload.sync_error + self.db.commit() + self.db.refresh(sync_run) get_sync_run_repo = SyncRunRepository(db=get_db()) \ No newline at end of file diff --git a/fastapi-odoo/app/schemas/sync.py b/fastapi-odoo/app/schemas/sync.py index 37e27b6..9e2ee75 100644 --- a/fastapi-odoo/app/schemas/sync.py +++ b/fastapi-odoo/app/schemas/sync.py @@ -86,3 +86,9 @@ class SyncRunCreate(BaseModel): updated_records: int = 0 error_records: int = 0 sync_error: str | None = None + +class SyncLogCreate(BaseModel): + sync_run_id: int = 1 + level: str = "log" + message: str = "" + data: dict diff --git a/fastapi-odoo/app/services/sync_service.py b/fastapi-odoo/app/services/sync_service.py index af8b178..32601e2 100644 --- a/fastapi-odoo/app/services/sync_service.py +++ b/fastapi-odoo/app/services/sync_service.py @@ -1,7 +1,5 @@ from datetime import datetime import functools -import time -from fastapi import Depends from sqlalchemy.orm import Session from app.integrations.odoo_client import OdooClient, OdooClientError @@ -9,7 +7,8 @@ from app.repositories.product_repository import ProductRepository from app.repositories.sale_order_repository import SaleOrderLineRepository, SaleOrderRepository from app.repositories.sync_run_repository import SyncRunRepository, get_sync_run_repo -from app.schemas.sync import SyncEntityResult, SyncResult, SyncRunCreate +from app.repositories.sync_log_repository import SyncLogRepository +from app.schemas.sync import SyncEntityResult, SyncLogCreate, SyncResult, SyncRunCreate def sync_run_decorator(sync_run_repo: SyncRunRepository = get_sync_run_repo): @@ -22,8 +21,11 @@ def wrapper(*args, **kwargs): sync_start_time=start, sync_end_time=start, ) + # insert to db for getting sync run id + db_sync_run=sync_run_repo.create(sync_run) + try: - result = func(*args, **kwargs) + result = func(*args, **kwargs, run_id=db_sync_run.id) if isinstance(result, SyncEntityResult): sync_run.fetched_records += result.total sync_run.stored_records += result.created @@ -42,8 +44,8 @@ def wrapper(*args, **kwargs): finally: sync_run.sync_end_time = datetime.now() - # save to sync_run repository - sync_run_repo.create(sync_run) + # update to sync_run repository + sync_run_repo.update(db_sync_run.id, sync_run) return wrapper return decorator @@ -56,14 +58,14 @@ def __init__(self, db: Session, odoo: OdooClient | None = None) -> None: self.product_repo = ProductRepository(db) self.order_repo = SaleOrderRepository(db) self.line_repo = SaleOrderLineRepository(db) - # self.sync_run_repo = SyncRunRepository(db) + self.sync_log_repo = SyncLogRepository(db) @sync_run_decorator() - def sync_all(self) -> SyncResult: + def sync_all(self, run_id: int) -> SyncResult: try: - contact_result = self._sync_contacts() - product_result = self._sync_products() - order_result, line_result = self._sync_sale_orders_and_lines() + contact_result = self._sync_contacts(run_id) + product_result = self._sync_products(run_id) + order_result, line_result = self._sync_sale_orders_and_lines(run_id) self.db.commit() return SyncResult( contacts=contact_result, @@ -72,50 +74,133 @@ def sync_all(self) -> SyncResult: sale_order_lines=line_result, ) - except Exception: + except Exception as exc: + print(exc) self.db.rollback() - raise + raise exc - def _sync_contacts(self) -> SyncEntityResult: - created = updated = 0 + def _sync_contacts(self, run_id: int) -> SyncEntityResult: + created = updated = errored = 0 for data in self.odoo.fetch_contacts(): - _, is_created = self.contact_repo.upsert(data) - if is_created: - created += 1 - else: - updated += 1 - total = created + updated - return SyncEntityResult(created=created, updated=updated, total=total) - - def _sync_products(self) -> SyncEntityResult: - created = updated = 0 + try: + _, is_created = self.contact_repo.upsert(data) + if is_created: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="success", + message="contact added to app db", + data=data.model_dump() + ) + ) + created += 1 + else: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="success", + message="contact updated in app db", + data=data.model_dump() + ) + ) + updated += 1 + except Exception as exc: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="error", + message="failed to upsert contact in app db: " + str(exc), + data=data.model_dump() + ) + ) + errored +=1 + total = created + updated + errored + return SyncEntityResult(created=created, updated=updated, errored=errored, total=total) + + def _sync_products(self, run_id: int) -> SyncEntityResult: + created = updated = errored = 0 for data in self.odoo.fetch_products(): - _, is_created = self.product_repo.upsert(data) - if is_created: - created += 1 - else: - updated += 1 - total = created + updated - return SyncEntityResult(created=created, updated=updated, total=total) - - def _sync_sale_orders_and_lines(self) -> tuple[SyncEntityResult, SyncEntityResult]: - order_created = order_updated = 0 - line_created = line_updated = 0 + try: + _, is_created = self.product_repo.upsert(data) + if is_created: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="success", + message="product added to app db", + data=data.model_dump() + ) + ) + created += 1 + else: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="success", + message="product updated in app db", + data=data.model_dump() + ) + ) + updated += 1 + except Exception as exc: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="error", + message="failed to upsert product in app db: " + str(exc), + data=data.model_dump() + ) + ) + errored +=1 + total = created + updated + errored + return SyncEntityResult(created=created, updated=updated, errored=errored, total=total) + + def _sync_sale_orders_and_lines(self, run_id: int) -> tuple[SyncEntityResult, SyncEntityResult]: + order_created = order_updated = order_errored = 0 + line_created = line_updated = line_errored = 0 for order_data in self.odoo.fetch_sale_orders(): - contact = self.contact_repo.get_by_odoo_id(order_data.partner_odoo_id) - if not contact: - fetched = self.odoo.fetch_contact_by_id(order_data.partner_odoo_id) - if fetched: - contact, _ = self.contact_repo.upsert(fetched) - else: - continue + try: + contact = self.contact_repo.get_by_odoo_id(order_data.partner_odoo_id) + if not contact: + fetched = self.odoo.fetch_contact_by_id(order_data.partner_odoo_id) + if fetched: + contact, _ = self.contact_repo.upsert(fetched) + else: + raise Exception("contact not found in local or odoo server") - _, is_order_created = self.order_repo.upsert(order_data, contact_id=contact.id) - if is_order_created: - order_created += 1 - else: - order_updated += 1 + _, is_order_created = self.order_repo.upsert(order_data, contact_id=contact.id) + if is_order_created: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="success", + message="order added to app db", + data=order_data.model_dump() + ) + ) + order_created += 1 + else: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="success", + message="order updated in app db", + data=order_data.model_dump() + ) + ) + order_updated += 1 + except Exception as exc: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="error", + message="failed to upsert order in app db: " + str(exc), + data=order_data.model_dump() + ) + ) + errored +=1 + continue local_order = self.order_repo.get_by_odoo_id(order_data.odoo_id) if not local_order: @@ -133,27 +218,54 @@ def _sync_sale_orders_and_lines(self) -> tuple[SyncEntityResult, SyncEntityResul if product: product_id = product.id - _, is_line_created = self.line_repo.upsert( - line_data, - sale_order_id=local_order.id, - product_id=product_id, - ) - if is_line_created: - line_created += 1 - else: - line_updated += 1 + try: + _, is_line_created = self.line_repo.upsert( + line_data, + sale_order_id=local_order.id, + product_id=product_id, + ) + if is_line_created: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="success", + message="order line added to app db", + data=line_data.model_dump() + ) + ) + line_created += 1 + else: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="success", + message="order line updated in app db", + data=line_data.model_dump() + ) + ) + line_updated += 1 + except Exception as exc: + self.sync_log_repo.create( + SyncLogCreate( + sync_run_id=run_id, + level="error", + message="failed to upsert order line in app db: " + str(exc), + data=line_data.model_dump() + ) + ) + line_errored += 1 - order_total = order_created + order_updated - line_total = line_created + line_updated + order_total = order_created + order_updated + order_errored + line_total = line_created + line_updated + line_errored return ( - SyncEntityResult(created=order_created, updated=order_updated, total=order_total), - SyncEntityResult(created=line_created, updated=line_updated, total=line_total), + SyncEntityResult(created=order_created, updated=order_updated, errored=order_errored, total=order_total), + SyncEntityResult(created=line_created, updated=line_updated, errored=line_errored, total=line_total), ) @sync_run_decorator() - def sync_contacts(self) -> SyncEntityResult: + def sync_contacts(self, run_id: int) -> SyncEntityResult: try: - result = self._sync_contacts() + result = self._sync_contacts(run_id) self.db.commit() return result except Exception: @@ -161,9 +273,9 @@ def sync_contacts(self) -> SyncEntityResult: raise @sync_run_decorator() - def sync_products(self) -> SyncEntityResult: + def sync_products(self, run_id: int) -> SyncEntityResult: try: - result = self._sync_products() + result = self._sync_products(run_id) self.db.commit() return result except Exception: @@ -171,10 +283,10 @@ def sync_products(self) -> SyncEntityResult: raise @sync_run_decorator() - def sync_sale_orders(self) -> SyncResult: + def sync_sale_orders(self, run_id: int) -> SyncResult: try: - contact_result = self._sync_contacts() - product_result = self._sync_products() + contact_result = self._sync_contacts(run_id) + product_result = self._sync_products(run_id) order_result, line_result = self._sync_sale_orders_and_lines() self.db.commit() return SyncResult( From 64e0ee645a330689dc3d92be991a53c7bf727c81 Mon Sep 17 00:00:00 2001 From: alireza Sharzeh Date: Fri, 24 Jul 2026 20:18:40 +0330 Subject: [PATCH 6/6] updated ReadME for additional technical documentations --- fastapi-odoo/README.md | 67 +++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/fastapi-odoo/README.md b/fastapi-odoo/README.md index c5c051f..4aa0e7d 100644 --- a/fastapi-odoo/README.md +++ b/fastapi-odoo/README.md @@ -7,26 +7,12 @@ FastAPI with layered architecture, PostgreSQL, SQLAlchemy/Alembic, and Odoo — | Service | URL | Role | | ------- | -------------------------------------------------------- | ------------------- | -| `api` | [http://localhost:8000/docs](http://localhost:8000/docs) | FastAPI app | -| `db` | localhost:5432 | PostgreSQL (shared) | +| `api` | [http://localhost:8000/docs](http://localhost:8080/docs) | FastAPI app | +| `db` | [http://localhost:5432](http://localhost:5432) | PostgreSQL (shared) | | `odoo` | [http://localhost:8069](http://localhost:8069) | Odoo 17 | -## Architecture - -``` -app/ -├── api/ # Routers + DI -├── schemas/ # Pydantic models -├── services/ # Business logic -├── repositories/ # SQLAlchemy data access -├── models/ # ORM entities -├── integrations/ # Odoo XML-RPC client -├── core/ # Settings + DB session -└── main.py -``` - - +# User Manual ## Start everything @@ -42,6 +28,22 @@ docker compose up --build - `POST /api/v1/odoo/seed-demo` then `/api/v1/sync` +# Technical Docs + +## Architecture + +``` +app/ +├── api/ # Routers + DI +├── schemas/ # Pydantic models +├── services/ # Business logic +├── repositories/ # SQLAlchemy data access +├── models/ # ORM entities +├── integrations/ # Odoo XML-RPC client +├── core/ # Settings + DB session +└── main.py +``` + ## Odoo → FastAPI sync @@ -61,11 +63,10 @@ Entities are synced from Odoo by `odoo_id`. Existing records are **updated**, ne ### Workflow 1. Start stack: `docker compose up --build` -2. Create Odoo DB at [http://localhost:8069](http://localhost:8069) (name: `odoo`, master pwd: `odoo`) -3. Install the **Sales** app(or eCommerce app) in Odoo (Apps → Sales → Activate) -4. Seed demo data in Odoo: `POST /api/v1/odoo/seed-demo` -5. Sync into Postgres: `POST /api/v1/sync` -6. Read local data: +2. Will create Odoo DB and activate the eCommerce in it at [http://localhost:8069](http://localhost:8069) +3. Seed demo data in Odoo: `POST /api/v1/odoo/seed-demo` +4. Sync into Postgres: `POST /api/v1/sync` +5. Read local data: - `GET /api/v1/contacts` - `GET /api/v1/products` - `GET /api/v1/sale-orders` @@ -90,8 +91,24 @@ Partial sync endpoints: `/api/v1/sync/contacts`, `/sync/products`, `/sync/sale-o +## BreakeThrought + +In this project we implemented an automated setup of **odoo server** for a ***eCommerce*** website(within docker compose) and a **fastAPI backend** that can fetch its data including contacts, products, sale orders and sale order items(lines). for database we used a **postgresql** that host two db, one for odoo server and one for fastAPI service. + +We used **SQLAlchemy** for ORM and **alembic** for migration managments. also used pydantic for better validation over tranfering data(DTOs). + +We implemented a `XML-RPC client` for connecting to **odoo server** from our backend. for seeding demo data to our odoo server we implemented an additional method that stores *hard coded* demo data for all requested entities in the odoo client. + +For syncing the data between **odoo server** and our **FastAPI** service we have a serive named `sync_service`and added routes to call this service`POST /api/v1/sync`. we can also implement a scheduled job(cronjob, apscheduler, fastapi scheduler, Celery) to call this service periodically or call it when some event happened, also there is some partial sync methods for when you know what part is not in sync between backend and odoo(based on the event that happened). + +Syncing process is a step by step process, in the first we have to sync contacts and product(cant sync sale orders first) so when they are synced we can move to sale orders and for each of them can sync ther order items(lines). after syncing all of sale orders and their items syncing process is finished. by this knowledge in the partial sync of sale orders we should first sync the contacts and products first(prevent not exist refernces). + +In the sync process we have two log mecanism, one for traking sync requests named `sync_run`, that work as a decorator that wrapes the public methods of `sync_service`(`sync_all`, `sync_contacts` and etc), the other one that persist logs about each records syncing named `sync_log`, and also related to the `sync_run`. it stores data about each record fetched from odoo and what happend for it in our backend system(insert, update or failed) and have been implemeted in the private methods of `sync_service` that operates on records on after another. + ## Migrations +Any time you made achange to your models or add a new one first make sure it is imported in `fastapi-odoo\alembic\env.py` and the run the folowing commands for auto generation of migration and apply that migration to your database. + ```bash docker compose exec api alembic revision --autogenerate -m "describe change" docker compose exec api alembic upgrade head @@ -101,11 +118,13 @@ docker compose exec api alembic upgrade head ## Local API (optional) +If you wish to start the fastAPI app without the docker(deploy localy) run following commands in the terminal + ```bash python -m venv .venv -.venv\Scripts\activate +source .venv\bin\activate pip install -r requirements.txt -copy .env.example .env +cp .env.example .env # Point DATABASE_URL at localhost, ODOO_HOST at localhost alembic upgrade head uvicorn app.main:app --reload