diff --git a/fastapi-odoo/.env.example b/fastapi-odoo/.env.example new file mode 100644 index 0000000..067e01d --- /dev/null +++ b/fastapi-odoo/.env.example @@ -0,0 +1,9 @@ +APP_NAME=odoo-sync-service +APP_ENV=development + +DATABASE_URL=postgresql+psycopg://app:app@localhost:5432/odoo_sync + +ODOO_URL=http://localhost:8069 +ODOO_DB=odoo_test +ODOO_USERNAME= +ODOO_PASSWORD= \ No newline at end of file diff --git a/fastapi-odoo/.gitignore b/fastapi-odoo/.gitignore new file mode 100644 index 0000000..8ecf843 --- /dev/null +++ b/fastapi-odoo/.gitignore @@ -0,0 +1,15 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ + +.env +.env.docker + +.idea/ +.vscode/ + +.mypy_cache/ +.ruff_cache/ \ No newline at end of file diff --git a/fastapi-odoo/Dockerfile b/fastapi-odoo/Dockerfile new file mode 100644 index 0000000..c719619 --- /dev/null +++ b/fastapi-odoo/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY pyproject.toml . + +RUN pip install --no-cache-dir \ + fastapi \ + "uvicorn[standard]" \ + sqlalchemy \ + "psycopg[binary]" \ + alembic \ + pydantic-settings + +COPY app ./app +COPY migrations ./migrations +COPY alembic.ini . +COPY entrypoint.sh . + +RUN chmod +x entrypoint.sh + +EXPOSE 8000 + +CMD ["./entrypoint.sh"] \ No newline at end of file diff --git a/fastapi-odoo/README.md b/fastapi-odoo/README.md new file mode 100644 index 0000000..6c83a59 --- /dev/null +++ b/fastapi-odoo/README.md @@ -0,0 +1,298 @@ +# Odoo Sync Service + +A Python service that synchronizes data from Odoo 18 into a separate PostgreSQL database. + +The service currently synchronizes: + +* Contacts +* Products +* Sale Orders +* Sale Order Lines + +The project uses: + +* Python 3.12+ +* FastAPI +* SQLAlchemy +* Alembic +* PostgreSQL +* Docker Compose +* Odoo 18 + +## Project Structure + +```text +. +├── app/ +│ ├── adapters/ # Odoo API communication and DTO validation +│ ├── api/ # HTTP API endpoints +│ ├── config/ # Application configuration +│ ├── database/ # SQLAlchemy database setup +│ ├── models/ # Database models and DTOs +│ ├── repositories/ # Database access +│ └── services/ # Synchronization business logic +│ +├── migrations/ # Alembic database migrations +├── scripts/ # Development and verification scripts +├── compose.yaml +├── Dockerfile +├── alembic.ini +├── pyproject.toml +├── .env.example +└── README.md +``` + +## Requirements + +For the recommended Docker setup: + +* Docker +* Docker Compose + +For local development: + +* Python 3.12+ +* PostgreSQL + +## Configuration + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +On Windows PowerShell: + +```powershell +Copy-Item .env.example .env +``` + +The application requires configuration for: + +* Odoo +* Application PostgreSQL database +* Odoo authentication + +When running the application inside Docker, Docker service names must be used instead of `localhost`. + +For example: + +```env +DATABASE_URL=postgresql+psycopg://app:app@app-db:5432/odoo_sync +ODOO_URL=http://odoo:8069 +``` + +When running the Python application directly on the host machine, the database is exposed on port `5433`: + +```env +DATABASE_URL=postgresql+psycopg://app:app@localhost:5433/odoo_sync +ODOO_URL=http://localhost:8069 +``` + +## Running with Docker Compose + +Start the complete environment: + +```bash +docker compose up --build +``` + +This starts: + +* Odoo +* PostgreSQL for Odoo +* PostgreSQL for the synchronization service +* The Python synchronization service + +The services are available at: + +```text +Odoo: +http://localhost:8069 + +Sync API: +http://localhost:8000 + +Swagger API documentation: +http://localhost:8000/docs +``` + +To stop the services: + +```bash +docker compose down +``` + +To stop the services while removing orphan containers: + +```bash +docker compose down --remove-orphans +``` + +## Database Migrations + +The project uses Alembic for database migrations. + +To apply migrations manually: + +```bash +python -m alembic upgrade head +``` + +To create a new migration: + +```bash +python -m alembic revision --autogenerate -m "describe change" +``` + +When running the service in Docker, migrations are applied during container startup. + +## API + +### Health Check + +```text +GET /health +``` + + +### Run Full Synchronization + +```text +POST /sync +``` + +The synchronization process runs in this order: + +```text +1. Contacts +2. Products +3. Sale Orders +4. Sale Order Lines +``` + +The synchronization flow resolves relationships between Odoo records and local PostgreSQL records. + +## Synchronization Behavior + +The synchronization is idempotent. + +Records are identified by their original Odoo ID. + +On the first synchronization: + +```text +Odoo record + ↓ +No local record found + ↓ +Create local record +``` + +On subsequent synchronizations: + +```text +Odoo record + ↓ +Local record found by Odoo ID + ↓ +Update existing local record +``` + +This prevents duplicate records when synchronization is executed repeatedly. + +## Development + +Create a virtual environment: + +```powershell +python -m venv .venv +``` + +Activate it on Windows: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +Install the project dependencies: + +```powershell +pip install -e . +``` + +Run the application: + +```powershell +python -m app.main +``` + +Alternatively: + +```powershell +uvicorn app.main:app --reload +``` + +Run development scripts using module syntax: + +```powershell +python -m scripts.test_settings +python -m scripts.test_database_connection +python -m scripts.test_adapters +``` + +## Architecture + +The application follows a layered architecture: + +```text +API + │ + ▼ +Services + │ + ├── Adapters ───────► Odoo + │ + └── Repositories ───► PostgreSQL +``` + +### Adapters + +Adapters communicate with Odoo and convert Odoo responses into validated application data structures. + +### Services + +Services contain synchronization logic, including: + +* Fetching data +* Finding existing records +* Creating new records +* Updating existing records +* Resolving relationships between entities +* Recording synchronization logs + +### Repositories + +Repositories handle database operations and isolate SQLAlchemy/database access from the synchronization logic. + +### Database Models + +The local PostgreSQL database stores synchronized data and synchronization history. + +The main entities are: + +* Contacts +* Products +* Sale Orders +* Sale Order Lines +* Sync Runs +* Sync Logs + +## Notes + +The Odoo instance is used as the source system. The local PostgreSQL database stores the synchronized application data. + +The Odoo database and the application database are separate PostgreSQL databases. + +The synchronization service communicates with both Odoo and the application database through the Docker Compose network when running in Docker. diff --git a/fastapi-odoo/alembic.ini b/fastapi-odoo/alembic.ini new file mode 100644 index 0000000..6365499 --- /dev/null +++ b/fastapi-odoo/alembic.ini @@ -0,0 +1,150 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname +sqlalchemy.url = postgresql+psycopg://app:app@localhost:5433/odoo_sync + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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/app/__init__.py b/fastapi-odoo/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/app/adapters/__init__.py b/fastapi-odoo/app/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/app/adapters/contact_adapter.py b/fastapi-odoo/app/adapters/contact_adapter.py new file mode 100644 index 0000000..ecf5092 --- /dev/null +++ b/fastapi-odoo/app/adapters/contact_adapter.py @@ -0,0 +1,24 @@ +from app.adapters.odoo_client import OdooClient +from app.schemas.contact import OdooContact + + +class ContactAdapter: + def __init__(self, client: OdooClient) -> None: + self._client = client + + def fetch_contacts(self) -> list[OdooContact]: + records = self._client.search_read( + model="res.partner", + fields=[ + "id", + "name", + "email", + "phone", + "mobile", + ], + ) + + return [ + OdooContact.model_validate(record) + for record in records + ] \ No newline at end of file diff --git a/fastapi-odoo/app/adapters/odoo_client.py b/fastapi-odoo/app/adapters/odoo_client.py new file mode 100644 index 0000000..37d1e31 --- /dev/null +++ b/fastapi-odoo/app/adapters/odoo_client.py @@ -0,0 +1,55 @@ +import xmlrpc.client + +from app.config.settings import get_settings + + +class OdooClient: + def __init__(self) -> None: + settings = get_settings() + + self._database = settings.odoo_db + self._username = settings.odoo_username + self._password = settings.odoo_password + + self._common = xmlrpc.client.ServerProxy( + f"{settings.odoo_url}/xmlrpc/2/common", + ) + + self._models = xmlrpc.client.ServerProxy( + f"{settings.odoo_url}/xmlrpc/2/object", + ) + + self._uid = self._authenticate() + + def _authenticate(self) -> int: + uid = self._common.authenticate( + self._database, + self._username, + self._password, + {}, + ) + + if not uid: + raise RuntimeError( + "Failed to authenticate with Odoo", + ) + + return uid + + def search_read( + self, + model: str, + fields: list[str], + domain: list | None = None, + ) -> list[dict]: + return self._models.execute_kw( + self._database, + self._uid, + self._password, + model, + "search_read", + [domain or []], + { + "fields": fields, + }, + ) diff --git a/fastapi-odoo/app/adapters/product_adapter.py b/fastapi-odoo/app/adapters/product_adapter.py new file mode 100644 index 0000000..bbffb36 --- /dev/null +++ b/fastapi-odoo/app/adapters/product_adapter.py @@ -0,0 +1,74 @@ +from app.adapters.odoo_client import OdooClient +from app.schemas.product import ( + OdooProduct, + OdooProductTemplate, + OdooProductVariant, +) + + +class ProductAdapter: + def __init__(self, client: OdooClient) -> None: + self._client = client + + def fetch_products(self) -> list[OdooProduct]: + variants = self._client.search_read( + model="product.product", + fields=[ + "id", + "product_tmpl_id", + "name", + "default_code", + "lst_price", + ], + ) + + templates = self._client.search_read( + model="product.template", + fields=[ + "id", + "list_price", + "type", + ], + ) + + variant_records = [ + OdooProductVariant.model_validate(record) + for record in variants + ] + + template_records = [ + OdooProductTemplate.model_validate(record) + for record in templates + ] + + templates_by_id = { + template.odoo_id: template + for template in template_records + } + + products: list[OdooProduct] = [] + + for variant in variant_records: + template = templates_by_id.get( + variant.product_template_id + ) + + if template is None: + raise ValueError( + "Product variant references missing " + f"template: {variant.product_template_id}" + ) + + products.append( + OdooProduct( + odoo_id=variant.odoo_id, + name=variant.name, + internal_reference=( + variant.internal_reference + ), + sale_price=template.sale_price, + product_type=template.product_type, + ) + ) + + return products \ No newline at end of file diff --git a/fastapi-odoo/app/adapters/sale_order_adapter.py b/fastapi-odoo/app/adapters/sale_order_adapter.py new file mode 100644 index 0000000..7b2007e --- /dev/null +++ b/fastapi-odoo/app/adapters/sale_order_adapter.py @@ -0,0 +1,50 @@ +from app.adapters.odoo_client import OdooClient +from app.schemas.odoo import get_odoo_id +from app.schemas.sale_order import ( + OdooSaleOrder, + OdooSaleOrderLine, +) + + +class SaleOrderAdapter: + def __init__(self, client: OdooClient) -> None: + self._client = client + + def fetch_orders(self) -> list[OdooSaleOrder]: + records = self._client.search_read( + model="sale.order", + fields=[ + "id", + "name", + "partner_id", + "date_order", + "state", + "amount_total", + "order_line", + ], + ) + + return [ + OdooSaleOrder.model_validate(record) + for record in records + ] + + def fetch_order_lines( + self, + ) -> list[OdooSaleOrderLine]: + records = self._client.search_read( + model="sale.order.line", + fields=[ + "id", + "order_id", + "product_id", + "product_uom_qty", + "price_unit", + "price_subtotal", + ], + ) + + return [ + OdooSaleOrderLine.model_validate(record) + for record in records + ] \ No newline at end of file diff --git a/fastapi-odoo/app/api/__init__.py b/fastapi-odoo/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/app/api/routes/health.py b/fastapi-odoo/app/api/routes/health.py new file mode 100644 index 0000000..acbdc38 --- /dev/null +++ b/fastapi-odoo/app/api/routes/health.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + + +router = APIRouter() + + +@router.get("/health") +def health_check() -> dict[str, str]: + return { + "status": "ok", + } \ No newline at end of file diff --git a/fastapi-odoo/app/api/routes/sync.py b/fastapi-odoo/app/api/routes/sync.py new file mode 100644 index 0000000..dc8f971 --- /dev/null +++ b/fastapi-odoo/app/api/routes/sync.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter + +from app.container import create_full_sync_service +from app.database.connection import SessionLocal + + +router = APIRouter() + + +@router.post("/sync") +def run_sync() -> dict[str, int | str]: + with SessionLocal() as session: + try: + full_sync_service = ( + create_full_sync_service(session) + ) + + sync_run = full_sync_service.sync() + + session.commit() + + return { + "sync_run_id": sync_run.id, + "status": sync_run.status, + "contacts_processed": ( + sync_run.contacts_processed + ), + "products_processed": ( + sync_run.products_processed + ), + "sale_orders_processed": ( + sync_run.sale_orders_processed + ), + "sale_order_lines_processed": ( + sync_run.sale_order_lines_processed + ), + } + + except Exception: + session.rollback() + + raise \ No newline at end of file diff --git a/fastapi-odoo/app/config/__init__.py b/fastapi-odoo/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/app/config/settings.py b/fastapi-odoo/app/config/settings.py new file mode 100644 index 0000000..3b61b80 --- /dev/null +++ b/fastapi-odoo/app/config/settings.py @@ -0,0 +1,26 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str + app_env: str + + database_url: str + + odoo_url: str + odoo_db: str + odoo_username: str + odoo_password: str + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + +@lru_cache +def get_settings() -> Settings: + return Settings() \ No newline at end of file diff --git a/fastapi-odoo/app/container.py b/fastapi-odoo/app/container.py new file mode 100644 index 0000000..a2b024b --- /dev/null +++ b/fastapi-odoo/app/container.py @@ -0,0 +1,100 @@ +from app.adapters.contact_adapter import ContactAdapter +from app.adapters.odoo_client import OdooClient +from app.adapters.product_adapter import ProductAdapter +from app.adapters.sale_order_adapter import SaleOrderAdapter +from app.repositories.contact_repository import ContactRepository +from app.repositories.product_repository import ProductRepository +from app.repositories.sale_order_line_repository import ( + SaleOrderLineRepository, +) +from app.repositories.sale_order_repository import ( + SaleOrderRepository, +) +from app.repositories.sync_repository import SyncRepository +from app.services.contact_sync_service import ( + ContactSyncService, +) +from app.services.full_sync_service import ( + FullSyncService, +) +from app.services.product_sync_service import ( + ProductSyncService, +) +from app.services.sale_order_line_sync_service import ( + SaleOrderLineSyncService, +) +from app.services.sale_order_sync_service import ( + SaleOrderSyncService, +) + +def create_full_sync_service( + session, +) -> FullSyncService: + odoo_client = OdooClient() + + sync_repository = SyncRepository(session) + + contact_repository = ContactRepository(session) + + product_repository = ProductRepository(session) + + sale_order_repository = SaleOrderRepository( + session, + ) + + sale_order_line_repository = ( + SaleOrderLineRepository(session) + ) + + contact_adapter = ContactAdapter( + odoo_client, + ) + + product_adapter = ProductAdapter( + odoo_client, + ) + + sale_order_adapter = SaleOrderAdapter( + odoo_client, + ) + + contact_sync_service = ContactSyncService( + adapter=contact_adapter, + contact_repository=contact_repository, + sync_repository=sync_repository, + ) + + product_sync_service = ProductSyncService( + adapter=product_adapter, + product_repository=product_repository, + sync_repository=sync_repository, + ) + + sale_order_sync_service = SaleOrderSyncService( + adapter=sale_order_adapter, + contact_repository=contact_repository, + sale_order_repository=sale_order_repository, + sync_repository=sync_repository, + ) + + sale_order_line_sync_service = ( + SaleOrderLineSyncService( + adapter=sale_order_adapter, + product_repository=product_repository, + sale_order_repository=sale_order_repository, + sale_order_line_repository=( + sale_order_line_repository + ), + sync_repository=sync_repository, + ) + ) + + return FullSyncService( + sync_repository=sync_repository, + contact_sync_service=contact_sync_service, + product_sync_service=product_sync_service, + sale_order_sync_service=sale_order_sync_service, + sale_order_line_sync_service=( + sale_order_line_sync_service + ), + ) \ No newline at end of file diff --git a/fastapi-odoo/app/database/__init__.py b/fastapi-odoo/app/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/app/database/base.py b/fastapi-odoo/app/database/base.py new file mode 100644 index 0000000..1c2dcc4 --- /dev/null +++ b/fastapi-odoo/app/database/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass \ No newline at end of file diff --git a/fastapi-odoo/app/database/connection.py b/fastapi-odoo/app/database/connection.py new file mode 100644 index 0000000..1032582 --- /dev/null +++ b/fastapi-odoo/app/database/connection.py @@ -0,0 +1,25 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config.settings import get_settings + + +settings = get_settings() + +engine = create_engine( + settings.database_url, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker( + bind=engine, + class_=Session, + expire_on_commit=False, +) + + +def get_db_session() -> Generator[Session, None, None]: + with SessionLocal() as session: + yield session \ No newline at end of file diff --git a/fastapi-odoo/app/main.py b/fastapi-odoo/app/main.py new file mode 100644 index 0000000..dd739d3 --- /dev/null +++ b/fastapi-odoo/app/main.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI + +from app.api.routes.health import router as health_router +from app.api.routes.sync import router as sync_router + + +app = FastAPI( + title="Odoonix Sync Service", + version="1.0.0", +) + + +app.include_router( + health_router, +) + +app.include_router( + sync_router, +) \ No newline at end of file diff --git a/fastapi-odoo/app/models/__init__.py b/fastapi-odoo/app/models/__init__.py new file mode 100644 index 0000000..6fdb94b --- /dev/null +++ b/fastapi-odoo/app/models/__init__.py @@ -0,0 +1,14 @@ +from app.models.contact import Contact +from app.models.product import Product +from app.models.sale_order import SaleOrder +from app.models.sale_order_line import SaleOrderLine +from app.models.sync import SyncLog, SyncRun + +__all__ = [ + "Contact", + "Product", + "SaleOrder", + "SaleOrderLine", + "SyncLog", + "SyncRun", +] \ No newline at end of file diff --git a/fastapi-odoo/app/models/contact.py b/fastapi-odoo/app/models/contact.py new file mode 100644 index 0000000..ecd9d42 --- /dev/null +++ b/fastapi-odoo/app/models/contact.py @@ -0,0 +1,63 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.base import Base + +if TYPE_CHECKING: + from app.models.sale_order import SaleOrder + + +class Contact(Base): + __tablename__ = "contacts" + + __table_args__ = ( + UniqueConstraint("odoo_id", name="uq_contacts_odoo_id"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + + odoo_id: Mapped[int] = mapped_column( + 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(50), + nullable=True, + ) + + mobile: Mapped[str | None] = mapped_column( + String(50), + 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, + ) + + sale_orders: Mapped[list["SaleOrder"]] = relationship( + back_populates="contact", + ) \ No newline at end of file diff --git a/fastapi-odoo/app/models/product.py b/fastapi-odoo/app/models/product.py new file mode 100644 index 0000000..c43e18e --- /dev/null +++ b/fastapi-odoo/app/models/product.py @@ -0,0 +1,64 @@ +from datetime import datetime +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, Numeric, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.base import Base + +if TYPE_CHECKING: + from app.models.sale_order_line import SaleOrderLine + + +class Product(Base): + __tablename__ = "products" + + __table_args__ = ( + UniqueConstraint("odoo_id", name="uq_products_odoo_id"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + + odoo_id: Mapped[int] = mapped_column( + unique=True, + nullable=False, + index=True, + ) + + name: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + + internal_reference: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + ) + + sale_price: Mapped[Decimal] = mapped_column( + Numeric(12, 2), + nullable=False, + ) + + product_type: Mapped[str] = mapped_column( + String(50), + 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( + back_populates="product", + ) \ No newline at end of file diff --git a/fastapi-odoo/app/models/sale_order.py b/fastapi-odoo/app/models/sale_order.py new file mode 100644 index 0000000..6f9ff27 --- /dev/null +++ b/fastapi-odoo/app/models/sale_order.py @@ -0,0 +1,64 @@ +from datetime import datetime +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import ( + DateTime, + ForeignKey, + Numeric, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.base import Base + +if TYPE_CHECKING: + from app.models.contact import Contact + from app.models.sale_order_line import SaleOrderLine + + +class SaleOrder(Base): + __tablename__ = "sale_orders" + + __table_args__ = ( + UniqueConstraint("odoo_id", name="uq_sale_orders_odoo_id"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + + odoo_id: Mapped[int] = mapped_column(nullable=False) + + order_number: Mapped[str] = mapped_column( + String(100), + nullable=False, + ) + + contact_id: Mapped[int] = mapped_column( + ForeignKey("contacts.id"), + nullable=False, + ) + + order_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + + state: Mapped[str] = mapped_column( + String(50), + nullable=False, + ) + + total_amount: Mapped[Decimal] = mapped_column( + Numeric(12, 2), + nullable=False, + ) + + contact: Mapped["Contact"] = relationship( + back_populates="sale_orders", + ) + + lines: Mapped[list["SaleOrderLine"]] = relationship( + back_populates="sale_order", + cascade="all, delete-orphan", + ) \ No newline at end of file diff --git a/fastapi-odoo/app/models/sale_order_line.py b/fastapi-odoo/app/models/sale_order_line.py new file mode 100644 index 0000000..b0c967c --- /dev/null +++ b/fastapi-odoo/app/models/sale_order_line.py @@ -0,0 +1,79 @@ +from datetime import datetime +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import ( + DateTime, + ForeignKey, + Numeric, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.base import Base + +if TYPE_CHECKING: + from app.models.product import Product + from app.models.sale_order import SaleOrder + + +class SaleOrderLine(Base): + __tablename__ = "sale_order_lines" + + __table_args__ = ( + UniqueConstraint( + "odoo_id", + name="uq_sale_order_lines_odoo_id", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + + odoo_id: Mapped[int] = mapped_column(nullable=False) + + sale_order_id: Mapped[int] = mapped_column( + ForeignKey("sale_orders.id"), + nullable=False, + ) + + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id"), + nullable=False, + ) + + quantity: Mapped[Decimal] = mapped_column( + Numeric(12, 2), + nullable=False, + ) + + unit_price: Mapped[Decimal] = mapped_column( + Numeric(12, 2), + nullable=False, + ) + + subtotal: Mapped[Decimal] = mapped_column( + Numeric(12, 2), + 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( + back_populates="lines", + ) + + product: Mapped["Product"] = relationship( + back_populates="sale_order_lines", + ) \ No newline at end of file diff --git a/fastapi-odoo/app/models/sync.py b/fastapi-odoo/app/models/sync.py new file mode 100644 index 0000000..d6826a1 --- /dev/null +++ b/fastapi-odoo/app/models/sync.py @@ -0,0 +1,121 @@ +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import ( + DateTime, + ForeignKey, + Integer, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.base import Base + + +class SyncStatus(StrEnum): + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + + +class SyncRun(Base): + __tablename__ = "sync_runs" + + id: Mapped[int] = mapped_column(primary_key=True) + + status: Mapped[SyncStatus] = mapped_column( + String(20), + nullable=False, + ) + + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + contacts_processed: Mapped[int] = mapped_column( + Integer, + default=0, + nullable=False, + ) + + products_processed: Mapped[int] = mapped_column( + Integer, + default=0, + nullable=False, + ) + + sale_orders_processed: Mapped[int] = mapped_column( + Integer, + default=0, + nullable=False, + ) + + sale_order_lines_processed: Mapped[int] = mapped_column( + Integer, + default=0, + nullable=False, + ) + + error_message: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + + logs: Mapped[list["SyncLog"]] = relationship( + back_populates="sync_run", + cascade="all, delete-orphan", + ) + + +class SyncLog(Base): + __tablename__ = "sync_logs" + + id: Mapped[int] = mapped_column(primary_key=True) + + sync_run_id: Mapped[int] = mapped_column( + ForeignKey("sync_runs.id"), + nullable=False, + ) + + level: Mapped[str] = mapped_column( + String(20), + nullable=False, + ) + + entity: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + ) + + action: Mapped[str | None] = mapped_column( + String(50), + nullable=True, + ) + + record_odoo_id: Mapped[int | None] = mapped_column( + nullable=True, + ) + + message: Mapped[str] = mapped_column( + Text, + nullable=False, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + sync_run: Mapped["SyncRun"] = relationship( + back_populates="logs", + ) \ No newline at end of file diff --git a/fastapi-odoo/app/repositories/__init__.py b/fastapi-odoo/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/app/repositories/contact_repository.py b/fastapi-odoo/app/repositories/contact_repository.py new file mode 100644 index 0000000..ff2d71e --- /dev/null +++ b/fastapi-odoo/app/repositories/contact_repository.py @@ -0,0 +1,48 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.contact import Contact +from app.schemas.contact import OdooContact + + +class ContactRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def get_by_odoo_id( + self, + odoo_id: int, + ) -> Contact | None: + statement = select(Contact).where( + Contact.odoo_id == odoo_id, + ) + + return self._session.scalar(statement) + + def create( + self, + contact_data: OdooContact, + ) -> Contact: + contact = Contact( + odoo_id=contact_data.odoo_id, + name=contact_data.name, + email=contact_data.email, + phone=contact_data.phone, + mobile=contact_data.mobile, + ) + + self._session.add(contact) + + return contact + + def update( + self, + contact: Contact, + contact_data: OdooContact, + ) -> Contact: + contact.name = contact_data.name + contact.email = contact_data.email + contact.phone = contact_data.phone + contact.mobile = contact_data.mobile + + return contact \ No newline at end of file diff --git a/fastapi-odoo/app/repositories/product_repository.py b/fastapi-odoo/app/repositories/product_repository.py new file mode 100644 index 0000000..dccb0a2 --- /dev/null +++ b/fastapi-odoo/app/repositories/product_repository.py @@ -0,0 +1,52 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.product import Product +from app.schemas.product import OdooProduct + + +class ProductRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def get_by_odoo_id( + self, + odoo_id: int, + ) -> Product | None: + statement = select(Product).where( + Product.odoo_id == odoo_id, + ) + + return self._session.scalar(statement) + + def create( + self, + product_data: OdooProduct, + ) -> Product: + product = Product( + odoo_id=product_data.odoo_id, + name=product_data.name, + internal_reference=( + product_data.internal_reference + ), + sale_price=product_data.sale_price, + product_type=product_data.product_type, + ) + + self._session.add(product) + + return product + + def update( + self, + product: Product, + product_data: OdooProduct, + ) -> Product: + product.name = product_data.name + product.internal_reference = ( + product_data.internal_reference + ) + product.sale_price = product_data.sale_price + product.product_type = product_data.product_type + + return product \ No newline at end of file diff --git a/fastapi-odoo/app/repositories/sale_order_line_repository.py b/fastapi-odoo/app/repositories/sale_order_line_repository.py new file mode 100644 index 0000000..1cb6b18 --- /dev/null +++ b/fastapi-odoo/app/repositories/sale_order_line_repository.py @@ -0,0 +1,54 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.sale_order_line import SaleOrderLine +from app.schemas.sale_order import OdooSaleOrderLine + + +class SaleOrderLineRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def get_by_odoo_id( + self, + odoo_id: int, + ) -> SaleOrderLine | None: + statement = select(SaleOrderLine).where( + SaleOrderLine.odoo_id == odoo_id, + ) + + return self._session.scalar(statement) + + def create( + self, + sale_order_line_data: OdooSaleOrderLine, + product_id: int, + sale_order_id: int, + ) -> SaleOrderLine: + sale_order_line = SaleOrderLine( + odoo_id=sale_order_line_data.odoo_id, + product_id=product_id, + sale_order_id=sale_order_id, + quantity=sale_order_line_data.quantity, + unit_price=sale_order_line_data.unit_price, + subtotal=sale_order_line_data.subtotal, + ) + + self._session.add(sale_order_line) + + return sale_order_line + + def update( + self, + sale_order_line: SaleOrderLine, + sale_order_line_data: OdooSaleOrderLine, + product_id: int, + sale_order_id: int, + ) -> SaleOrderLine: + sale_order_line.product_id = product_id + sale_order_line.sale_order_id = sale_order_id + sale_order_line.quantity = sale_order_line_data.quantity + sale_order_line.unit_price = sale_order_line_data.unit_price + sale_order_line.subtotal = sale_order_line_data.subtotal + + return sale_order_line \ No newline at end of file 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..b074d83 --- /dev/null +++ b/fastapi-odoo/app/repositories/sale_order_repository.py @@ -0,0 +1,52 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.sale_order import SaleOrder +from app.schemas.sale_order import OdooSaleOrder + + +class SaleOrderRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def get_by_odoo_id( + self, + odoo_id: int, + ) -> SaleOrder | None: + statement = select(SaleOrder).where( + SaleOrder.odoo_id == odoo_id, + ) + + return self._session.scalar(statement) + + def create( + self, + sale_order_data: OdooSaleOrder, + contact_id: int, + ) -> SaleOrder: + sale_order = SaleOrder( + odoo_id=sale_order_data.odoo_id, + order_number=sale_order_data.order_number, + contact_id=contact_id, + order_date=sale_order_data.order_date, + state=sale_order_data.state, + total_amount=sale_order_data.total_amount, + ) + + self._session.add(sale_order) + + return sale_order + + def update( + self, + sale_order: SaleOrder, + sale_order_data: OdooSaleOrder, + contact_id: int, + ) -> SaleOrder: + sale_order.order_number = sale_order_data.order_number + sale_order.contact_id = contact_id + sale_order.order_date = sale_order_data.order_date + sale_order.state = sale_order_data.state + sale_order.total_amount = sale_order_data.total_amount + + return sale_order \ No newline at end of file diff --git a/fastapi-odoo/app/repositories/sync_repository.py b/fastapi-odoo/app/repositories/sync_repository.py new file mode 100644 index 0000000..33cf031 --- /dev/null +++ b/fastapi-odoo/app/repositories/sync_repository.py @@ -0,0 +1,62 @@ +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.models.sync import ( + SyncLog, + SyncRun, + SyncStatus, +) + + +class SyncRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def create_run(self) -> SyncRun: + sync_run = SyncRun( + status=SyncStatus.RUNNING, + ) + + self._session.add(sync_run) + self._session.flush() + + return sync_run + + def complete_run( + self, + sync_run: SyncRun, + ) -> None: + sync_run.status = SyncStatus.SUCCESS + sync_run.finished_at = datetime.now(timezone.utc) + + def fail_run( + self, + sync_run: SyncRun, + error_message: str, + ) -> None: + sync_run.status = SyncStatus.FAILED + sync_run.finished_at = datetime.now(timezone.utc) + sync_run.error_message = error_message + + def create_log( + self, + sync_run: SyncRun, + level: str, + message: str, + entity: str | None = None, + action: str | None = None, + record_odoo_id: int | None = None, + ) -> SyncLog: + log = SyncLog( + sync_run=sync_run, + level=level, + entity=entity, + action=action, + record_odoo_id=record_odoo_id, + message=message, + ) + + self._session.add(log) + + return log \ No newline at end of file diff --git a/fastapi-odoo/app/schemas/__init__.py b/fastapi-odoo/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/app/schemas/contact.py b/fastapi-odoo/app/schemas/contact.py new file mode 100644 index 0000000..a5d0479 --- /dev/null +++ b/fastapi-odoo/app/schemas/contact.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, Field, field_validator + + +class OdooContact(BaseModel): + odoo_id: int = Field(alias="id") + name: str + email: str | None + phone: str | None + mobile: str | None + + @field_validator( + "email", + "phone", + "mobile", + mode="before", + ) + @classmethod + def normalize_empty_values( + cls, + value: str | bool | None, + ) -> str | None: + if value is False: + return None + + return value + + model_config = { + "populate_by_name": True, + } \ No newline at end of file diff --git a/fastapi-odoo/app/schemas/odoo.py b/fastapi-odoo/app/schemas/odoo.py new file mode 100644 index 0000000..b7241dc --- /dev/null +++ b/fastapi-odoo/app/schemas/odoo.py @@ -0,0 +1,13 @@ +from typing import Any + + +def get_odoo_id(value: Any) -> int: + if isinstance(value, list): + return value[0] + + if isinstance(value, int): + return value + + raise ValueError( + f"Expected an Odoo relational value, got: {value!r}" + ) \ No newline at end of file diff --git a/fastapi-odoo/app/schemas/product.py b/fastapi-odoo/app/schemas/product.py new file mode 100644 index 0000000..baaa9b2 --- /dev/null +++ b/fastapi-odoo/app/schemas/product.py @@ -0,0 +1,46 @@ +from decimal import Decimal + +from pydantic import BaseModel, Field, field_validator + +from app.schemas.odoo import get_odoo_id + + +class OdooProductVariant(BaseModel): + odoo_id: int = Field(alias="id") + product_template_id: int = Field(alias="product_tmpl_id") + name: str + internal_reference: str | None = Field( + default=None, + alias="default_code", + ) + sale_price: Decimal = Field(alias="lst_price") + + @field_validator("product_template_id", mode="before") + @classmethod + def normalize_product_template_id( + cls, + value: list | int, + ) -> int: + return get_odoo_id(value) + + model_config = { + "populate_by_name": True, + } + + +class OdooProductTemplate(BaseModel): + odoo_id: int = Field(alias="id") + sale_price: Decimal = Field(alias="list_price") + product_type: str = Field(alias="type") + + model_config = { + "populate_by_name": True, + } + + +class OdooProduct(BaseModel): + odoo_id: int + name: str + internal_reference: str | None + sale_price: Decimal + product_type: str \ No newline at end of file diff --git a/fastapi-odoo/app/schemas/sale_order.py b/fastapi-odoo/app/schemas/sale_order.py new file mode 100644 index 0000000..e128446 --- /dev/null +++ b/fastapi-odoo/app/schemas/sale_order.py @@ -0,0 +1,53 @@ +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, Field, field_validator + +from app.schemas.odoo import get_odoo_id + + +class OdooSaleOrder(BaseModel): + odoo_id: int = Field(alias="id") + order_number: str = Field(alias="name") + partner_id: int + order_date: datetime = Field(alias="date_order") + state: str + total_amount: Decimal = Field(alias="amount_total") + order_line: list[int] + + @field_validator("partner_id", mode="before") + @classmethod + def normalize_partner_id( + cls, + value: list | int, + ) -> int: + return get_odoo_id(value) + + model_config = { + "populate_by_name": True, + } + + +class OdooSaleOrderLine(BaseModel): + odoo_id: int = Field(alias="id") + order_id: int + product_id: int + quantity: Decimal = Field(alias="product_uom_qty") + unit_price: Decimal = Field(alias="price_unit") + subtotal: Decimal = Field(alias="price_subtotal") + + @field_validator( + "order_id", + "product_id", + mode="before", + ) + @classmethod + def normalize_relation_ids( + cls, + value: list | int, + ) -> int: + return get_odoo_id(value) + + model_config = { + "populate_by_name": True, + } \ No newline at end of file diff --git a/fastapi-odoo/app/services/__init__.py b/fastapi-odoo/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/app/services/contact_sync_service.py b/fastapi-odoo/app/services/contact_sync_service.py new file mode 100644 index 0000000..7b997c5 --- /dev/null +++ b/fastapi-odoo/app/services/contact_sync_service.py @@ -0,0 +1,64 @@ +from app.adapters.contact_adapter import ContactAdapter +from app.models import SyncRun +from app.repositories.contact_repository import ContactRepository +from app.repositories.sync_repository import SyncRepository + + +class ContactSyncService: + def __init__( + self, + adapter: ContactAdapter, + contact_repository: ContactRepository, + sync_repository: SyncRepository, + ) -> None: + self._adapter = adapter + self._contact_repository = contact_repository + self._sync_repository = sync_repository + + def sync( + self, + sync_run: SyncRun, + ) -> int: + processed_count = 0 + + contacts = self._adapter.fetch_contacts() + + for contact_data in contacts: + existing_contact = ( + self._contact_repository.get_by_odoo_id( + contact_data.odoo_id, + ) + ) + + if existing_contact is None: + self._contact_repository.create( + contact_data, + ) + + action = "created" + + else: + self._contact_repository.update( + existing_contact, + contact_data, + ) + + action = "updated" + + self._sync_repository.create_log( + sync_run=sync_run, + level="INFO", + entity="contact", + action=action, + record_odoo_id=contact_data.odoo_id, + message=( + f"Contact {contact_data.odoo_id} " + f"{action} successfully" + ), + ) + + processed_count += 1 + + sync_run.contacts_processed = processed_count + + return processed_count diff --git a/fastapi-odoo/app/services/full_sync_service.py b/fastapi-odoo/app/services/full_sync_service.py new file mode 100644 index 0000000..de33a12 --- /dev/null +++ b/fastapi-odoo/app/services/full_sync_service.py @@ -0,0 +1,56 @@ +from app.models.sync import SyncRun +from app.repositories.sync_repository import SyncRepository +from app.services.contact_sync_service import ContactSyncService +from app.services.product_sync_service import ProductSyncService +from app.services.sale_order_line_sync_service import ( + SaleOrderLineSyncService, +) +from app.services.sale_order_sync_service import ( + SaleOrderSyncService, +) + + +class FullSyncService: + def __init__( + self, + sync_repository: SyncRepository, + contact_sync_service: ContactSyncService, + product_sync_service: ProductSyncService, + sale_order_sync_service: SaleOrderSyncService, + sale_order_line_sync_service: ( + SaleOrderLineSyncService + ), + ) -> None: + self._sync_repository = sync_repository + self._contact_sync_service = ( + contact_sync_service + ) + self._product_sync_service = ( + product_sync_service + ) + self._sale_order_sync_service = ( + sale_order_sync_service + ) + self._sale_order_line_sync_service = ( + sale_order_line_sync_service + ) + + def sync(self) -> SyncRun: + sync_run = self._sync_repository.create_run() + + try: + self._contact_sync_service.sync(sync_run) + self._product_sync_service.sync(sync_run) + self._sale_order_sync_service.sync(sync_run) + self._sale_order_line_sync_service.sync(sync_run) + + self._sync_repository.complete_run(sync_run) + return sync_run + + except Exception as error: + self._sync_repository.fail_run( + sync_run=sync_run, + error_message=str(error), + ) + + raise diff --git a/fastapi-odoo/app/services/product_sync_service.py b/fastapi-odoo/app/services/product_sync_service.py new file mode 100644 index 0000000..bcdcff5 --- /dev/null +++ b/fastapi-odoo/app/services/product_sync_service.py @@ -0,0 +1,66 @@ +from app.adapters.product_adapter import ProductAdapter +from app.models import SyncRun +from app.repositories.product_repository import ( + ProductRepository, +) +from app.repositories.sync_repository import SyncRepository + + +class ProductSyncService: + def __init__( + self, + adapter: ProductAdapter, + product_repository: ProductRepository, + sync_repository: SyncRepository, + ) -> None: + self._adapter = adapter + self._product_repository = product_repository + self._sync_repository = sync_repository + + def sync( + self, + sync_run: SyncRun, + ) -> int: + processed_count = 0 + + products = self._adapter.fetch_products() + + for product_data in products: + existing_product = ( + self._product_repository.get_by_odoo_id( + product_data.odoo_id, + ) + ) + + if existing_product is None: + self._product_repository.create( + product_data, + ) + + action = "created" + + else: + self._product_repository.update( + existing_product, + product_data, + ) + + action = "updated" + + self._sync_repository.create_log( + sync_run=sync_run, + level="INFO", + entity="product", + action=action, + record_odoo_id=product_data.odoo_id, + message=( + f"Product {product_data.odoo_id} " + f"{action} successfully" + ), + ) + + processed_count += 1 + + sync_run.products_processed = processed_count + + return processed_count diff --git a/fastapi-odoo/app/services/sale_order_line_sync_service.py b/fastapi-odoo/app/services/sale_order_line_sync_service.py new file mode 100644 index 0000000..bbce412 --- /dev/null +++ b/fastapi-odoo/app/services/sale_order_line_sync_service.py @@ -0,0 +1,110 @@ +from app.adapters.sale_order_adapter import SaleOrderAdapter +from app.models import SyncRun +from app.repositories.product_repository import ( + ProductRepository, +) +from app.repositories.sale_order_line_repository import ( + SaleOrderLineRepository, +) +from app.repositories.sale_order_repository import ( + SaleOrderRepository, +) +from app.repositories.sync_repository import SyncRepository + + +class SaleOrderLineSyncService: + def __init__( + self, + adapter: SaleOrderAdapter, + product_repository: ProductRepository, + sale_order_repository: SaleOrderRepository, + sale_order_line_repository: SaleOrderLineRepository, + sync_repository: SyncRepository, + ) -> None: + self._adapter = adapter + self._product_repository = product_repository + self._sale_order_repository = sale_order_repository + self._sale_order_line_repository = sale_order_line_repository + self._sync_repository = sync_repository + + def sync( + self, + sync_run: SyncRun, + ) -> int: + processed_count = 0 + + sale_order_lines = ( + self._adapter.fetch_order_lines() + ) + + for sale_order_line_data in sale_order_lines: + product = ( + self._product_repository.get_by_odoo_id( + sale_order_line_data.product_id, + ) + ) + + if product is None: + raise ValueError( + f"Product with Odoo ID " + f"{sale_order_line_data.product_id} " + f"not found", + ) + + sale_order = ( + self._sale_order_repository.get_by_odoo_id( + sale_order_line_data.order_id, + ) + ) + + if sale_order is None: + raise ValueError( + f"Sale order with Odoo ID " + f"{sale_order_line_data.order_id} " + f"not found", + ) + + existing_sale_order_line = ( + self._sale_order_line_repository + .get_by_odoo_id( + sale_order_line_data.odoo_id, + ) + ) + + if existing_sale_order_line is None: + self._sale_order_line_repository.create( + sale_order_line_data, + product.id, + sale_order.id, + ) + + action = "created" + + else: + self._sale_order_line_repository.update( + existing_sale_order_line, + sale_order_line_data, + product.id, + sale_order.id, + ) + + action = "updated" + + self._sync_repository.create_log( + sync_run=sync_run, + level="INFO", + entity="sale_order_line", + action=action, + record_odoo_id=sale_order_line_data.odoo_id, + message=( + f"Sale Order Line " + f"{sale_order_line_data.odoo_id} " + f"{action} successfully" + ), + ) + + processed_count += 1 + + sync_run.sale_order_lines_processed = processed_count + + return processed_count diff --git a/fastapi-odoo/app/services/sale_order_sync_service.py b/fastapi-odoo/app/services/sale_order_sync_service.py new file mode 100644 index 0000000..5412cfe --- /dev/null +++ b/fastapi-odoo/app/services/sale_order_sync_service.py @@ -0,0 +1,86 @@ +from app.adapters.sale_order_adapter import SaleOrderAdapter +from app.models import SyncRun +from app.repositories.contact_repository import ( + ContactRepository, +) +from app.repositories.sale_order_repository import ( + SaleOrderRepository, +) +from app.repositories.sync_repository import SyncRepository + + +class SaleOrderSyncService: + def __init__( + self, + adapter: SaleOrderAdapter, + contact_repository: ContactRepository, + sale_order_repository: SaleOrderRepository, + sync_repository: SyncRepository, + ) -> None: + self._adapter = adapter + self._contact_repository = contact_repository + self._sale_order_repository = sale_order_repository + self._sync_repository = sync_repository + + def sync( + self, + sync_run: SyncRun, + ) -> int: + processed_count = 0 + + sale_orders = self._adapter.fetch_orders() + + for sale_order_data in sale_orders: + contact = ( + self._contact_repository.get_by_odoo_id( + sale_order_data.partner_id, + ) + ) + + if contact is None: + raise ValueError( + f"Contact with Odoo ID " + f"{sale_order_data.partner_id} " + f"was not found", + ) + + existing_sale_order = ( + self._sale_order_repository.get_by_odoo_id( + sale_order_data.odoo_id, + ) + ) + + if existing_sale_order is None: + self._sale_order_repository.create( + sale_order_data, + contact.id, + ) + + action = "created" + + else: + self._sale_order_repository.update( + existing_sale_order, + sale_order_data, + contact.id, + ) + + action = "updated" + + self._sync_repository.create_log( + sync_run=sync_run, + level="INFO", + entity="sale_order", + action=action, + record_odoo_id=sale_order_data.odoo_id, + message=( + f"Sale Order {sale_order_data.odoo_id} " + f"{action} successfully" + ), + ) + + processed_count += 1 + + sync_run.sale_orders_processed = processed_count + + return processed_count \ No newline at end of file diff --git a/fastapi-odoo/compose.yaml b/fastapi-odoo/compose.yaml new file mode 100644 index 0000000..9406c6b --- /dev/null +++ b/fastapi-odoo/compose.yaml @@ -0,0 +1,64 @@ +services: + odoo-db: + image: postgres:15 + environment: + POSTGRES_DB: postgres + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + volumes: + - odoo-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U odoo -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + + odoo: + image: odoo:18.0 + depends_on: + odoo-db: + condition: service_healthy + ports: + - "8069:8069" + environment: + HOST: odoo-db + PORT: 5432 + USER: odoo + PASSWORD: odoo + volumes: + - odoo-web-data:/var/lib/odoo + + app-db: + image: postgres:15 + environment: + POSTGRES_DB: odoo_sync + POSTGRES_USER: app + POSTGRES_PASSWORD: app + ports: + - "5433:5432" + volumes: + - app-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d odoo_sync"] + interval: 5s + timeout: 5s + retries: 5 + + sync-service: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env.docker + ports: + - "8000:8000" + depends_on: + app-db: + condition: service_healthy + odoo: + condition: service_started + +volumes: + odoo-db-data: + odoo-web-data: + app-db-data: \ No newline at end of file diff --git a/fastapi-odoo/entrypoint.sh b/fastapi-odoo/entrypoint.sh new file mode 100644 index 0000000..1b5caca --- /dev/null +++ b/fastapi-odoo/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -e + +alembic upgrade head + +exec uvicorn app.main:app \ + --host 0.0.0.0 \ + --port 8000 \ No newline at end of file diff --git a/fastapi-odoo/migrations/README b/fastapi-odoo/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/fastapi-odoo/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/fastapi-odoo/migrations/env.py b/fastapi-odoo/migrations/env.py new file mode 100644 index 0000000..c2c965f --- /dev/null +++ b/fastapi-odoo/migrations/env.py @@ -0,0 +1,85 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +from app.config.settings import get_settings +from app.database.base import Base +import app.models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +# target_metadata = None +settings = get_settings() + +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + 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: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + 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/migrations/script.py.mako b/fastapi-odoo/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/fastapi-odoo/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${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, Sequence[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: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/fastapi-odoo/migrations/versions/627fa7dd5e85_create_initial_sync_tables.py b/fastapi-odoo/migrations/versions/627fa7dd5e85_create_initial_sync_tables.py new file mode 100644 index 0000000..0465f4a --- /dev/null +++ b/fastapi-odoo/migrations/versions/627fa7dd5e85_create_initial_sync_tables.py @@ -0,0 +1,111 @@ +"""create initial sync tables + +Revision ID: 627fa7dd5e85 +Revises: +Create Date: 2026-07-24 17:26:27.839470 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '627fa7dd5e85' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + 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=50), nullable=True), + sa.Column('mobile', sa.String(length=50), 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'), + sa.UniqueConstraint('odoo_id', name='uq_contacts_odoo_id') + ) + 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('internal_reference', sa.String(length=100), nullable=True), + sa.Column('sale_price', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('product_type', sa.String(length=50), nullable=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', name='uq_products_odoo_id') + ) + op.create_table('sync_runs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('contacts_processed', sa.Integer(), nullable=False), + sa.Column('products_processed', sa.Integer(), nullable=False), + sa.Column('sale_orders_processed', sa.Integer(), nullable=False), + sa.Column('sale_order_lines_processed', sa.Integer(), nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('sale_orders', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('odoo_id', sa.Integer(), nullable=False), + sa.Column('order_number', sa.String(length=100), nullable=False), + sa.Column('contact_id', sa.Integer(), nullable=False), + sa.Column('order_date', sa.DateTime(timezone=True), nullable=False), + sa.Column('state', sa.String(length=50), nullable=False), + sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=False), + sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('odoo_id', name='uq_sale_orders_odoo_id') + ) + 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=20), nullable=False), + sa.Column('entity', sa.String(length=100), nullable=True), + sa.Column('action', sa.String(length=50), nullable=True), + sa.Column('record_odoo_id', sa.Integer(), nullable=True), + sa.Column('message', sa.Text(), 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') + ) + 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=False), + sa.Column('quantity', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('unit_price', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('subtotal', sa.Numeric(precision=12, scale=2), nullable=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.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.ForeignKeyConstraint(['sale_order_id'], ['sale_orders.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('odoo_id', name='uq_sale_order_lines_odoo_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('sale_order_lines') + op.drop_table('sync_logs') + op.drop_table('sale_orders') + op.drop_table('sync_runs') + op.drop_table('products') + op.drop_table('contacts') + # ### end Alembic commands ### diff --git a/fastapi-odoo/pyproject.toml b/fastapi-odoo/pyproject.toml new file mode 100644 index 0000000..16517d9 --- /dev/null +++ b/fastapi-odoo/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "odoo-sync-test" +version = "0.1.0" +description = "Odoo to PostgreSQL synchronization service" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "psycopg[binary]>=3.2.0", + "alembic>=1.14.0", + "pydantic-settings>=2.6.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.3.0", + "pytest-cov>=6.0.0", +] diff --git a/fastapi-odoo/scripts/__init__.py b/fastapi-odoo/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi-odoo/scripts/test_adapters.py b/fastapi-odoo/scripts/test_adapters.py new file mode 100644 index 0000000..be631b2 --- /dev/null +++ b/fastapi-odoo/scripts/test_adapters.py @@ -0,0 +1,37 @@ +from app.adapters.contact_adapter import ContactAdapter +from app.adapters.odoo_client import OdooClient +from app.adapters.product_adapter import ProductAdapter +from app.adapters.sale_order_adapter import SaleOrderAdapter + + +client = OdooClient() + +contact_adapter = ContactAdapter(client) +product_adapter = ProductAdapter(client) +sale_order_adapter = SaleOrderAdapter(client) + + +contacts = contact_adapter.fetch_contacts() +products = product_adapter.fetch_products() +orders = sale_order_adapter.fetch_orders() +lines = sale_order_adapter.fetch_order_lines() + + +print("Contacts:") +for contact in contacts: + print(contact) + + +print("\nProducts:") +for product in products: + print(product) + + +print("\nOrders:") +for order in orders: + print(order) + + +print("\nOrder Lines:") +for line in lines: + print(line) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_contact_sync.py b/fastapi-odoo/scripts/test_contact_sync.py new file mode 100644 index 0000000..63b21d5 --- /dev/null +++ b/fastapi-odoo/scripts/test_contact_sync.py @@ -0,0 +1,32 @@ +from app.adapters.contact_adapter import ContactAdapter +from app.adapters.odoo_client import OdooClient +from app.database.connection import SessionLocal +from app.repositories.contact_repository import ( + ContactRepository, +) +from app.repositories.sync_repository import ( + SyncRepository, +) +from app.services.contact_sync_service import ( + ContactSyncService, +) + + +client = OdooClient() +adapter = ContactAdapter(client) + +with SessionLocal() as session: + contact_repository = ContactRepository(session) + sync_repository = SyncRepository(session) + + service = ContactSyncService( + adapter=adapter, + contact_repository=contact_repository, + sync_repository=sync_repository, + ) + + result = service.sync() + + session.commit() + + print(result) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_database_connection.py b/fastapi-odoo/scripts/test_database_connection.py new file mode 100644 index 0000000..373fc59 --- /dev/null +++ b/fastapi-odoo/scripts/test_database_connection.py @@ -0,0 +1,11 @@ +from sqlalchemy import text + +from app.database.connection import SessionLocal + + +with SessionLocal() as session: + result = session.execute( + text("SELECT 1") + ) + + print(result.scalar()) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_full_sync.py b/fastapi-odoo/scripts/test_full_sync.py new file mode 100644 index 0000000..0cb8f58 --- /dev/null +++ b/fastapi-odoo/scripts/test_full_sync.py @@ -0,0 +1,19 @@ +from app.container import create_full_sync_service +from app.database.connection import SessionLocal + +with SessionLocal() as session: + try: + full_sync_service = create_full_sync_service(session) + + sync_run = full_sync_service.sync() + + session.commit() + + print( + f"Sync {sync_run.id} completed successfully", + ) + + except Exception: + session.rollback() + + raise diff --git a/fastapi-odoo/scripts/test_models.py b/fastapi-odoo/scripts/test_models.py new file mode 100644 index 0000000..d378f8e --- /dev/null +++ b/fastapi-odoo/scripts/test_models.py @@ -0,0 +1,6 @@ +from app.database.base import Base +import app.models + + +for table_name in Base.metadata.tables: + print(table_name) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_odoo_client.py b/fastapi-odoo/scripts/test_odoo_client.py new file mode 100644 index 0000000..1d1db73 --- /dev/null +++ b/fastapi-odoo/scripts/test_odoo_client.py @@ -0,0 +1,18 @@ +from app.adapters.odoo_client import OdooClient + + +client = OdooClient() + +contacts = client.search_read( + model="res.partner", + fields=[ + "id", + "name", + "email", + "phone", + "mobile", + ], +) + +for contact in contacts: + print(contact) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_odoo_connection.py b/fastapi-odoo/scripts/test_odoo_connection.py new file mode 100644 index 0000000..8446eea --- /dev/null +++ b/fastapi-odoo/scripts/test_odoo_connection.py @@ -0,0 +1,158 @@ +import xmlrpc.client + +ODOO_URL = "http://localhost:8069" +ODOO_DB = "odoo_test" +ODOO_USERNAME = "admin@example.com" +ODOO_PASSWORD = "admin" + +common = xmlrpc.client.ServerProxy( + f"{ODOO_URL}/xmlrpc/2/common" +) + +uid = common.authenticate( + ODOO_DB, + ODOO_USERNAME, + ODOO_PASSWORD, + {}, +) + +print(f"Authenticated user ID: {uid}") + +models = xmlrpc.client.ServerProxy( + f"{ODOO_URL}/xmlrpc/2/object" +) + +contacts = models.execute_kw( + ODOO_DB, + uid, + ODOO_PASSWORD, + "res.partner", + "search_read", + [[]], + { + "fields": [ + "id", + "name", + "email", + "phone", + "mobile", + ], + }, +) + +print("\nContacts:") + +for contact in contacts: + print(contact) + +products = models.execute_kw( + ODOO_DB, + uid, + ODOO_PASSWORD, + "product.template", + "search_read", + [[]], + { + "fields": [ + "id", + "name", + "default_code", + "list_price", + "type", + ], + }, +) + +print("\nProducts:") + +for product in products: + print(product) + +sale_orders = models.execute_kw( + ODOO_DB, + uid, + ODOO_PASSWORD, + "sale.order", + "search_read", + [[]], + { + "fields": [ + "id", + "name", + "partner_id", + "date_order", + "state", + "amount_total", + "order_line", + ], + }, +) + +print("\nSale Orders:") + +for order in sale_orders: + print(order) + +order_lines = models.execute_kw( + ODOO_DB, + uid, + ODOO_PASSWORD, + "sale.order.line", + "search_read", + [[]], + { + "fields": [ + "id", + "order_id", + "product_id", + "product_uom_qty", + "price_unit", + "price_subtotal", + ], + }, +) + +print("\nSale Order Lines:") + +for line in order_lines: + print(line) + +product_variants = models.execute_kw( + ODOO_DB, + uid, + ODOO_PASSWORD, + "product.product", + "search_read", + [[]], + { + "fields": [ + "id", + "product_tmpl_id", + "name", + "default_code", + "lst_price", + ], + }, +) +product_variants = models.execute_kw( + ODOO_DB, + uid, + ODOO_PASSWORD, + "product.product", + "search_read", + [[]], + { + "fields": [ + "id", + "product_tmpl_id", + "name", + "default_code", + "lst_price", + ], + }, +) + +print("\nProduct Variants:") + +for product in product_variants: + print(product) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_product_sync.py b/fastapi-odoo/scripts/test_product_sync.py new file mode 100644 index 0000000..9e5fe43 --- /dev/null +++ b/fastapi-odoo/scripts/test_product_sync.py @@ -0,0 +1,32 @@ +from app.adapters.odoo_client import OdooClient +from app.adapters.product_adapter import ProductAdapter +from app.database.connection import SessionLocal +from app.repositories.product_repository import ( + ProductRepository, +) +from app.repositories.sync_repository import ( + SyncRepository, +) +from app.services.product_sync_service import ( + ProductSyncService, +) + + +client = OdooClient() +adapter = ProductAdapter(client) + +with SessionLocal() as session: + product_repository = ProductRepository(session) + sync_repository = SyncRepository(session) + + service = ProductSyncService( + adapter=adapter, + product_repository=product_repository, + sync_repository=sync_repository, + ) + + result = service.sync() + + session.commit() + + print(result) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_sale_order_line_sync.py b/fastapi-odoo/scripts/test_sale_order_line_sync.py new file mode 100644 index 0000000..337ab87 --- /dev/null +++ b/fastapi-odoo/scripts/test_sale_order_line_sync.py @@ -0,0 +1,42 @@ +from app.adapters.odoo_client import OdooClient +from app.adapters.sale_order_adapter import SaleOrderAdapter +from app.database.connection import SessionLocal +from app.repositories.product_repository import ( + ProductRepository, +) +from app.repositories.sale_order_repository import ( + SaleOrderRepository, +) +from app.repositories.sale_order_line_repository import ( + SaleOrderLineRepository, +) +from app.repositories.sync_repository import ( + SyncRepository, +) +from app.services.sale_order_line_sync_service import ( + SaleOrderLineSyncService, +) + + +client = OdooClient() +adapter = SaleOrderAdapter(client) + +with SessionLocal() as session: + product_repository = ProductRepository(session) + sale_order_repository = SaleOrderRepository(session) + sale_order_line_repository = SaleOrderLineRepository(session) + sync_repository = SyncRepository(session) + + service = SaleOrderLineSyncService( + adapter=adapter, + product_repository=product_repository, + sale_order_repository=sale_order_repository, + sale_order_line_repository=sale_order_line_repository, + sync_repository=sync_repository, + ) + + result = service.sync() + + session.commit() + + print(result) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_sale_order_sync.py b/fastapi-odoo/scripts/test_sale_order_sync.py new file mode 100644 index 0000000..8e069f0 --- /dev/null +++ b/fastapi-odoo/scripts/test_sale_order_sync.py @@ -0,0 +1,36 @@ +from app.adapters.odoo_client import OdooClient +from app.adapters.sale_order_adapter import SaleOrderAdapter +from app.database.connection import SessionLocal +from app.repositories.contact_repository import ( + ContactRepository, +) +from app.repositories.sale_order_repository import ( + SaleOrderRepository, +) +from app.repositories.sync_repository import ( + SyncRepository, +) +from app.services.sale_order_sync_service import ( + SaleOrderSyncService, +) + +client = OdooClient() +adapter = SaleOrderAdapter(client) + +with SessionLocal() as session: + contact_repository = ContactRepository(session) + sale_order_repository = SaleOrderRepository(session) + sync_repository = SyncRepository(session) + + service = SaleOrderSyncService( + adapter=adapter, + contact_repository=contact_repository, + sale_order_repository=sale_order_repository, + sync_repository=sync_repository, + ) + + result = service.sync() + + session.commit() + + print(result) \ No newline at end of file diff --git a/fastapi-odoo/scripts/test_settings.py b/fastapi-odoo/scripts/test_settings.py new file mode 100644 index 0000000..60143cb --- /dev/null +++ b/fastapi-odoo/scripts/test_settings.py @@ -0,0 +1,7 @@ +from app.config.settings import get_settings + +settings = get_settings() + +print(settings.app_name) +print(settings.odoo_url) +print(settings.odoo_db) \ No newline at end of file