Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions fastapi-odoo/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.git
.env
.venv
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
.ruff_cache
*.egg-info
.DS_Store
27 changes: 27 additions & 0 deletions fastapi-odoo/.env.example
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions fastapi-odoo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.env
.idea/
.vscode/
*.log
23 changes: 23 additions & 0 deletions fastapi-odoo/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.12-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"]
132 changes: 132 additions & 0 deletions fastapi-odoo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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](http://localhost:8080/docs) | FastAPI app |
| `db` | [http://localhost:5432](http://localhost:5432) | PostgreSQL (shared) |
| `odoo` | [http://localhost:8069](http://localhost:8069) | Odoo 17 |


# User Manual

## Start everything

```bash
docker compose up --build
```

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


# 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

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

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 |




## 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
```



## 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
source .venv\bin\activate
pip install -r requirements.txt
cp .env.example .env
# Point DATABASE_URL at localhost, ODOO_HOST at localhost
alembic upgrade head
uvicorn app.main:app --reload
```

42 changes: 42 additions & 0 deletions fastapi-odoo/alembic.ini
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions fastapi-odoo/alembic/env.py
Original file line number Diff line number Diff line change
@@ -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, Product, SaleOrder, SaleOrderLine, sync_run # 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()
25 changes: 25 additions & 0 deletions fastapi-odoo/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -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"}
47 changes: 47 additions & 0 deletions fastapi-odoo/alembic/versions/0001_create_items.py
Original file line number Diff line number Diff line change
@@ -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")
Loading