diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1 @@ +.venv diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d559286 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,18 @@ +DATABASE_HOST= +DATABASE_PORT= +DATABASE_NAME= +DATABASE_USER= +DATABASE_PASSWORD= + +ODOO_HOST= +ODOO_PORT= +ODOO_DATABASE= +ODOO_DB_USERNAME= +ODOO_DB_PASSWORD= +ODOO_USERNAME= +ODOO_PASSWORD= +ODOO_URL= + +RUN_MODE= +SYNC_INTERVAL_SECONDS= +LOG_LEVEL= \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..34d7a19 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +.venv/ +.env +__pychache__/ \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..dd68188 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN chmod +x entrypoint.sh +RUN chmod +x odoouserpermision.sh + +CMD ["./entrypoint.sh"] \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..6121f1a --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,58 @@ +.PHONY: help up down build logs restart migrate test shell seed clean + +help: + @echo "Available commands:" + @echo " make up - Start all containers" + @echo " make down - Stop all containers" + @echo " make build - Build docker images" + @echo " make logs - Show container logs" + @echo " make restart - Restart containers" + @echo " make migrate - Run database migrations" + @echo " make test - Run tests" + @echo " make shell - Open backend shell" + @echo " make seed - Seed Odoo test data" + @echo " make clean - Remove containers and volumes" + + +up: + sudo docker compose up + + +build: + sudo docker compose build + + +down: + sudo docker compose down + + +restart: + sudo docker compose restart + + +logs: + sudo docker compose logs -f + + +migrate: + sudo docker compose exec backend alembic upgrade head + + +build test: + sudo docker compose --profile test build tests + +test: + sudo docker compose --profile test run --rm tests + + + +shell: + sudo docker compose exec backend bash + + +seed: + sudo docker compose exec backend python -m scripts.seed_odoo.main + + +clean: + sudo docker compose down -v \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..b5fe4c9 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,73 @@ + +# Integrate with Odoo + +This project implements a backend synchronization service between Odoo ERP and PostgreSQL. + +The solution runs Odoo and it's PostgreSQL database using Docker, then connects to Odoo through it's XML-RPC API. During synchronization, the backend retrieves Contacts, Products, Sale Orders, and Sale Order Lines, maps the external Odoo data into internal models, and persists them in a separate PostgreSQL database. + +## How to run the project + +clone the project + +```bash + git clone https://github.com/javadnr/education.git +``` + +build the docker images + +```bash + make build +``` + +run the container + +```bash + make up +``` + +connect to backend service to create alembic changes + + +```bash + make shell + alembic revision --autogenerate -m "init" +``` + +make migration to database + +```bash + make migrate +``` + + +## how it works + +After we ran the docker container the backend starts to get the data from api and sync to it's database I added 2 functions in the project to run the backend in loop or run it just 1 time. + +you can specify the run type in .env file and I putted a .env.example file to see the vars you can set. + +## tests +To run tests + +```bash + make build test +``` + +## Architecture decisions + +The application is designed using a layered architecture with clear separation of concerns. Odoo communication is isolated in an adapter layer, business logic is implemented in services, database access is encapsulated by repositories, and mapping logic is handled by dedicated mappers. This design keeps the system modular, testable, and easy to extend. + + +## Known Limitations + +The current implementation satisfies the requirements of the technical assignment; however, several areas can be improved for a production-scale environment: + +Synchronization is manually triggered and does not include a scheduling mechanism. In a production environment, it could be executed periodically using a scheduler such as Celery Beat or Cron. + +The project currently performs synchronization in a single process. Queue-based processing (e.g., RabbitMQ with background workers) would improve scalability for high-volume workloads. + +Conflict detection is limited to the Odoo ID. More advanced synchronization strategies, such as version comparison or timestamp-based conflict resolution, can be added if required. + +Monitoring and metrics collection (e.g., Prometheus and Grafana) are outside the scope of this assignment. + +Authentication, authorization, and API endpoints are not implemented because the assignment focuses on synchronization between Odoo and PostgreSQL rather than exposing a public backend API. \ No newline at end of file diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..e8b4e3c --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# 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/alembic + +# 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 = + + +[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/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..d2dff63 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,65 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +from app.config.settings import get_settings +from app.db.base import Base + +import app.db.models + + +config = context.config + + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +config.set_main_option( + "sqlalchemy.url", + get_settings().database_url +) + + +target_metadata = Base.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode.""" + 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(): + """Run migrations in 'online' mode.""" + 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() \ No newline at end of file diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/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/backend/alembic/versions/921f805734ac_initial_migration.py b/backend/alembic/versions/921f805734ac_initial_migration.py new file mode 100644 index 0000000..b533199 --- /dev/null +++ b/backend/alembic/versions/921f805734ac_initial_migration.py @@ -0,0 +1,117 @@ +"""initial_migration + +Revision ID: 921f805734ac +Revises: +Create Date: 2026-07-24 07:40:35.858361 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '921f805734ac' +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('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('id', sa.Integer(), autoincrement=True, 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', name=op.f('pk_contacts')) + ) + op.create_index(op.f('ix_contacts_odoo_id'), 'contacts', ['odoo_id'], unique=True) + op.create_table('products', + 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('id', sa.Integer(), autoincrement=True, 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', name=op.f('pk_products')) + ) + op.create_index(op.f('ix_products_odoo_id'), 'products', ['odoo_id'], unique=True) + op.create_table('sync_runs', + sa.Column('started_at', sa.DateTime(), nullable=False), + sa.Column('finished_at', sa.DateTime(), nullable=True), + sa.Column('received_count', sa.Integer(), nullable=False), + sa.Column('created_count', sa.Integer(), nullable=False), + sa.Column('updated_count', sa.Integer(), nullable=False), + sa.Column('failed_count', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, 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', name=op.f('pk_sync_runs')) + ) + op.create_table('sale_orders', + sa.Column('odoo_id', sa.Integer(), nullable=False), + sa.Column('order_number', sa.String(length=100), nullable=False), + sa.Column('customer_id', sa.Integer(), nullable=False), + sa.Column('order_date', sa.DateTime(), nullable=False), + sa.Column('state', sa.String(length=50), nullable=False), + sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, 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(['customer_id'], ['contacts.id'], name=op.f('fk_sale_orders_customer_id_contacts')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_sale_orders')) + ) + op.create_index(op.f('ix_sale_orders_odoo_id'), 'sale_orders', ['odoo_id'], unique=True) + op.create_table('sync_logs', + sa.Column('sync_run_id', sa.Integer(), nullable=False), + sa.Column('level', sa.String(length=50), nullable=False), + sa.Column('entity_type', sa.String(length=50), nullable=False), + sa.Column('entity_odoo_id', sa.Integer(), nullable=False), + sa.Column('error_message', sa.Text(), nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, 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(['sync_run_id'], ['sync_runs.id'], name=op.f('fk_sync_logs_sync_run_id_sync_runs')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_sync_logs')) + ) + op.create_table('sale_order_lines', + 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('id', sa.Integer(), autoincrement=True, 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'], name=op.f('fk_sale_order_lines_product_id_products')), + sa.ForeignKeyConstraint(['sale_order_id'], ['sale_orders.id'], name=op.f('fk_sale_order_lines_sale_order_id_sale_orders')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_sale_order_lines')) + ) + op.create_index(op.f('ix_sale_order_lines_odoo_id'), 'sale_order_lines', ['odoo_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_sale_order_lines_odoo_id'), table_name='sale_order_lines') + op.drop_table('sale_order_lines') + op.drop_table('sync_logs') + op.drop_index(op.f('ix_sale_orders_odoo_id'), table_name='sale_orders') + op.drop_table('sale_orders') + op.drop_table('sync_runs') + op.drop_index(op.f('ix_products_odoo_id'), table_name='products') + op.drop_table('products') + op.drop_index(op.f('ix_contacts_odoo_id'), table_name='contacts') + op.drop_table('contacts') + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py new file mode 100644 index 0000000..bc078e7 --- /dev/null +++ b/backend/app/config/settings.py @@ -0,0 +1,51 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + +import os + +class Settings(BaseSettings): + + APP_NAME: str = "Odoo Sync Service" + + DATABASE_HOST: str + DATABASE_PORT: int = 5432 + DATABASE_NAME: str + DATABASE_USER: str + DATABASE_PASSWORD: str + + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore", + ) + + @property + def database_url(self) -> str: + return ( + f"postgresql+psycopg2://" + f"{self.DATABASE_USER}:" + f"{self.DATABASE_PASSWORD}@" + f"{self.DATABASE_HOST}:" + f"{self.DATABASE_PORT}/" + f"{self.DATABASE_NAME}" + ) + + ODOO_URL: str + + ODOO_DATABASE: str + + ODOO_USERNAME: str + + ODOO_PASSWORD: str + + log_level = os.environ.get("LOG_LEVEL", "INFO"), + + run_mode: str + + sync_interval_seconds: int + + log_level: str + +@lru_cache +def get_settings() -> Settings: + return Settings() \ No newline at end of file diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..5a5ab92 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,49 @@ +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase + +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + +convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +metadata = MetaData( + naming_convention=convention +) + + +class Base(DeclarativeBase): + + metadata = metadata + + +class BaseModel(Base): + + __abstract__ = True + + + id: Mapped[int] = mapped_column( + primary_key=True, + autoincrement=True + ) + + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now() + ) + + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now() + ) \ No newline at end of file diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..fe792ae --- /dev/null +++ b/backend/app/db/models.py @@ -0,0 +1,187 @@ +from datetime import datetime +from sqlalchemy import ( + Integer, String, Numeric, DateTime,Text,ForeignKey +) +from sqlalchemy.orm import relationship + +from .base import BaseModel +from sqlalchemy.orm import Mapped, mapped_column + +class Contact(BaseModel): + + __tablename__ = "contacts" + + 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 + ) + sale_orders = relationship( + "SaleOrder", + back_populates="customer" + ) + +class Product(BaseModel): + + __tablename__ = "products" + + odoo_id: Mapped[int] = mapped_column( + Integer, + unique=True, + nullable=False, + index=True + ) + + name: Mapped[str] = mapped_column( + String(255), + nullable=False + ) + internal_reference: Mapped[str | None] = mapped_column( + String(100) + ) + sale_price: Mapped[float] = mapped_column( + Numeric(12, 2) + ) + product_type: Mapped[str] = mapped_column( + String(50) + ) + order_lines = relationship( + "SaleOrderLine", + back_populates="product" + ) + +class SaleOrder(BaseModel): + + __tablename__ = "sale_orders" + + odoo_id: Mapped[int] = mapped_column( + Integer, + unique=True, + nullable=False, + index=True + ) + order_number: Mapped[str] = mapped_column( + String(100), + nullable=False + ) + customer_id: Mapped[int] = mapped_column( + ForeignKey("contacts.id"), + nullable=True + ) + order_date: Mapped[datetime] = mapped_column( + DateTime + ) + state: Mapped[str] = mapped_column( + String(50) + ) + total_amount: Mapped[float] = mapped_column( + Numeric(12, 2) + ) + customer = relationship( + "Contact", + back_populates="sale_orders" + ) + lines = relationship( + "SaleOrderLine", + back_populates="sale_order", + cascade="all, delete-orphan" + ) + +class SaleOrderLine(BaseModel): + + __tablename__ = "sale_order_lines" + + odoo_id: Mapped[int] = mapped_column( + Integer, + unique=True, + nullable=False, + index=True + ) + sale_order_id: Mapped[int] = mapped_column( + ForeignKey("sale_orders.id") + ) + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id") + ) + quantity: Mapped[float] = mapped_column( + Numeric(12,2) + ) + unit_price: Mapped[float] = mapped_column( + Numeric(12,2) + ) + subtotal: Mapped[float] = mapped_column( + Numeric(12,2) + ) + sale_order = relationship( + "SaleOrder", + back_populates="lines" + ) + product = relationship( + "Product", + back_populates="order_lines" + ) + +class SyncRun(BaseModel): + + __tablename__ = "sync_runs" + + started_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False + ) + finished_at: Mapped[datetime | None] = mapped_column( + DateTime + ) + received_count: Mapped[int] = mapped_column( + Integer, + default=0 + ) + created_count: Mapped[int] = mapped_column( + Integer, + default=0 + ) + updated_count: Mapped[int] = mapped_column( + Integer, + default=0 + ) + failed_count: Mapped[int] = mapped_column( + Integer, + default=0 + ) +class SyncLog(BaseModel): + + __tablename__ = "sync_logs" + + + sync_run_id: Mapped[int] = mapped_column( + ForeignKey("sync_runs.id") + ) + level: Mapped[str] = mapped_column( + String(50), + nullable=False + ) + entity_type: Mapped[str] = mapped_column( + String(50) + ) + entity_odoo_id: Mapped[int] = mapped_column( + Integer + ) + error_message: Mapped[str] = mapped_column( + Text + ) \ No newline at end of file diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..c30daa2 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,16 @@ + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session + +from app.config.settings import get_settings + + +settings = get_settings() + +engine = create_engine(settings.database_url, pool_pre_ping=True, future=True) + +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +def get_session() -> Session: + return SessionLocal() diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py new file mode 100644 index 0000000..2f2fe5d --- /dev/null +++ b/backend/app/exceptions.py @@ -0,0 +1,24 @@ + + +class AppError(Exception): + """Base exception class""" + + +class OdooConnectionError(AppError): + """retryable error for odoo conections""" + + +class OdooDataError(AppError): + """odoo data is not valid""" + + +class RecordProcessingError(AppError): + """Just a record processing error (mapping or saving) that should not stop the sync process""" + + def __init__(self, entity_type: str, odoo_id, original_exception: Exception): + self.entity_type = entity_type + self.odoo_id = odoo_id + self.original_exception = original_exception + super().__init__( + f"error processing {entity_type} with odoo_id={odoo_id}: {original_exception}" + ) diff --git a/backend/app/logging_setup.py b/backend/app/logging_setup.py new file mode 100644 index 0000000..c84bccf --- /dev/null +++ b/backend/app/logging_setup.py @@ -0,0 +1,28 @@ +import json +import logging +import sys +from datetime import datetime, timezone + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False) + + +def setup_logging(level: str = "INFO") -> None: + root = logging.getLogger() + root.setLevel(level) + + root.handlers.clear() + + handler = logging.StreamHandler(stream=sys.stdout) + handler.setFormatter(JsonFormatter()) + root.addHandler(handler) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..41ee68b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,63 @@ +import logging +import signal +import sys +import time + +from app.config.settings import get_settings +from app.logging_setup import setup_logging +from app.services.sync_orchestrator import run_full_sync + +logger = logging.getLogger("main") + +_shutdown_requested = False +settings = get_settings() + + +def _handle_shutdown_signal(signum, frame): + global _shutdown_requested + logger.info("signal %s received; shutting down...", signum) + _shutdown_requested = True + + +def _run_once() -> bool: + try: + stats = run_full_sync() + logger.info("sync ended: %s", stats) + return True + except Exception: + logger.exception("sync failed") + return False + + +def main() -> None: + setup_logging(settings.log_level) + signal.signal(signal.SIGTERM, _handle_shutdown_signal) + signal.signal(signal.SIGINT, _handle_shutdown_signal) + + logger.info( + "program started (run_mode=%s, interval=%ss, odoo_url=%s, odoo_db=%s)", + settings.run_mode, + settings.sync_interval_seconds, + settings.ODOO_URL, + settings.ODOO_DATABASE, + ) + + if settings.run_mode == "once": + success = _run_once() + sys.exit(0 if success else 1) + + # run_mode == "loop" + while not _shutdown_requested: + _run_once() + + waited = 0 + while waited < settings.sync_interval_seconds and not _shutdown_requested: + time.sleep(1) + waited += 1 + + logger.info("program ended (gracefully)") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/backend/app/mapping/mappers.py b/backend/app/mapping/mappers.py new file mode 100644 index 0000000..3907f69 --- /dev/null +++ b/backend/app/mapping/mappers.py @@ -0,0 +1,87 @@ + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from typing import Any, Optional + + +def _rel_id(value: Any) -> Optional[int]: + """take the id from a many2one field like [5, 'Ali'] and return the id only.""" + if isinstance(value, (list, tuple)) and len(value) >= 1: + return value[0] + if isinstance(value, int): + return value + return None + + +def _to_decimal(value: Any) -> Optional[Decimal]: + if value in (None, False, ""): + return None + try: + return Decimal(str(value)) + except InvalidOperation: + return None + + +def _to_date(value: Any) -> Optional[date]: + if not value: + return None + if isinstance(value, date): + return value + text = str(value)[:10] + try: + return datetime.strptime(text, "%Y-%m-%d").date() + except ValueError: + return None + + +def map_contact(raw: dict) -> dict: + return { + "name": raw.get("name") or "", + "email": raw.get("email") or None, + "phone": raw.get("phone") or None, + "mobile": raw.get("mobile") or None, + } + + +def map_product(raw: dict) -> dict: + product_type = raw.get("detailed_type") or raw.get("type") + return { + "name": raw.get("name") or "", + "internal_reference": raw.get("default_code") or None, + "sale_price": _to_decimal(raw.get("list_price")), + "product_type": product_type, + } + + +def map_sale_order(raw: dict, customer_internal_id: Optional[int]) -> dict: + return { + "order_number": raw.get("name") or None, + "customer_id": customer_internal_id, + "order_date": _to_date(raw.get("date_order")), + "state": raw.get("state") or None, + "total_amount": _to_decimal(raw.get("amount_total")), + } + + +def map_sale_order_line( + raw: dict, sale_order_internal_id: int, product_internal_id: Optional[int] +) -> dict: + return { + "sale_order_id": sale_order_internal_id, + "product_id": product_internal_id, + "quantity": _to_decimal(raw.get("product_uom_qty")), + "unit_price": _to_decimal(raw.get("price_unit")), + "subtotal": _to_decimal(raw.get("price_subtotal")), + } + + +def extract_partner_odoo_id(raw_sale_order: dict) -> Optional[int]: + return _rel_id(raw_sale_order.get("partner_id")) + + +def extract_product_odoo_id(raw_sale_order_line: dict) -> Optional[int]: + return _rel_id(raw_sale_order_line.get("product_id")) + + +def extract_order_odoo_id(raw_sale_order_line: dict) -> Optional[int]: + return _rel_id(raw_sale_order_line.get("order_id")) diff --git a/backend/app/odoo_client/client.py b/backend/app/odoo_client/client.py new file mode 100644 index 0000000..277b095 --- /dev/null +++ b/backend/app/odoo_client/client.py @@ -0,0 +1,107 @@ +import logging +import xmlrpc.client +from typing import Any, Iterator + +from app.exceptions import OdooConnectionError +from app.retry import retry + +logger = logging.getLogger(__name__) + + +class OdooClient: + def __init__(self, url: str, db: str, username: str, password: str): + self.url = url + self.db = db + self.username = username + self.password = password + self._uid: int | None = None + self._common = xmlrpc.client.ServerProxy( + f"{url}/xmlrpc/2/common", allow_none=True + ) + self._models = xmlrpc.client.ServerProxy( + f"{url}/xmlrpc/2/object", allow_none=True + ) + + @retry( + max_attempts=5, + initial_delay=2.0, + exceptions=(ConnectionError, OSError, xmlrpc.client.ProtocolError), + ) + def authenticate(self) -> int: + try: + uid = self._common.authenticate(self.db, self.username, self.password, {}) + except (ConnectionError, OSError, xmlrpc.client.ProtocolError): + raise + except Exception as exc: + raise OdooConnectionError(f"authentication with Odoo failed: {exc}") from exc + + if not uid: + raise OdooConnectionError( + "Authentication with Odoo failed; check database/username/password" + ) + self._uid = uid + logger.info("connection to Odoo established (db=%s, uid=%s)", self.db, uid) + return uid + + @property + def uid(self) -> int: + if self._uid is None: + self.authenticate() + return self._uid # type: ignore[return-value] + + @retry( + max_attempts=3, + initial_delay=2.0, + exceptions=(ConnectionError, OSError, xmlrpc.client.ProtocolError), + ) + def _execute_kw(self, model: str, method: str, args: list, kwargs: dict | None = None): + try: + return self._models.execute_kw( + self.db, self.uid, self.password, model, method, args, kwargs or {} + ) + except (ConnectionError, OSError, xmlrpc.client.ProtocolError): + raise + except Exception as exc: # noqa: BLE001 + raise OdooConnectionError( + f"فراخوانی {model}.{method} با خطا مواجه شد: {exc}" + ) from exc + + def search_read( + self, + model: str, + domain: list, + fields: list[str], + offset: int = 0, + limit: int = 100, + order: str | None = None, + ) -> list[dict[str, Any]]: + kwargs: dict[str, Any] = {"offset": offset, "limit": limit} + if order: + kwargs["order"] = order + return self._execute_kw(model, "search_read", [domain, fields], kwargs) + + def iter_all( + self, + model: str, + domain: list, + fields: list[str], + batch_size: int = 100, + ) -> Iterator[dict[str, Any]]: + """ + fetch all records of a model in a paginated way (page by page) and + return a generator of records. + """ + offset = 0 + while True: + batch = self.search_read( + model, domain, fields, offset=offset, limit=batch_size + ) + if not batch: + return + yield from batch + if len(batch) < batch_size: + return + offset += batch_size + + def create(self, model: str, values: dict) -> int: + return self._execute_kw(model, "create", [values]) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repositories/base_repository.py b/backend/app/repositories/base_repository.py new file mode 100644 index 0000000..346a9ae --- /dev/null +++ b/backend/app/repositories/base_repository.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from typing import Generic, Optional, TypeVar + +T = TypeVar("T") + + +class AbstractRepository(ABC, Generic[T]): + @abstractmethod + def get_by_odoo_id(self, odoo_id: int) -> Optional[T]: + ... + + @abstractmethod + def upsert(self, odoo_id: int, data: dict) -> tuple[T, bool]: + """Create if not exists, update if exists""" + ... diff --git a/backend/app/repositories/contact_repository.py b/backend/app/repositories/contact_repository.py new file mode 100644 index 0000000..1a9ab20 --- /dev/null +++ b/backend/app/repositories/contact_repository.py @@ -0,0 +1,6 @@ +from app.db.models import Contact +from app.repositories.sqlalchemy_repository import SQLAlchemyRepository + + +class ContactRepository(SQLAlchemyRepository[Contact]): + model_class = Contact diff --git a/backend/app/repositories/product_repository.py b/backend/app/repositories/product_repository.py new file mode 100644 index 0000000..8af936b --- /dev/null +++ b/backend/app/repositories/product_repository.py @@ -0,0 +1,6 @@ +from app.db.models import Product +from app.repositories.sqlalchemy_repository import SQLAlchemyRepository + + +class ProductRepository(SQLAlchemyRepository[Product]): + model_class = Product diff --git a/backend/app/repositories/sale_order_line_repository.py b/backend/app/repositories/sale_order_line_repository.py new file mode 100644 index 0000000..fb62d6c --- /dev/null +++ b/backend/app/repositories/sale_order_line_repository.py @@ -0,0 +1,6 @@ +from app.db.models import SaleOrderLine +from app.repositories.sqlalchemy_repository import SQLAlchemyRepository + + +class SaleOrderLineRepository(SQLAlchemyRepository[SaleOrderLine]): + model_class = SaleOrderLine diff --git a/backend/app/repositories/sale_order_repository.py b/backend/app/repositories/sale_order_repository.py new file mode 100644 index 0000000..c977167 --- /dev/null +++ b/backend/app/repositories/sale_order_repository.py @@ -0,0 +1,6 @@ +from app.db.models import SaleOrder +from app.repositories.sqlalchemy_repository import SQLAlchemyRepository + + +class SaleOrderRepository(SQLAlchemyRepository[SaleOrder]): + model_class = SaleOrder diff --git a/backend/app/repositories/sqlalchemy_repository.py b/backend/app/repositories/sqlalchemy_repository.py new file mode 100644 index 0000000..ad45f97 --- /dev/null +++ b/backend/app/repositories/sqlalchemy_repository.py @@ -0,0 +1,37 @@ +from typing import Generic, Optional, Type, TypeVar + +from sqlalchemy.orm import Session + +from app.repositories.base_repository import AbstractRepository + +T = TypeVar("T") + + +class SQLAlchemyRepository(AbstractRepository[T], Generic[T]): + model_class: Type[T] + + def __init__(self, session: Session): + self.session = session + + def get_by_odoo_id(self, odoo_id: int) -> Optional[T]: + return ( + self.session.query(self.model_class) + .filter_by(odoo_id=odoo_id) + .first() + ) + + def get_by_id(self, internal_id: int) -> Optional[T]: + return self.session.get(self.model_class, internal_id) + + def upsert(self, odoo_id: int, data: dict) -> tuple[T, bool]: + existing = self.get_by_odoo_id(odoo_id) + if existing is not None: + for key, value in data.items(): + setattr(existing, key, value) + self.session.flush() + return existing, False + + entity = self.model_class(odoo_id=odoo_id, **data) + self.session.add(entity) + self.session.flush() + return entity, True diff --git a/backend/app/retry.py b/backend/app/retry.py new file mode 100644 index 0000000..2248cc9 --- /dev/null +++ b/backend/app/retry.py @@ -0,0 +1,46 @@ +import functools +import logging +import time +from typing import Tuple, Type + +logger = logging.getLogger(__name__) + + +def retry( + max_attempts: int = 3, + initial_delay: float = 1.0, + backoff_factor: float = 2.0, + exceptions: Tuple[Type[Exception], ...] = (Exception,), +): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + delay = initial_delay + attempt = 1 + while True: + try: + return func(*args, **kwargs) + except exceptions as exc: + if attempt >= max_attempts: + logger.error( + "operation %s failed after %s attempts: %s", + func.__name__, + attempt, + exc, + ) + raise + logger.warning( + "trying %s/%s operation %s failed (%s); retrying in %.1f seconds", + attempt, + max_attempts, + func.__name__, + exc, + delay, + ) + time.sleep(delay) + delay *= backoff_factor + attempt += 1 + + return wrapper + + return decorator diff --git a/backend/app/services/contact_sync.py b/backend/app/services/contact_sync.py new file mode 100644 index 0000000..13d24c8 --- /dev/null +++ b/backend/app/services/contact_sync.py @@ -0,0 +1,42 @@ +import logging + +from app.mapping.mappers import map_contact +from app.odoo_client.client import OdooClient +from app.repositories.contact_repository import ContactRepository +from app.services.stats import SyncStats +from app.services.sync_logger import SyncRunLogger + +logger = logging.getLogger(__name__) + +CONTACT_FIELDS = ["id", "name", "email", "phone", "mobile"] + +CONTACT_DOMAIN: list = [] + + +class ContactSyncService: + def __init__(self, odoo_client: OdooClient, repository: ContactRepository): + self.odoo_client = odoo_client + self.repository = repository + + def sync(self, sync_run_logger: SyncRunLogger) -> SyncStats: + stats = SyncStats() + + for raw in self.odoo_client.iter_all( + "res.partner", CONTACT_DOMAIN, CONTACT_FIELDS, batch_size=100 + ): + stats.fetched += 1 + odoo_id = raw["id"] + try: + data = map_contact(raw) + _, created = self.repository.upsert(odoo_id, data) + if created: + stats.created += 1 + else: + stats.updated += 1 + except Exception as exc: + stats.failed += 1 + logger.exception("error processing contact with odoo_id=%s", odoo_id) + sync_run_logger.log_error("contact", odoo_id, str(exc)) + continue + + return stats diff --git a/backend/app/services/product_sync.py b/backend/app/services/product_sync.py new file mode 100644 index 0000000..785e939 --- /dev/null +++ b/backend/app/services/product_sync.py @@ -0,0 +1,47 @@ +import logging + +from app.mapping.mappers import map_product +from app.odoo_client.client import OdooClient +from app.repositories.product_repository import ProductRepository +from app.services.stats import SyncStats +from app.services.sync_logger import SyncRunLogger + +logger = logging.getLogger(__name__) + +PRODUCT_FIELDS = [ + "id", + "name", + "default_code", + "list_price", + "type", +] +PRODUCT_DOMAIN: list = [] + + +class ProductSyncService: + def __init__(self, odoo_client: OdooClient, repository: ProductRepository): + self.odoo_client = odoo_client + self.repository = repository + + def sync(self, sync_run_logger: SyncRunLogger) -> SyncStats: + stats = SyncStats() + + for raw in self.odoo_client.iter_all( + "product.product", PRODUCT_DOMAIN, PRODUCT_FIELDS, batch_size=100 + ): + stats.fetched += 1 + odoo_id = raw["id"] + try: + data = map_product(raw) + _, created = self.repository.upsert(odoo_id, data) + if created: + stats.created += 1 + else: + stats.updated += 1 + except Exception as exc: + stats.failed += 1 + logger.exception("error processing product with odoo_id=%s", odoo_id) + sync_run_logger.log_error("product", odoo_id, str(exc)) + continue + + return stats diff --git a/backend/app/services/sale_order_sync.py b/backend/app/services/sale_order_sync.py new file mode 100644 index 0000000..48f7156 --- /dev/null +++ b/backend/app/services/sale_order_sync.py @@ -0,0 +1,149 @@ +import logging + +from app.mapping.mappers import ( + extract_order_odoo_id, + extract_partner_odoo_id, + extract_product_odoo_id, + map_sale_order, + map_sale_order_line, +) +from app.odoo_client.client import OdooClient +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.services.stats import SyncStats +from app.services.sync_logger import SyncRunLogger + +logger = logging.getLogger(__name__) + +SALE_ORDER_FIELDS = ["id", "name", "partner_id", "date_order", "state", "amount_total"] +SALE_ORDER_LINE_FIELDS = [ + "id", + "order_id", + "product_id", + "product_uom_qty", + "price_unit", + "price_subtotal", +] +SALE_ORDER_DOMAIN: list = [] + +SALE_ORDER_LINE_DOMAIN: list = [("display_type", "=", False)] + + +class SaleOrderSyncService: + def __init__( + self, + odoo_client: OdooClient, + sale_order_repository: SaleOrderRepository, + sale_order_line_repository: SaleOrderLineRepository, + contact_repository: ContactRepository, + product_repository: ProductRepository, + ): + self.odoo_client = odoo_client + self.sale_order_repository = sale_order_repository + self.sale_order_line_repository = sale_order_line_repository + self.contact_repository = contact_repository + self.product_repository = product_repository + + def sync(self, sync_run_logger: SyncRunLogger) -> SyncStats: + total = SyncStats() + total.merge(self._sync_orders(sync_run_logger)) + total.merge(self._sync_order_lines(sync_run_logger)) + return total + + + def _sync_orders(self, sync_run_logger: SyncRunLogger) -> SyncStats: + stats = SyncStats() + + for raw in self.odoo_client.iter_all( + "sale.order", SALE_ORDER_DOMAIN, SALE_ORDER_FIELDS, batch_size=100 + ): + stats.fetched += 1 + odoo_id = raw["id"] + try: + partner_odoo_id = extract_partner_odoo_id(raw) + customer_internal_id = None + if partner_odoo_id is not None: + contact = self.contact_repository.get_by_odoo_id(partner_odoo_id) + if contact is not None: + customer_internal_id = contact.id + else: + sync_run_logger.log_warning( + f"Partner with odoo_id={partner_odoo_id} not found in local database for order " + f"odoo_id={odoo_id}; order will be saved without customer.", + entity_type="sale_order", + entity_odoo_id=odoo_id, + ) + + data = map_sale_order(raw, customer_internal_id) + _, created = self.sale_order_repository.upsert(odoo_id, data) + if created: + stats.created += 1 + else: + stats.updated += 1 + except Exception as exc: + stats.failed += 1 + logger.exception(f"error processing order with odoo_id={odoo_id}") + sync_run_logger.log_error("sale_order", odoo_id, str(exc)) + continue + + return stats + + + def _sync_order_lines(self, sync_run_logger: SyncRunLogger) -> SyncStats: + stats = SyncStats() + + for raw in self.odoo_client.iter_all( + "sale.order.line", + SALE_ORDER_LINE_DOMAIN, + SALE_ORDER_LINE_FIELDS, + batch_size=200, + ): + stats.fetched += 1 + odoo_id = raw["id"] + try: + order_odoo_id = extract_order_odoo_id(raw) + sale_order = ( + self.sale_order_repository.get_by_odoo_id(order_odoo_id) + if order_odoo_id is not None + else None + ) + if sale_order is None: + stats.failed += 1 + sync_run_logger.log_error( + "sale_order_line", + odoo_id, + f"Parent order with odoo_id={order_odoo_id} not found; " + "this line has been skipped.", + ) + continue + + product_odoo_id = extract_product_odoo_id(raw) + product_internal_id = None + if product_odoo_id is not None: + product = self.product_repository.get_by_odoo_id(product_odoo_id) + if product is not None: + product_internal_id = product.id + else: + sync_run_logger.log_warning( + f"Product with odoo_id={product_odoo_id} not found for line " + f"odoo_id={odoo_id}; saving without product association.", + entity_type="sale_order_line", + entity_odoo_id=odoo_id, + ) + + + data = map_sale_order_line(raw, sale_order.id, product_internal_id) + _, created = self.sale_order_line_repository.upsert(odoo_id, data) + if created: + stats.created += 1 + else: + stats.updated += 1 + except Exception as exc: + stats.failed += 1 + logger.exception(f"error processing line with odoo_id={odoo_id}") + sync_run_logger.log_error("sale_order_line", odoo_id, str(exc)) + continue + + return stats diff --git a/backend/app/services/stats.py b/backend/app/services/stats.py new file mode 100644 index 0000000..aa478f5 --- /dev/null +++ b/backend/app/services/stats.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + + +@dataclass +class SyncStats: + fetched: int = 0 + created: int = 0 + updated: int = 0 + failed: int = 0 + + def as_dict(self) -> dict: + return { + "fetched": self.fetched, + "created": self.created, + "updated": self.updated, + "failed": self.failed, + } + + def merge(self, other: "SyncStats") -> None: + self.fetched += other.fetched + self.created += other.created + self.updated += other.updated + self.failed += other.failed diff --git a/backend/app/services/sync_logger.py b/backend/app/services/sync_logger.py new file mode 100644 index 0000000..32e00cc --- /dev/null +++ b/backend/app/services/sync_logger.py @@ -0,0 +1,70 @@ +import logging +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from app.db.models import SyncLog, SyncRun + +logger = logging.getLogger("sync") + + +class SyncRunLogger: + def __init__(self, session: Session, operation_type: str): + self.session = session + self.sync_run = SyncRun( + started_at=datetime.now(timezone.utc), + ) + self.session.add(self.sync_run) + self.session.flush() + logger.info( + "sync run #%s started (operation_type=%s)", + self.sync_run.id, + operation_type, + ) + self.sync_run_id = self.sync_run.id + + + def log_info(self, message: str, entity_type: Optional[str] = None, + entity_odoo_id: Optional[int] = None) -> None: + self._log("info", message, entity_type, entity_odoo_id) + logger.info(message) + + def log_warning(self, message: str, entity_type: Optional[str] = None, + entity_odoo_id: Optional[int] = None) -> None: + self._log("warning", message, entity_type, entity_odoo_id) + logger.warning(message) + + def log_error(self, entity_type: str, entity_odoo_id: Optional[int], + message: str) -> None: + self._log("error", message, entity_type, entity_odoo_id) + logger.error( + "error in %s (odoo_id=%s): %s", entity_type, entity_odoo_id, message + ) + + def _log(self, level: str, message: str, entity_type: Optional[str], + entity_odoo_id: Optional[int]) -> None: + log_row = SyncLog( + sync_run_id=self.sync_run_id, + level=level, + entity_type=entity_type, + entity_odoo_id=entity_odoo_id, + error_message=message, + ) + self.session.add(log_row) + self.session.flush() + + + def finish(self, status: str, stats: dict) -> None: + self.sync_run.finished_at = datetime.now(timezone.utc) + self.sync_run.received_count = stats.get("fetched", 0) + self.sync_run.created_count = stats.get("created", 0) + self.sync_run.updated_count = stats.get("updated", 0) + self.sync_run.failed_count = stats.get("failed", 0) + self.session.flush() + logger.info( + "sync run #%s ended (status=%s, stats=%s)", + self.sync_run.id, + status, + stats, + ) diff --git a/backend/app/services/sync_orchestrator.py b/backend/app/services/sync_orchestrator.py new file mode 100644 index 0000000..ca0d619 --- /dev/null +++ b/backend/app/services/sync_orchestrator.py @@ -0,0 +1,99 @@ +import logging +from datetime import datetime, timezone +from typing import Callable, Optional + +from sqlalchemy.orm import Session + +from app.config.settings import get_settings +from app.db.models import SyncRun +from app.db.session import get_session +from app.odoo_client.client import OdooClient +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.services.contact_sync import ContactSyncService +from app.services.product_sync import ProductSyncService +from app.services.sale_order_sync import SaleOrderSyncService +from app.services.stats import SyncStats +from app.services.sync_logger import SyncRunLogger + +logger = logging.getLogger(__name__) + +settings = get_settings() + +def _build_default_odoo_client() -> OdooClient: + client = OdooClient( + url=settings.ODOO_URL, + db=settings.ODOO_DATABASE, + username=settings.ODOO_USERNAME, + password=settings.ODOO_PASSWORD, + ) + client.authenticate() + return client + + +def run_full_sync( + odoo_client: Optional[OdooClient] = None, + session_factory: Optional[Callable[[], Session]] = None, +) -> dict: + session_factory = session_factory or get_session + session = session_factory() + total_stats = SyncStats() + + try: + odoo_client = odoo_client or _build_default_odoo_client() + + sync_run_logger = SyncRunLogger(session, operation_type="full_sync") + sync_run_id = sync_run_logger.sync_run.id + session.commit() + + contact_repo = ContactRepository(session) + product_repo = ProductRepository(session) + sale_order_repo = SaleOrderRepository(session) + sale_order_line_repo = SaleOrderLineRepository(session) + + contact_service = ContactSyncService(odoo_client, contact_repo) + product_service = ProductSyncService(odoo_client, product_repo) + sale_order_service = SaleOrderSyncService( + odoo_client, + sale_order_repo, + sale_order_line_repo, + contact_repo, + product_repo, + ) + + try: + total_stats.merge(contact_service.sync(sync_run_logger)) + session.commit() + + total_stats.merge(product_service.sync(sync_run_logger)) + session.commit() + + total_stats.merge(sale_order_service.sync(sync_run_logger)) + session.commit() + + sync_run_logger.finish("success", total_stats.as_dict()) + session.commit() + + except Exception: + session.rollback() + logger.exception("sync got an exception and rolled back") + failure_session = session_factory() + try: + sync_run = failure_session.get(SyncRun, sync_run_id) + if sync_run is not None: + sync_run.finished_at = datetime.now(timezone.utc) + sync_run.received_count = total_stats.fetched + sync_run.created_count = total_stats.created + sync_run.updated_count = total_stats.updated + sync_run.failed_count = total_stats.failed + failure_session.commit() + finally: + failure_session.close() + raise + + return total_stats.as_dict() + + finally: + session.close() diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..8bda611 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,124 @@ +version: "3.9" + +services: + odoo-db: + image: postgres:17 + container_name: odoo-db + restart: always + environment: + POSTGRES_DB: postgres + POSTGRES_USER: ${ODOO_DB_USERNAME} + POSTGRES_PASSWORD: ${ODOO_DB_PASSWORD} + + volumes: + - odoo_postgres_data:/var/lib/postgresql/data + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U odoo"] + + interval: 10s + + timeout: 5s + + retries: 5 + odoo: + + image: odoo:18 + container_name: odoo + restart: always + depends_on: + odoo-db: + condition: service_healthy + ports: + - "8069:8069" + environment: + HOST: ${ODOO_HOST} + USER: ${ODOO_DB_USERNAME} + PASSWORD: ${ODOO_DB_PASSWORD} + command: > + odoo -i base,sale --without-demo=all -d ${ODOO_DATABASE} + volumes: + - odoo_data:/var/lib/odoo + healthcheck: + test: + [ + "CMD", + "curl", + "-f", + "http://localhost:8069" + ] + + interval: 15s + timeout: 5s + retries: 10 + start_period: 90s + + app-db: + image: postgres:17 + environment: + POSTGRES_USER: ${DATABASE_USER} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + POSTGRES_DB: ${DATABASE_NAME:-app} + volumes: + - app_db_data:/var/lib/postgresql/data + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER}"] + interval: 5s + timeout: 5s + retries: 10 + backend: + build: ./ + volumes: + - .:/app + depends_on: + odoo: + condition: service_healthy + app-db: + condition: service_healthy + environment: + ODOO_URL: http://odoo:8069 + ODOO_DB: ${ODOO_DATABASE} + ODOO_USERNAME: ${ODOO_USERNAME} + ODOO_PASSWORD: ${ODOO_PASSWORD} + DATABASE_URL: postgresql+psycopg2://${DATABASE_USER:-app}:${DATABASE_PASSWORD:-app}@app-db:5432/${DATABASE_NAME} + RUN_MODE: ${RUN_MODE:-loop} + SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-300} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + # restart: unless-stopped + test-db: + image: postgres:17 + profiles: ["test"] + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: app_test + ports: + - "5434:5432" + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app"] + interval: 5s + timeout: 5s + retries: 10 + + tests: + profiles: ["test"] + build: ./ + depends_on: + test-db: + condition: service_healthy + environment: + TEST_DATABASE_URL: postgresql+psycopg2://app:app@test-db:5432/app_test + entrypoint: ["/bin/sh", "-c"] + command: + - "pip install --no-cache-dir -q -r requirements.txt && pytest --cov=app --cov-report=term-missing -v" + + +volumes: + app_db_data: + odoo_postgres_data: + odoo_data: + diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100755 index 0000000..3985a2f --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[entrypoint] در حال اجرای migration های Alembic..." +alembic upgrade head +echo "Setting up admin permissions..." + +# Wait for Odoo to be ready +sleep 5 + +python3 << EOF +import xmlrpc.client +import time + +url = 'http://odoo:8069' +db = '${ODOO_DATABASE:-odoo}' + +# Try to connect +for i in range(30): + try: + common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common') + uid = common.authenticate(db, 'admin', 'admin', {}) + print(uid) + if uid: + break + except: + pass + time.sleep(2) + +if uid: + models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object') + + # Get admin user + admin_user = models.execute_kw(db, uid, 'admin', 'res.users', 'search_read', + [[['login', '=', 'admin']]], + {'fields': ['id', 'groups_id']} + )[0] + + # Get Sales Administrator group + sales_group = models.execute_kw(db, uid, 'admin', 'res.groups', 'search', + [[['name', '=', 'Administrator'], ['category_id.name', '=', 'Sales']]] + ) + + if sales_group: + models.execute_kw(db, uid, 'admin', 'res.users', 'write', + [[admin_user['id']], {'groups_id': [(4, sales_group[0])]}] + ) + print('✅ Sales Administrator permission added to admin user!') + + # Get Invoicing group + invoice_group = models.execute_kw(db, uid, 'admin', 'res.groups', 'search', + [[['name', '=', 'Invoicing'], ['category_id.name', '=', 'Invoicing']]] + ) + + if invoice_group: + models.execute_kw(db, uid, 'admin', 'res.users', 'write', + [[admin_user['id']], {'groups_id': [(4, invoice_group[0])]}] + ) + print('✅ Invoicing group added to admin user!') + + print('✅ Permissions setup complete!') +else: + print('❌ Could not connect to Odoo') +EOF +echo "[entrypoint] اجرای برنامه..." +exec python -m app.main diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..67515f6 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,21 @@ +alembic==1.18.5 +annotated-types==0.7.0 +asyncpg==0.31.0 +coverage==7.15.2 +greenlet==3.5.4 +iniconfig==2.3.0 +Mako==1.3.12 +MarkupSafe==3.0.3 +packaging==26.2 +pluggy==1.6.0 +psycopg2-binary==2.9.12 +pydantic==2.13.4 +pydantic-settings==2.14.2 +pydantic_core==2.46.4 +Pygments==2.20.0 +pytest==9.1.1 +pytest-cov==7.1.0 +python-dotenv==1.2.2 +SQLAlchemy==2.0.51 +typing-inspection==0.4.2 +typing_extensions==4.16.0 diff --git a/backend/scripts/seed_odoo/main.py b/backend/scripts/seed_odoo/main.py new file mode 100644 index 0000000..1ac7e4a --- /dev/null +++ b/backend/scripts/seed_odoo/main.py @@ -0,0 +1,117 @@ +import logging +import os +import sys + + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.odoo_client.client import OdooClient + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") +logger = logging.getLogger("seed") + + +CONTACTS = [ + {"name": "علی رضایی", "email": "ali.rezaei@example.com", "phone": "021-1111111", "mobile": "0912-1111111"}, + {"name": "سارا محمدی", "email": "sara.mohammadi@example.com", "phone": "021-2222222", "mobile": "0912-2222222"}, + {"name": "حسین کریمی", "email": "hossein.karimi@example.com", "phone": "021-3333333", "mobile": "0912-3333333"}, +] + +PRODUCTS = [ + {"name": "لپ‌تاپ مدل A", "default_code": "SKU-A100", "list_price": 25000000, "type": "consu"}, + {"name": "موس بی‌سیم", "default_code": "SKU-M200", "list_price": 850000, "type": "consu"}, + {"name": "کیبورد مکانیکال", "default_code": "SKU-K300", "list_price": 2200000, "type": "consu"}, +] + + +SALE_ORDERS = [ + {"customer_email": "ali.rezaei@example.com", "lines": [("SKU-A100", 1), ("SKU-M200", 2)]}, + {"customer_email": "sara.mohammadi@example.com", "lines": [("SKU-K300", 1)]}, + {"customer_email": "hossein.karimi@example.com", "lines": [("SKU-A100", 2), ("SKU-K300", 1), ("SKU-M200", 1)]}, +] + + +def get_client() -> OdooClient: + client = OdooClient( + url=os.environ.get("ODOO_URL", "http://localhost:8069"), + db=os.environ.get("ODOO_DB", "odoo"), + username=os.environ.get("ODOO_USERNAME", "admin"), + password=os.environ.get("ODOO_PASSWORD", "admin"), + ) + client.authenticate() + return client + + +def seed_contacts(client: OdooClient) -> dict[str, int]: + email_to_id: dict[str, int] = {} + for contact in CONTACTS: + existing = client.search_read( + "res.partner", [("email", "=", contact["email"])], ["id"], limit=1 + ) + if existing: + logger.info("Contact %s already exists (id=%s)", contact["email"], existing[0]["id"]) + email_to_id[contact["email"]] = existing[0]["id"] + continue + new_id = client.create("res.partner", contact) + logger.info("Contact %s created (id=%s)", contact["email"], new_id) + email_to_id[contact["email"]] = new_id + return email_to_id + + +def seed_products(client: OdooClient) -> dict[str, int]: + code_to_id: dict[str, int] = {} + for product in PRODUCTS: + existing = client.search_read( + "product.product", + [("default_code", "=", product["default_code"])], + ["id"], + limit=1, + ) + if existing: + logger.info( + "Product %s already exists (id=%s)", product["default_code"], existing[0]["id"] + ) + code_to_id[product["default_code"]] = existing[0]["id"] + continue + new_id = client.create("product.product", product) + logger.info("Product %s created (id=%s)", product["default_code"], new_id) + code_to_id[product["default_code"]] = new_id + return code_to_id + + +def seed_sale_orders( + client: OdooClient, email_to_id: dict[str, int], code_to_id: dict[str, int] +) -> None: + for order in SALE_ORDERS: + partner_id = email_to_id[order["customer_email"]] + existing_orders = client.search_read( + "sale.order", [("partner_id", "=", partner_id)], ["id"] + ) + if existing_orders: + logger.info( + "Order already exists for customer with partner_id=%s; skipped", partner_id + ) + continue + + order_lines = [ + (0, 0, {"product_id": code_to_id[code], "product_uom_qty": qty}) + for code, qty in order["lines"] + ] + order_id = client.create( + "sale.order", + {"partner_id": partner_id, "order_line": order_lines}, + ) + logger.info("Order created (id=%s, partner_id=%s)", order_id, partner_id) + + +def main() -> None: + client = get_client() + logger.info("Starting seeding of Odoo data...") + email_to_id = seed_contacts(client) + code_to_id = seed_products(client) + seed_sale_orders(client, email_to_id, code_to_id) + logger.info("Seeding of Odoo data ended.") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..8b32dc8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,119 @@ +""" +Common fixtures shared across all tests. + +The test database URL is read from TEST_DATABASE_URL. With docker compose: + docker compose --profile test up -d test-db + TEST_DATABASE_URL=postgresql+psycopg2://app:app@localhost:5434/app_test pytest +Or simpler, through the ready-made tests service in docker-compose (see README). + +We have two types of fixtures because we need two different testing patterns: + +db_session: + For tests that work directly with a single Session (Repositories, + models, SyncRunLogger). Uses SQLAlchemy's official pattern for isolating + tests with a real database: an outer transaction is opened and the Session + joins it with join_transaction_mode="create_savepoint" - meaning session.commit() + inside the test only commits a SAVEPOINT, not the outer transaction. + At the end of the test, the entire outer transaction is rolled back and the + database is restored to its previous state. It's fast (no new DDL between tests) + and completely isolated. + +pg_session_factory: + For Integration tests where the code under test (e.g., SyncOrchestrator) + creates multiple independent Sessions and actually commits multiple times. + The savepoint pattern is not suitable for this case because multiple separate + Sessions cannot share the same connection. Instead, we truncate all tables + with TRUNCATE ... RESTART IDENTITY CASCADE after each test. + +make_fake_odoo_client: + A factory that creates a fake OdooClient (MagicMock). Simply pass a + dictionary {model_name: [rows...]} and iter_all()/search_read() + will return that data - just like the real thing, without needing a real Odoo. +""" +import os +import sys +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.db.base import Base +from app.db import models + +TEST_DATABASE_URL = os.environ.get( + "TEST_DATABASE_URL", + "postgresql+psycopg2://app:app@localhost:5434/app_test", +) + + +@pytest.fixture(scope="session") +def engine(): + eng = create_engine(TEST_DATABASE_URL, future=True) + + try: + with eng.connect() as conn: + conn.execute(text("SELECT 1")) + except Exception as exc: # noqa: BLE001 + pytest.exit( + "Cannot connect to PostgreSQL test database " + f"({TEST_DATABASE_URL}).\n" + "First start the test database:\n" + " docker compose --profile test up -d test-db\n" + "Or run all tests inside Docker:\n" + " docker compose --profile test run --rm tests\n" + f"Original error: {exc}", + returncode=1, + ) + + Base.metadata.drop_all(eng) + Base.metadata.create_all(eng) + yield eng + Base.metadata.drop_all(eng) + eng.dispose() + + +@pytest.fixture() +def db_session(engine): + connection = engine.connect() + trans = connection.begin() + session = Session(bind=connection, join_transaction_mode="create_savepoint") + + yield session + + session.close() + trans.rollback() + connection.close() + + +@pytest.fixture() +def pg_session_factory(engine): + factory = sessionmaker(bind=engine, future=True) + + yield factory + table_names = ", ".join( + f'"{table.name}"' for table in reversed(Base.metadata.sorted_tables) + ) + with engine.begin() as conn: + conn.execute(text(f"TRUNCATE TABLE {table_names} RESTART IDENTITY CASCADE")) + + +@pytest.fixture() +def make_fake_odoo_client(): + def _factory(data_by_model: dict) -> MagicMock: + client = MagicMock() + + def _iter_all(model, domain, fields, batch_size=100): # noqa: ARG001 + return iter(data_by_model.get(model, [])) + + def _search_read(model, domain, fields, offset=0, limit=100, order=None): # noqa: ARG001 + rows = data_by_model.get(model, []) + return rows[offset : offset + limit] + + client.iter_all.side_effect = _iter_all + client.search_read.side_effect = _search_read + return client + + return _factory diff --git a/backend/tests/db/__init__.py b/backend/tests/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/db/test_models_constraints.py b/backend/tests/db/test_models_constraints.py new file mode 100644 index 0000000..3922f22 --- /dev/null +++ b/backend/tests/db/test_models_constraints.py @@ -0,0 +1,72 @@ +""" +Database Relationship Tests: + - Cascade delete between SaleOrder and SaleOrderLine + - Cascade delete between SyncRun and SyncLog + - Relationship correctness (customer.orders, order.lines, ...) +""" +import pytest + +from app.db.models import Contact, Product, SaleOrder, SaleOrderLine, SyncLog, SyncRun +from datetime import datetime, timezone +pytestmark = pytest.mark.db + + +def test_sale_order_lines_cascade_delete_with_order(db_session): + from app.db.models import Contact + customer = Contact( + odoo_id=1, + name="Test Customer", + email="test@example.com" + ) + db_session.add(customer) + db_session.commit() + + product = Product(odoo_id=7, name="Laptop",internal_reference="SKU-A",sale_price=1000,product_type="consu") + db_session.add(product) + db_session.commit() + order = SaleOrder(odoo_id=100, order_number="S00001", customer_id=customer.id,order_date=datetime.now(timezone.utc),state='1',total_amount=1) + order.lines.append(SaleOrderLine(odoo_id=1000, quantity=1, unit_price=1, subtotal=1,sale_order_id=order.id,product_id=product.id)) + order.lines.append(SaleOrderLine(odoo_id=1001, quantity=2, unit_price=2, subtotal=4,sale_order_id=order.id,product_id=product.id)) + + db_session.add(order) + db_session.commit() + + assert db_session.query(SaleOrderLine).count() == 2 + + db_session.delete(order) + db_session.commit() + + assert db_session.query(SaleOrder).count() == 0 + assert db_session.query(SaleOrderLine).count() == 0 + + +def test_contact_orders_relationship(db_session): + contact = Contact(odoo_id=1, name="Ali") + db_session.add(contact) + db_session.commit() + + order1 = SaleOrder(odoo_id=100, order_number="S00001", customer_id=contact.id,order_date=datetime.now(timezone.utc),state='1',total_amount=1) + order2 = SaleOrder(odoo_id=101, order_number="S00001", customer_id=contact.id,order_date=datetime.now(timezone.utc),state='1',total_amount=1) + db_session.add_all([order1, order2]) + db_session.commit() + + db_session.refresh(contact) + assert {o.odoo_id for o in contact.sale_orders} == {100, 101} + + +def test_sale_order_line_product_relationship(db_session): + contact = Contact(odoo_id=1, name="Ali") + db_session.add(contact) + db_session.commit() + product = Product(odoo_id=7, name="Laptop",internal_reference="SKU-A",sale_price=1000,product_type="consu") + order = SaleOrder(odoo_id=100, order_number="S00001", customer_id=contact.id,order_date=datetime.now(timezone.utc),state='1',total_amount=1) + db_session.add_all([product, order]) + db_session.commit() + + line = SaleOrderLine(odoo_id=1000, quantity=1, unit_price=1, subtotal=1,sale_order_id=order.id,product_id=product.id) + db_session.add(line) + db_session.commit() + + db_session.refresh(line) + assert line.product.name == "Laptop" + assert line.sale_order.odoo_id == 100 diff --git a/backend/tests/db/test_repositories.py b/backend/tests/db/test_repositories.py new file mode 100644 index 0000000..b7b7c45 --- /dev/null +++ b/backend/tests/db/test_repositories.py @@ -0,0 +1,173 @@ +""" +Repository Tests (on real PostgreSQL test database). + +The most important thing tested here is idempotency: calling upsert() with +the same odoo_id twice should not create a second record; it should update +the same record. +""" +import pytest +from sqlalchemy.exc import IntegrityError + +from app.db.models import Contact, Product, SaleOrder, SaleOrderLine +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 datetime import timezone, datetime +pytestmark = pytest.mark.db + + +class TestContactRepository: + def test_upsert_creates_new_record(self, db_session): + repo = ContactRepository(db_session) + entity, created = repo.upsert( + 101, {"name": "Ali", "email": "ali@x.com", "phone": None, "mobile": None} + ) + db_session.commit() + + assert created is True + assert entity.odoo_id == 101 + assert db_session.query(Contact).count() == 1 + + def test_upsert_twice_updates_not_duplicates(self, db_session): + repo = ContactRepository(db_session) + repo.upsert(101, {"name": "Ali", "email": "ali@x.com", "phone": None, "mobile": None}) + db_session.commit() + + entity2, created2 = repo.upsert( + 101, {"name": "Ali Updated", "email": "ali2@x.com", "phone": None, "mobile": None} + ) + db_session.commit() + + assert created2 is False + assert entity2.name == "Ali Updated" + assert entity2.email == "ali2@x.com" + assert db_session.query(Contact).count() == 1 + + def test_get_by_odoo_id_returns_none_when_missing(self, db_session): + repo = ContactRepository(db_session) + assert repo.get_by_odoo_id(9999) is None + + def test_get_by_odoo_id_finds_existing(self, db_session): + repo = ContactRepository(db_session) + repo.upsert(101, {"name": "Ali", "email": None, "phone": None, "mobile": None}) + db_session.commit() + + found = repo.get_by_odoo_id(101) + assert found is not None + assert found.name == "Ali" + + def test_odoo_id_must_be_unique_at_db_level(self, db_session): + db_session.add(Contact(odoo_id=555, name="A")) + db_session.commit() + + db_session.add(Contact(odoo_id=555, name="B")) + with pytest.raises(IntegrityError): + db_session.commit() + db_session.rollback() + + +class TestProductRepository: + def test_upsert_creates_and_updates(self, db_session): + repo = ProductRepository(db_session) + _, created1 = repo.upsert( + 7, {"name": "Laptop", "internal_reference": "SKU-A", "sale_price": 1000, "product_type": "consu"} + ) + db_session.commit() + entity2, created2 = repo.upsert( + 7, {"name": "Laptop Pro", "internal_reference": "SKU-A", "sale_price": 1200, "product_type": "consu"} + ) + db_session.commit() + assert created1 is True + assert created2 is False + assert entity2.name == "Laptop Pro" + assert db_session.query(Product).count() == 1 + + +class TestSaleOrderRepository: + def test_upsert_creates_and_updates(self, db_session): + contact_repo = ContactRepository(db_session) + contact, _ = contact_repo.upsert(1, {"name": "Ali", "email": None, "phone": None, "mobile": None}) + db_session.commit() + + order_repo = SaleOrderRepository(db_session) + entity, created = order_repo.upsert( + 100, + { + "order_number": "S00001", + "customer_id": contact.id, + "order_date": datetime.now(timezone.utc), + "state": "draft", + "total_amount": 0, + }, + ) + db_session.commit() + + assert created is True + assert entity.customer_id == contact.id + assert db_session.query(SaleOrder).count() == 1 + + entity2, created2 = order_repo.upsert( + 100, + { + "order_number": "S00001", + "customer_id": contact.id, + "order_date": datetime.now(timezone.utc), + "state": "sale", + "total_amount": 500, + }, + ) + db_session.commit() + + assert created2 is False + assert entity2.state == "sale" + assert db_session.query(SaleOrder).count() == 1 + + +class TestSaleOrderLineRepository: + def test_upsert_requires_valid_sale_order_fk(self, db_session): + line_repo = SaleOrderLineRepository(db_session) + db_session.add( + SaleOrderLine(odoo_id=1, sale_order_id=99999, quantity=1, unit_price=1, subtotal=1) + ) + with pytest.raises(IntegrityError): + db_session.commit() + db_session.rollback() + + def test_upsert_creates_line_linked_to_order(self, db_session): + order_repo = SaleOrderRepository(db_session) + contact = Contact(odoo_id=1, name="Ali") + db_session.add(contact) + db_session.commit() + product = Product(odoo_id=7, name="Laptop",internal_reference="SKU-A",sale_price=1000,product_type="consu") + db_session.add(product) + db_session.commit() + order, _ = order_repo.upsert( + 100, + { + "order_number": "S00001", + "customer_id": contact.id, + "order_date": datetime.now(timezone.utc), + "state": "draft", + "total_amount": 0, + }, + ) + db_session.commit() + + line_repo = SaleOrderLineRepository(db_session) + entity, created = line_repo.upsert( + 1000, + { + "sale_order_id": order.id, + "product_id": product.id, + "quantity": 2, + "unit_price": 100, + "subtotal": 200, + }, + ) + db_session.commit() + + assert created is True + assert entity.sale_order_id == order.id + assert db_session.query(SaleOrderLine).count() == 1 diff --git a/backend/tests/db/test_sync_logger.py b/backend/tests/db/test_sync_logger.py new file mode 100644 index 0000000..58664de --- /dev/null +++ b/backend/tests/db/test_sync_logger.py @@ -0,0 +1,78 @@ +""" +SyncRunLogger tests on a real database. +Here we actually verify that records are correctly saved in the sync_runs and sync_logs +tables - not just that the methods have been called. +""" +import pytest + +from app.db.models import SyncLog, SyncRun +from app.services.sync_logger import SyncRunLogger + +pytestmark = pytest.mark.db + + +def test_creates_sync_run_on_init(db_session): + logger = SyncRunLogger(db_session, operation_type="full_sync") + db_session.commit() + + runs = db_session.query(SyncRun).all() + assert len(runs) == 1 + assert runs[0].id == logger.sync_run.id + + +def test_log_error_persists_to_sync_logs(db_session): + logger = SyncRunLogger(db_session, operation_type="full_sync") + logger.log_error("contact", 42, "something went wrong") + db_session.commit() + + logs = db_session.query(SyncLog).all() + assert len(logs) == 1 + assert logs[0].level == "error" + assert logs[0].entity_type == "contact" + assert logs[0].entity_odoo_id == 42 + assert logs[0].error_message == "something went wrong" + assert logs[0].sync_run_id == logger.sync_run.id + + +def test_log_warning_and_info_use_correct_levels(db_session): + logger = SyncRunLogger(db_session, operation_type="full_sync") + logger.log_warning("careful", entity_type="product", entity_odoo_id=1) + logger.log_info("all good", entity_type="product", entity_odoo_id=2) + db_session.commit() + + logs = {log.level: log for log in db_session.query(SyncLog).all()} + assert "warning" in logs + assert "info" in logs + assert logs["warning"].entity_odoo_id == 1 + assert logs["info"].entity_odoo_id == 2 + + +def test_finish_updates_status_and_stats(db_session): + logger = SyncRunLogger(db_session, operation_type="full_sync") + logger.finish("success", {"fetched": 10, "created": 6, "updated": 3, "failed": 1}) + db_session.commit() + + run = db_session.query(SyncRun).one() + assert run.finished_at is not None + assert run.received_count == 10 + assert run.created_count == 6 + assert run.updated_count == 3 + assert run.failed_count == 1 + + +def test_multiple_sync_runs_have_independent_logs(db_session): + run1 = SyncRunLogger(db_session, operation_type="contacts_sync") + run1.log_error("contact", 1, "err in run 1") + db_session.commit() + + run2 = SyncRunLogger(db_session, operation_type="products_sync") + run2.log_error("product", 2, "err in run 2") + db_session.commit() + + run1_logs = db_session.query(SyncLog).filter_by(sync_run_id=run1.sync_run.id).all() + run2_logs = db_session.query(SyncLog).filter_by(sync_run_id=run2.sync_run.id).all() + + assert len(run1_logs) == 1 + assert len(run2_logs) == 1 + assert run1_logs[0].error_message == "err in run 1" + assert run2_logs[0].error_message == "err in run 2" diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/test_full_sync_pipeline.py b/backend/tests/integration/test_full_sync_pipeline.py new file mode 100644 index 0000000..0e3771b --- /dev/null +++ b/backend/tests/integration/test_full_sync_pipeline.py @@ -0,0 +1,129 @@ +""" +Integration Test: Run the entire pipeline through actual run_full_sync +(not by calling each service manually), with a fake OdooClient and a real PostgreSQL +database (the same database used in docker-compose). + +This test follows exactly the same path as in production: +SyncOrchestrator -> Repositories -> Services -> DB, only the network layer to +Odoo is mocked. The goal is to verify the correct interaction between layers, +not just the behavior of each one in isolation (which is covered in unit tests). + +The most important scenario: **running the entire sync twice should not create duplicate data** +(idempotency at the whole-system level, not just a single Repository). +""" +import pytest + +from app.db.models import Contact, Product, SaleOrder, SaleOrderLine, SyncLog, SyncRun +from app.services.sync_orchestrator import run_full_sync + +pytestmark = pytest.mark.integration + + +ODOO_DATA = { + "res.partner": [ + {"id": 1, "name": "Ali", "email": "ali@x.com", "phone": "111", "mobile": "999"}, + {"id": 2, "name": "Sara", "email": "sara@x.com", "phone": "222", "mobile": "888"}, + ], + "product.product": [ + {"id": 10, "name": "Laptop", "default_code": "SKU-A", "list_price": 1000, "type": "consu"}, + {"id": 11, "name": "Mouse", "default_code": "SKU-B", "list_price": 50, "type": "consu"}, + ], + "sale.order": [ + {"id": 100, "name": "S00001", "partner_id": [1, "Ali"], "date_order": "2026-07-01", "state": "sale", "amount_total": 2050}, + {"id": 101, "name": "S00002", "partner_id": [999, "Ghost"], "date_order": "2026-07-02", "state": "draft", "amount_total": 50}, + ], + "sale.order.line": [ + {"id": 1000, "order_id": [100, "S00001"], "product_id": [10, "Laptop"], "product_uom_qty": 2, "price_unit": 1000, "price_subtotal": 2000}, + {"id": 1001, "order_id": [100, "S00001"], "product_id": [11, "Mouse"], "product_uom_qty": 1, "price_unit": 50, "price_subtotal": 50}, + {"id": 1002, "order_id": [999, "Missing"], "product_id": [10, "Laptop"], "product_uom_qty": 1, "price_unit": 1, "price_subtotal": 1}, + ], +} + + +def _fake_odoo_client(): + from unittest.mock import MagicMock + + client = MagicMock() + + def _iter_all(model, domain, fields, batch_size=100): + return iter(ODOO_DATA.get(model, [])) + + client.iter_all.side_effect = _iter_all + return client + + +class TestFullSyncPipeline: + def test_first_run_creates_everything_correctly(self, pg_session_factory): + stats = run_full_sync( + odoo_client=_fake_odoo_client(), session_factory=pg_session_factory + ) + + assert stats["fetched"] == 9 + assert stats["failed"] == 1 + + session = pg_session_factory() + try: + assert session.query(Contact).count() == 2 + assert session.query(Product).count() == 2 + assert session.query(SaleOrder).count() == 2 + assert session.query(SaleOrderLine).count() == 2 + + sync_run = session.query(SyncRun).one() + assert sync_run.created_count == 8 + + error_logs = session.query(SyncLog).filter_by(level="error").all() + assert len(error_logs) == 1 + + warning_logs = session.query(SyncLog).filter_by(level="warning").all() + assert len(warning_logs) == 1 + finally: + session.close() + + def test_second_run_does_not_duplicate_anything(self, pg_session_factory): + run_full_sync(odoo_client=_fake_odoo_client(), session_factory=pg_session_factory) + stats_second_run = run_full_sync( + odoo_client=_fake_odoo_client(), session_factory=pg_session_factory + ) + + assert stats_second_run["created"] == 0 + assert stats_second_run["updated"] == 8 + assert stats_second_run["failed"] == 1 + + session = pg_session_factory() + try: + assert session.query(Contact).count() == 2 + assert session.query(Product).count() == 2 + assert session.query(SaleOrder).count() == 2 + assert session.query(SaleOrderLine).count() == 2 + + assert session.query(SyncRun).count() == 2 + finally: + session.close() + + def test_updated_data_from_odoo_is_reflected_on_second_run(self, pg_session_factory): + run_full_sync(odoo_client=_fake_odoo_client(), session_factory=pg_session_factory) + + updated_data = { + **ODOO_DATA, + "product.product": [ + {"id": 10, "name": "Laptop Pro", "default_code": "SKU-A", "list_price": 1500, "type": "consu"}, + ODOO_DATA["product.product"][1], + ], + } + from unittest.mock import MagicMock + + client = MagicMock() + client.iter_all.side_effect = lambda model, domain, fields, batch_size=100: iter( + updated_data.get(model, []) + ) + + run_full_sync(odoo_client=client, session_factory=pg_session_factory) + + session = pg_session_factory() + try: + product = session.query(Product).filter_by(odoo_id=10).one() + assert product.name == "Laptop Pro" + assert float(product.sale_price) == 1500 + assert session.query(Product).filter_by(odoo_id=10).count() == 1 + finally: + session.close() diff --git a/backend/tests/integration/test_sync_orchestrator_failure.py b/backend/tests/integration/test_sync_orchestrator_failure.py new file mode 100644 index 0000000..d8c721b --- /dev/null +++ b/backend/tests/integration/test_sync_orchestrator_failure.py @@ -0,0 +1,58 @@ +""" +Test the complete failure path of SyncOrchestrator: when an infrastructure/unexpected error +(not a per-record error) occurs, it should: + 1. Rollback the main session + 2. Record a sync_runs entry with status='failed' and finished_at set + (using a separate session, because the main session has been rolled back) + 3. Re-raise the Exception so the caller (main.py) is notified + +These tests run on the same real PostgreSQL database (shared pg_session_factory fixture from +conftest.py). +""" +from unittest.mock import MagicMock + +import pytest + +from app.db.models import SyncRun +from app.services.sync_orchestrator import run_full_sync + +pytestmark = pytest.mark.integration + + +def test_infrastructure_failure_marks_sync_run_as_failed(pg_session_factory): + broken_client = MagicMock() + broken_client.iter_all.side_effect = RuntimeError("odoo is down") + + with pytest.raises(RuntimeError): + run_full_sync(odoo_client=broken_client, session_factory=pg_session_factory) + + session = pg_session_factory() + try: + sync_run = session.query(SyncRun).one() + assert sync_run.finished_at is not None + finally: + session.close() + + +def test_partial_success_before_infrastructure_failure_is_preserved(pg_session_factory): + from app.db.models import Contact + + def flaky_iter_all(model, domain, fields, batch_size=100): + if model == "res.partner": + return iter([{"id": 1, "name": "Ali", "email": "a@x.com", "phone": None, "mobile": None}]) + if model == "product.product": + raise RuntimeError("odoo timeout") + return iter([]) + + broken_client = MagicMock() + broken_client.iter_all.side_effect = flaky_iter_all + + with pytest.raises(RuntimeError): + run_full_sync(odoo_client=broken_client, session_factory=pg_session_factory) + + session = pg_session_factory() + try: + assert session.query(Contact).filter_by(odoo_id=1).count() == 1 + sync_run = session.query(SyncRun).one() + finally: + session.close() diff --git a/backend/tests/live/__init__.py b/backend/tests/live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/live/test_odoo_client_live.py b/backend/tests/live/test_odoo_client_live.py new file mode 100644 index 0000000..22e1478 --- /dev/null +++ b/backend/tests/live/test_odoo_client_live.py @@ -0,0 +1,70 @@ +""" +Live Test: The only test in this project that requires a real Odoo instance. + +It is SKIPPED by default because in typical CI environments and most local runs +(without docker compose up), no Odoo is available. To actually run it: + + docker compose up -d # Bring up Odoo + cd backend + RUN_LIVE_ODOO_TESTS=1 ODOO_URL=http://localhost:8069 \ + ODOO_DB=sync_test pytest tests/live -m live -v + +The purpose of this test is only to ensure the real XML-RPC path is correct (authentication +and a simple search_read) - not to repeat the sync logic which is already covered +by integration tests with mocks. +""" +import os + +import pytest + +from app.config import settings +from app.odoo_client.client import OdooClient + +pytestmark = pytest.mark.live + +RUN_LIVE = os.environ.get("RUN_LIVE_ODOO_TESTS") == "1" + +skip_reason = ( + "This test only runs with a real Odoo instance. To enable: " + "docker compose up -d and then RUN_LIVE_ODOO_TESTS=1 pytest tests/live -m live" +) + +@pytest.mark.skipif(not RUN_LIVE, reason=skip_reason) +class TestLiveOdooConnection: + def test_authenticate_succeeds(self): + client = OdooClient( + url=settings.odoo_url, + db=settings.odoo_db, + username=settings.odoo_username, + password=settings.odoo_password, + ) + uid = client.authenticate() + assert isinstance(uid, int) + assert uid > 0 + + def test_search_read_contacts(self): + client = OdooClient( + url=settings.odoo_url, + db=settings.odoo_db, + username=settings.odoo_username, + password=settings.odoo_password, + ) + client.authenticate() + rows = client.search_read( + "res.partner", domain=[], fields=["id", "name"], limit=5 + ) + assert isinstance(rows, list) + + def test_pagination_returns_all_records_without_duplicates(self): + client = OdooClient( + url=settings.odoo_url, + db=settings.odoo_db, + username=settings.odoo_username, + password=settings.odoo_password, + ) + client.authenticate() + seen_ids = set() + for row in client.iter_all("res.partner", [], ["id"], batch_size=2): + assert row["id"] not in seen_ids, "iter_all باید هر رکورد را فقط یک‌بار برگرداند" + seen_ids.add(row["id"]) + assert len(seen_ids) > 0 diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_contact_sync.py b/backend/tests/unit/test_contact_sync.py new file mode 100644 index 0000000..9ae5fd8 --- /dev/null +++ b/backend/tests/unit/test_contact_sync.py @@ -0,0 +1,84 @@ +from unittest.mock import MagicMock + +from app.services.contact_sync import ContactSyncService + + +def _fake_client(rows): + client = MagicMock() + client.iter_all.return_value = iter(rows) + return client + + +def test_sync_creates_and_updates_correctly(): + client = _fake_client( + [ + {"id": 1, "name": "Ali", "email": "ali@x.com", "phone": None, "mobile": None}, + {"id": 2, "name": "Sara", "email": "sara@x.com", "phone": None, "mobile": None}, + ] + ) + repo = MagicMock() + repo.upsert.side_effect = [ + (MagicMock(), True), + (MagicMock(), False), + ] + + service = ContactSyncService(client, repo) + stats = service.sync(sync_run_logger=MagicMock()) + + assert stats.fetched == 2 + assert stats.created == 1 + assert stats.updated == 1 + assert stats.failed == 0 + assert repo.upsert.call_count == 2 + + +def test_sync_continues_after_a_single_record_error(): + client = _fake_client( + [ + {"id": 1, "name": "A"}, + {"id": 2, "name": "B"}, + {"id": 3, "name": "C"}, + ] + ) + repo = MagicMock() + repo.upsert.side_effect = [ + (MagicMock(), True), + Exception("db exploded"), + (MagicMock(), True), + ] + + sync_run_logger = MagicMock() + service = ContactSyncService(client, repo) + stats = service.sync(sync_run_logger=sync_run_logger) + + assert stats.fetched == 3 + assert stats.created == 2 + assert stats.failed == 1 + assert repo.upsert.call_count == 3 + sync_run_logger.log_error.assert_called_once_with("contact", 2, "db exploded") + + +def test_sync_with_no_contacts_returns_zero_stats(): + client = _fake_client([]) + repo = MagicMock() + + service = ContactSyncService(client, repo) + stats = service.sync(sync_run_logger=MagicMock()) + + assert stats.as_dict() == {"fetched": 0, "created": 0, "updated": 0, "failed": 0} + repo.upsert.assert_not_called() + + +def test_sync_passes_mapped_data_to_repository(): + client = _fake_client( + [{"id": 1, "name": "Ali", "email": "ali@x.com", "phone": "111", "mobile": "222"}] + ) + repo = MagicMock() + repo.upsert.return_value = (MagicMock(), True) + + service = ContactSyncService(client, repo) + service.sync(sync_run_logger=MagicMock()) + + repo.upsert.assert_called_once_with( + 1, {"name": "Ali", "email": "ali@x.com", "phone": "111", "mobile": "222"} + ) diff --git a/backend/tests/unit/test_logging_setup.py b/backend/tests/unit/test_logging_setup.py new file mode 100644 index 0000000..194f168 --- /dev/null +++ b/backend/tests/unit/test_logging_setup.py @@ -0,0 +1,64 @@ +import json +import logging + +from app.logging_setup import JsonFormatter, setup_logging + + +class TestJsonFormatter: + def test_formats_basic_record_as_valid_json(self): + formatter = JsonFormatter() + record = logging.LogRecord( + name="test.logger", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="hello %s", + args=("world",), + exc_info=None, + ) + + output = formatter.format(record) + parsed = json.loads(output) + + assert parsed["level"] == "INFO" + assert parsed["logger"] == "test.logger" + assert parsed["message"] == "hello world" + assert "timestamp" in parsed + + def test_includes_exception_info_when_present(self): + formatter = JsonFormatter() + try: + raise ValueError("boom") + except ValueError: + import sys + + record = logging.LogRecord( + name="test.logger", + level=logging.ERROR, + pathname=__file__, + lineno=1, + msg="failed", + args=(), + exc_info=sys.exc_info(), + ) + + output = formatter.format(record) + parsed = json.loads(output) + assert "exception" in parsed + assert "ValueError" in parsed["exception"] + + +class TestSetupLogging: + def test_setup_logging_sets_level_and_json_handler(self): + setup_logging("DEBUG") + root = logging.getLogger() + + assert root.level == logging.DEBUG + assert len(root.handlers) == 1 + assert isinstance(root.handlers[0].formatter, JsonFormatter) + + def test_calling_setup_logging_twice_does_not_duplicate_handlers(self): + setup_logging("INFO") + setup_logging("INFO") + root = logging.getLogger() + assert len(root.handlers) == 1 diff --git a/backend/tests/unit/test_main.py b/backend/tests/unit/test_main.py new file mode 100644 index 0000000..95e1bb9 --- /dev/null +++ b/backend/tests/unit/test_main.py @@ -0,0 +1,109 @@ +""" +Tests for app.main: _run_once logic and loop/graceful shutdown behavior, +without needing real Odoo or database (run_full_sync is fully mocked). + +Note: Settings is a dataclass(frozen=True), so we cannot directly monkeypatch +a field on it; instead, we replace the entire settings object with a new +version using dataclasses.replace. +""" +import dataclasses + +import app.main as main_module + + +def _settings_with(**overrides): + return main_module.settings.model_copy(update=overrides) + +def test_run_once_returns_true_on_success(monkeypatch): + monkeypatch.setattr(main_module, "run_full_sync", lambda: {"fetched": 1}) + assert main_module._run_once() is True + + +def test_run_once_returns_false_on_exception(monkeypatch): + def _boom(): + raise RuntimeError("odoo down") + + monkeypatch.setattr(main_module, "run_full_sync", _boom) + assert main_module._run_once() is False + + +def test_main_once_mode_exits_zero_on_success(monkeypatch): + monkeypatch.setattr(main_module, "settings", _settings_with(run_mode="once")) + monkeypatch.setattr(main_module, "run_full_sync", lambda: {"fetched": 1}) + monkeypatch.setattr(main_module, "setup_logging", lambda level: None) + + exited_with = {} + + def fake_exit(code): + exited_with["code"] = code + raise SystemExit(code) + + monkeypatch.setattr(main_module.sys, "exit", fake_exit) + + try: + main_module.main() + except SystemExit: + pass + + assert exited_with["code"] == 0 + + +def test_main_once_mode_exits_nonzero_on_failure(monkeypatch): + monkeypatch.setattr(main_module, "settings", _settings_with(run_mode="once")) + + def _boom(): + raise RuntimeError("boom") + + monkeypatch.setattr(main_module, "run_full_sync", _boom) + monkeypatch.setattr(main_module, "setup_logging", lambda level: None) + + exited_with = {} + + def fake_exit(code): + exited_with["code"] = code + raise SystemExit(code) + + monkeypatch.setattr(main_module.sys, "exit", fake_exit) + + try: + main_module.main() + except SystemExit: + pass + + assert exited_with["code"] == 1 + + +def test_main_loop_mode_stops_gracefully_on_shutdown_signal(monkeypatch): + monkeypatch.setattr( + main_module, "settings", _settings_with(run_mode="loop", sync_interval_seconds=5) + ) + monkeypatch.setattr(main_module, "setup_logging", lambda level: None) + monkeypatch.setattr(main_module.signal, "signal", lambda *a, **k: None) + + run_count = {"n": 0} + + def fake_run_once(): + run_count["n"] += 1 + main_module._shutdown_requested = True + return True + + monkeypatch.setattr(main_module, "_run_once", fake_run_once) + monkeypatch.setattr(main_module.time, "sleep", lambda s: None) + + exited_with = {} + + def fake_exit(code): + exited_with["code"] = code + raise SystemExit(code) + + monkeypatch.setattr(main_module.sys, "exit", fake_exit) + + try: + main_module.main() + except SystemExit: + pass + finally: + main_module._shutdown_requested = False + + assert run_count["n"] == 1 + assert exited_with["code"] == 0 diff --git a/backend/tests/unit/test_mappers.py b/backend/tests/unit/test_mappers.py new file mode 100644 index 0000000..52723f3 --- /dev/null +++ b/backend/tests/unit/test_mappers.py @@ -0,0 +1,170 @@ +""" +Tests for the Mapping layer. +All of these functions are pure - they have no IO - so no mocks or +database are needed. This simplicity is exactly the design goal of this layer. +""" +from datetime import date +from decimal import Decimal + +from app.mapping.mappers import ( + extract_order_odoo_id, + extract_partner_odoo_id, + extract_product_odoo_id, + map_contact, + map_product, + map_sale_order, + map_sale_order_line, +) + + +class TestMapContact: + def test_maps_all_fields(self): + raw = {"id": 1, "name": "Ali", "email": "ali@x.com", "phone": "111", "mobile": "222"} + result = map_contact(raw) + assert result == { + "name": "Ali", + "email": "ali@x.com", + "phone": "111", + "mobile": "222", + } + + def test_missing_optional_fields_become_none(self): + raw = {"id": 1, "name": "Ali"} + result = map_contact(raw) + assert result["email"] is None + assert result["phone"] is None + assert result["mobile"] is None + + def test_odoo_false_becomes_none(self): + raw = {"id": 1, "name": "Ali", "email": False, "phone": False, "mobile": False} + result = map_contact(raw) + assert result["email"] is None + assert result["phone"] is None + assert result["mobile"] is None + + def test_missing_name_becomes_empty_string_not_none(self): + raw = {"id": 1} + result = map_contact(raw) + assert result["name"] == "" + + +class TestMapProduct: + def test_maps_all_fields_with_detailed_type(self): + raw = { + "id": 7, + "name": "Laptop", + "default_code": "SKU-A", + "list_price": 1000.5, + "type": "consu", + "detailed_type": "product", + } + result = map_product(raw) + assert result["name"] == "Laptop" + assert result["internal_reference"] == "SKU-A" + assert result["sale_price"] == Decimal("1000.5") + assert result["product_type"] == "product" + + def test_falls_back_to_type_when_no_detailed_type(self): + raw = {"id": 7, "name": "Laptop", "type": "consu"} + result = map_product(raw) + assert result["product_type"] == "consu" + + def test_invalid_price_becomes_none(self): + raw = {"id": 7, "name": "Laptop", "list_price": "not-a-number"} + result = map_product(raw) + assert result["sale_price"] is None + + def test_missing_price_becomes_none(self): + raw = {"id": 7, "name": "Laptop", "list_price": False} + result = map_product(raw) + assert result["sale_price"] is None + + +class TestMapSaleOrder: + def test_maps_all_fields(self): + raw = { + "id": 100, + "name": "S00001", + "partner_id": [3, "Ali Rezaei"], + "date_order": "2026-07-20 10:30:00", + "state": "sale", + "amount_total": 199.99, + } + result = map_sale_order(raw, customer_internal_id=1) + assert result == { + "order_number": "S00001", + "customer_id": 1, + "order_date": date(2026, 7, 20), + "state": "sale", + "total_amount": Decimal("199.99"), + } + + def test_date_only_string_also_works(self): + raw = {"id": 100, "date_order": "2026-01-05"} + result = map_sale_order(raw, customer_internal_id=None) + assert result["order_date"] == date(2026, 1, 5) + + def test_missing_date_becomes_none(self): + raw = {"id": 100, "date_order": False} + result = map_sale_order(raw, customer_internal_id=None) + assert result["order_date"] is None + + def test_invalid_date_becomes_none(self): + raw = {"id": 100, "date_order": "not-a-date"} + result = map_sale_order(raw, customer_internal_id=None) + assert result["order_date"] is None + + def test_customer_internal_id_none_is_allowed(self): + raw = {"id": 100, "name": "S00001"} + result = map_sale_order(raw, customer_internal_id=None) + assert result["customer_id"] is None + + +class TestMapSaleOrderLine: + def test_maps_all_fields(self): + raw = { + "id": 1000, + "order_id": [100, "S00001"], + "product_id": [7, "Laptop"], + "product_uom_qty": 2, + "price_unit": 500, + "price_subtotal": 1000, + } + result = map_sale_order_line(raw, sale_order_internal_id=1, product_internal_id=2) + assert result == { + "sale_order_id": 1, + "product_id": 2, + "quantity": Decimal("2"), + "unit_price": Decimal("500"), + "subtotal": Decimal("1000"), + } + + def test_product_internal_id_can_be_none(self): + raw = {"id": 1000, "product_uom_qty": 1, "price_unit": 1, "price_subtotal": 1} + result = map_sale_order_line(raw, sale_order_internal_id=1, product_internal_id=None) + assert result["product_id"] is None + + +class TestRelationalIdExtractors: + def test_extract_partner_odoo_id_from_many2one_pair(self): + raw = {"partner_id": [3, "Ali Rezaei"]} + assert extract_partner_odoo_id(raw) == 3 + + def test_extract_partner_odoo_id_when_false(self): + raw = {"partner_id": None} + assert extract_partner_odoo_id(raw) is None + + def test_extract_partner_odoo_id_missing_key(self): + assert extract_partner_odoo_id({}) is None + + def test_extract_product_odoo_id(self): + raw = {"product_id": [7, "[SKU-A] Laptop"]} + assert extract_product_odoo_id(raw) == 7 + + def test_extract_order_odoo_id(self): + raw = {"order_id": [100, "S00001"]} + assert extract_order_odoo_id(raw) == 100 + + def test_extract_handles_plain_int_too(self): + raw = {"partner_id": 5} + assert extract_partner_odoo_id(raw) == 5 diff --git a/backend/tests/unit/test_odoo_client.py b/backend/tests/unit/test_odoo_client.py new file mode 100644 index 0000000..0d4f1e0 --- /dev/null +++ b/backend/tests/unit/test_odoo_client.py @@ -0,0 +1,138 @@ +""" +Tests for OdooClient by mocking xmlrpc.client.ServerProxy. +No real network is involved here; only authentication behavior, errors, retry, and +pagination (iter_all) are tested. +""" +import xmlrpc.client +from unittest.mock import MagicMock, patch + +import pytest + +from app.exceptions import OdooConnectionError +from app.odoo_client.client import OdooClient + + +@pytest.fixture() +def client_with_mocked_proxies(monkeypatch): + common_proxy = MagicMock() + models_proxy = MagicMock() + + def fake_server_proxy(url, allow_none=True): # noqa: ARG001 + return common_proxy if url.endswith("/xmlrpc/2/common") else models_proxy + + with patch("xmlrpc.client.ServerProxy", side_effect=fake_server_proxy): + client = OdooClient( + url="http://fake-odoo:8069", db="testdb", username="admin", password="admin" + ) + return client, common_proxy, models_proxy + + +class TestAuthenticate: + def test_authenticate_success_sets_uid(self, client_with_mocked_proxies): + client, common_proxy, _ = client_with_mocked_proxies + common_proxy.authenticate.return_value = 7 + + uid = client.authenticate() + + assert uid == 7 + assert client.uid == 7 + common_proxy.authenticate.assert_called_once_with("testdb", "admin", "admin", {}) + + def test_authenticate_rejected_raises_connection_error(self, client_with_mocked_proxies): + client, common_proxy, _ = client_with_mocked_proxies + common_proxy.authenticate.return_value = False # Odoo این را برای رد شدن برمی‌گرداند + + with pytest.raises(OdooConnectionError): + client.authenticate() + + def test_uid_property_authenticates_lazily(self, client_with_mocked_proxies): + client, common_proxy, _ = client_with_mocked_proxies + common_proxy.authenticate.return_value = 3 + + assert client._uid is None + assert client.uid == 3 # اولین دسترسی باید authenticate را صدا بزند + assert common_proxy.authenticate.call_count == 1 + + _ = client.uid # دسترسی دوم نباید دوباره authenticate کند + assert common_proxy.authenticate.call_count == 1 + + +class TestSearchReadAndExecuteKw: + def test_search_read_calls_execute_kw_with_correct_args(self, client_with_mocked_proxies): + client, common_proxy, models_proxy = client_with_mocked_proxies + common_proxy.authenticate.return_value = 1 + models_proxy.execute_kw.return_value = [{"id": 1, "name": "Ali"}] + + result = client.search_read( + "res.partner", domain=[("email", "!=", False)], fields=["id", "name"], + offset=10, limit=50, + ) + + assert result == [{"id": 1, "name": "Ali"}] + models_proxy.execute_kw.assert_called_once_with( + "testdb", 1, "admin", + "res.partner", "search_read", + [[("email", "!=", False)], ["id", "name"]], + {"offset": 10, "limit": 50}, + ) + + def test_execute_kw_wraps_unexpected_errors(self, client_with_mocked_proxies): + client, common_proxy, models_proxy = client_with_mocked_proxies + common_proxy.authenticate.return_value = 1 + models_proxy.execute_kw.side_effect = ValueError("weird odoo error") + + with pytest.raises(OdooConnectionError): + client.search_read("res.partner", [], ["id"]) + + def test_execute_kw_retries_on_protocol_error(self, client_with_mocked_proxies, monkeypatch): + client, common_proxy, models_proxy = client_with_mocked_proxies + common_proxy.authenticate.return_value = 1 + monkeypatch.setattr("app.retry.time.sleep", lambda s: None) + + models_proxy.execute_kw.side_effect = [ + xmlrpc.client.ProtocolError("http://x", 500, "err", {}), + [{"id": 1}], + ] + + result = client.search_read("res.partner", [], ["id"]) + assert result == [{"id": 1}] + assert models_proxy.execute_kw.call_count == 2 + + +class TestIterAll: + def test_iter_all_paginates_until_short_batch(self, client_with_mocked_proxies): + client, common_proxy, models_proxy = client_with_mocked_proxies + common_proxy.authenticate.return_value = 1 + + page1 = [{"id": i} for i in range(1, 3)] + page2 = [{"id": 3}] + models_proxy.execute_kw.side_effect = [page1, page2] + + rows = list(client.iter_all("res.partner", [], ["id"], batch_size=2)) + + assert [r["id"] for r in rows] == [1, 2, 3] + assert models_proxy.execute_kw.call_count == 2 + + def test_iter_all_stops_immediately_on_empty_result(self, client_with_mocked_proxies): + client, common_proxy, models_proxy = client_with_mocked_proxies + common_proxy.authenticate.return_value = 1 + models_proxy.execute_kw.return_value = [] + + rows = list(client.iter_all("res.partner", [], ["id"], batch_size=100)) + + assert rows == [] + assert models_proxy.execute_kw.call_count == 1 + + +class TestCreate: + def test_create_returns_new_id(self, client_with_mocked_proxies): + client, common_proxy, models_proxy = client_with_mocked_proxies + common_proxy.authenticate.return_value = 1 + models_proxy.execute_kw.return_value = 42 + + new_id = client.create("res.partner", {"name": "Ali"}) + + assert new_id == 42 + models_proxy.execute_kw.assert_called_once_with( + "testdb", 1, "admin", "res.partner", "create", [{"name": "Ali"}], {} + ) diff --git a/backend/tests/unit/test_product_sync.py b/backend/tests/unit/test_product_sync.py new file mode 100644 index 0000000..269256f --- /dev/null +++ b/backend/tests/unit/test_product_sync.py @@ -0,0 +1,43 @@ +from unittest.mock import MagicMock + +from app.services.product_sync import ProductSyncService + + +def _fake_client(rows): + client = MagicMock() + client.iter_all.return_value = iter(rows) + return client + + +def test_sync_creates_and_updates_correctly(): + client = _fake_client( + [ + {"id": 7, "name": "Laptop", "default_code": "SKU-A", "list_price": 1000, "type": "consu"}, + {"id": 8, "name": "Mouse", "default_code": "SKU-B", "list_price": 50, "type": "consu"}, + ] + ) + repo = MagicMock() + repo.upsert.side_effect = [(MagicMock(), True), (MagicMock(), False)] + + service = ProductSyncService(client, repo) + stats = service.sync(sync_run_logger=MagicMock()) + + assert stats.fetched == 2 + assert stats.created == 1 + assert stats.updated == 1 + assert stats.failed == 0 + + +def test_sync_continues_after_error(): + client = _fake_client([{"id": 7, "name": "A"}, {"id": 8, "name": "B"}]) + repo = MagicMock() + repo.upsert.side_effect = [Exception("boom"), (MagicMock(), True)] + + sync_run_logger = MagicMock() + service = ProductSyncService(client, repo) + stats = service.sync(sync_run_logger=sync_run_logger) + + assert stats.fetched == 2 + assert stats.created == 1 + assert stats.failed == 1 + sync_run_logger.log_error.assert_called_once_with("product", 7, "boom") diff --git a/backend/tests/unit/test_retry.py b/backend/tests/unit/test_retry.py new file mode 100644 index 0000000..521cf05 --- /dev/null +++ b/backend/tests/unit/test_retry.py @@ -0,0 +1,75 @@ +""" +Tests for the retry decorator. +We monkeypatch the actual time.sleep so tests run instantly (without waiting +real seconds) while also checking that the delay backoff works correctly. +""" +import pytest + +from app.retry import retry + + +class FlakyError(Exception): + pass + + +def test_succeeds_immediately_without_retry(): + calls = {"count": 0} + + @retry(max_attempts=3, initial_delay=0) + def always_ok(): + calls["count"] += 1 + return "ok" + + assert always_ok() == "ok" + assert calls["count"] == 1 + + +def test_retries_then_succeeds(monkeypatch): + sleep_calls = [] + monkeypatch.setattr("app.retry.time.sleep", lambda s: sleep_calls.append(s)) + + calls = {"count": 0} + + @retry(max_attempts=3, initial_delay=1.0, backoff_factor=2.0, exceptions=(FlakyError,)) + def fails_twice_then_ok(): + calls["count"] += 1 + if calls["count"] < 3: + raise FlakyError("temporary") + return "ok" + + result = fails_twice_then_ok() + + assert result == "ok" + assert calls["count"] == 3 + assert sleep_calls == [1.0, 2.0] + + +def test_raises_after_max_attempts(monkeypatch): + monkeypatch.setattr("app.retry.time.sleep", lambda s: None) + + calls = {"count": 0} + + @retry(max_attempts=3, initial_delay=0.1, exceptions=(FlakyError,)) + def always_fails(): + calls["count"] += 1 + raise FlakyError("permanent failure") + + with pytest.raises(FlakyError): + always_fails() + + assert calls["count"] == 3 + + +def test_does_not_retry_unlisted_exceptions(monkeypatch): + monkeypatch.setattr("app.retry.time.sleep", lambda s: None) + calls = {"count": 0} + + @retry(max_attempts=3, initial_delay=0.1, exceptions=(FlakyError,)) + def raises_other_error(): + calls["count"] += 1 + raise ValueError("not retryable") + + with pytest.raises(ValueError): + raises_other_error() + + assert calls["count"] == 1 diff --git a/backend/tests/unit/test_sale_order_sync.py b/backend/tests/unit/test_sale_order_sync.py new file mode 100644 index 0000000..6e8c054 --- /dev/null +++ b/backend/tests/unit/test_sale_order_sync.py @@ -0,0 +1,166 @@ +""" +Unit tests for SaleOrderSyncService with full mocking (no real database). +Scenarios covered: FK found, FK not found (warning), parent order of a line not found (error + skip), and continued processing after an error. +For end-to-end tests with a real database, see tests/integration/test_full_sync_pipeline.py. +""" +from unittest.mock import MagicMock + +from app.services.sale_order_sync import SaleOrderSyncService + + +def _fake_client(orders=None, lines=None): + client = MagicMock() + + def iter_all(model, domain, fields, batch_size=100): + if model == "sale.order": + return iter(orders or []) + if model == "sale.order.line": + return iter(lines or []) + return iter([]) + + client.iter_all.side_effect = iter_all + return client + + +def _mock_entity(internal_id): + entity = MagicMock() + entity.id = internal_id + return entity + + +class TestOrderSyncing: + def test_order_with_known_customer_resolves_fk(self): + client = _fake_client( + orders=[{"id": 100, "name": "S1", "partner_id": [3, "Ali"], "amount_total": 100}] + ) + contact_repo = MagicMock() + contact_repo.get_by_odoo_id.return_value = _mock_entity(1) + order_repo = MagicMock() + order_repo.upsert.return_value = (MagicMock(), True) + line_repo = MagicMock() + product_repo = MagicMock() + + service = SaleOrderSyncService(client, order_repo, line_repo, contact_repo, product_repo) + sync_run_logger = MagicMock() + stats = service.sync(sync_run_logger) + + assert stats.created == 1 + assert stats.failed == 0 + order_repo.upsert.assert_called_once() + called_data = order_repo.upsert.call_args[0][1] + assert called_data["customer_id"] == 1 + sync_run_logger.log_warning.assert_not_called() + + def test_order_with_unknown_customer_logs_warning_but_still_saves(self): + client = _fake_client( + orders=[{"id": 100, "name": "S1", "partner_id": [999, "Ghost"], "amount_total": 50}] + ) + contact_repo = MagicMock() + contact_repo.get_by_odoo_id.return_value = None # مخاطب sync نشده + order_repo = MagicMock() + order_repo.upsert.return_value = (MagicMock(), True) + line_repo = MagicMock() + product_repo = MagicMock() + + service = SaleOrderSyncService(client, order_repo, line_repo, contact_repo, product_repo) + sync_run_logger = MagicMock() + stats = service.sync(sync_run_logger) + + assert stats.created == 1 # سفارش همچنان ذخیره می‌شود + called_data = order_repo.upsert.call_args[0][1] + assert called_data["customer_id"] is None + sync_run_logger.log_warning.assert_called_once() + + def test_order_processing_continues_after_error(self): + client = _fake_client( + orders=[ + {"id": 100, "name": "S1", "amount_total": 1}, + {"id": 101, "name": "S2", "amount_total": 2}, + ] + ) + contact_repo = MagicMock() + contact_repo.get_by_odoo_id.return_value = None + order_repo = MagicMock() + order_repo.upsert.side_effect = [Exception("db error"), (MagicMock(), True)] + line_repo = MagicMock() + product_repo = MagicMock() + + service = SaleOrderSyncService(client, order_repo, line_repo, contact_repo, product_repo) + sync_run_logger = MagicMock() + stats = service.sync(sync_run_logger) + + assert stats.fetched == 2 + assert stats.failed == 1 + assert stats.created == 1 + sync_run_logger.log_error.assert_any_call("sale_order", 100, "db error") + + +class TestOrderLineSyncing: + def test_line_with_missing_parent_order_is_skipped(self): + client = _fake_client( + lines=[{"id": 1000, "order_id": [999, "Missing"], "product_id": [7, "P"]}] + ) + contact_repo = MagicMock() + order_repo = MagicMock() + order_repo.get_by_odoo_id.return_value = None + line_repo = MagicMock() + product_repo = MagicMock() + + service = SaleOrderSyncService(client, order_repo, line_repo, contact_repo, product_repo) + sync_run_logger = MagicMock() + stats = service.sync(sync_run_logger) + + assert stats.failed == 1 + line_repo.upsert.assert_not_called() + sync_run_logger.log_error.assert_called_once() + assert sync_run_logger.log_error.call_args[0][0] == "sale_order_line" + + def test_line_with_missing_product_logs_warning_but_still_saves(self): + client = _fake_client( + lines=[{"id": 1000, "order_id": [100, "S1"], "product_id": [999, "Ghost"]}] + ) + contact_repo = MagicMock() + order_repo = MagicMock() + order_repo.get_by_odoo_id.return_value = _mock_entity(1) + product_repo = MagicMock() + product_repo.get_by_odoo_id.return_value = None + line_repo = MagicMock() + line_repo.upsert.return_value = (MagicMock(), True) + + service = SaleOrderSyncService(client, order_repo, line_repo, contact_repo, product_repo) + sync_run_logger = MagicMock() + stats = service.sync(sync_run_logger) + + assert stats.created == 1 + called_data = line_repo.upsert.call_args[0][1] + assert called_data["product_id"] is None + sync_run_logger.log_warning.assert_called_once() + + def test_line_with_valid_order_and_product_resolves_both_fks(self): + client = _fake_client( + lines=[ + { + "id": 1000, + "order_id": [100, "S1"], + "product_id": [7, "Laptop"], + "product_uom_qty": 2, + "price_unit": 500, + "price_subtotal": 1000, + } + ] + ) + contact_repo = MagicMock() + order_repo = MagicMock() + order_repo.get_by_odoo_id.return_value = _mock_entity(1) + product_repo = MagicMock() + product_repo.get_by_odoo_id.return_value = _mock_entity(2) + line_repo = MagicMock() + line_repo.upsert.return_value = (MagicMock(), True) + + service = SaleOrderSyncService(client, order_repo, line_repo, contact_repo, product_repo) + stats = service.sync(sync_run_logger=MagicMock()) + + assert stats.created == 1 + called_data = line_repo.upsert.call_args[0][1] + assert called_data["sale_order_id"] == 1 + assert called_data["product_id"] == 2 diff --git a/backend/tests/unit/test_stats.py b/backend/tests/unit/test_stats.py new file mode 100644 index 0000000..cfe4fe1 --- /dev/null +++ b/backend/tests/unit/test_stats.py @@ -0,0 +1,16 @@ +from app.services.stats import SyncStats + + +def test_default_stats_are_zero(): + stats = SyncStats() + assert stats.as_dict() == {"fetched": 0, "created": 0, "updated": 0, "failed": 0} + + +def test_merge_adds_values_together(): + a = SyncStats(fetched=5, created=3, updated=1, failed=1) + b = SyncStats(fetched=2, created=0, updated=2, failed=0) + + a.merge(b) + + assert a.as_dict() == {"fetched": 7, "created": 3, "updated": 3, "failed": 1} + assert b.as_dict() == {"fetched": 2, "created": 0, "updated": 2, "failed": 0}