From 86c117ee2a1491f44c10c5e1ac29840c9a2704e0 Mon Sep 17 00:00:00 2001 From: Emad modaresi Date: Thu, 24 Sep 2026 12:32:59 +0330 Subject: [PATCH] fix error in gitignore --- .env.example | 28 ++ .gitignore | 220 +++++++++++++++ .idea/.gitignore | 8 + .idea/cetana-backend.iml | 23 ++ .idea/deployment.xml | 14 + .idea/inspectionProfiles/Project_Default.xml | 260 ++++++++++++++++++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + apps/__init__.py | 1 + apps/accounts/__init__.py | 1 + apps/accounts/apps.py | 6 + apps/accounts/migrations/0001_initial.py | 63 +++++ apps/accounts/migrations/__init__.py | 0 apps/accounts/models.py | 30 ++ apps/accounts/serializers.py | 55 ++++ apps/accounts/tests.py | 56 ++++ apps/accounts/urls.py | 12 + apps/accounts/views.py | 106 +++++++ apps/audit/__init__.py | 1 + apps/audit/apps.py | 6 + apps/audit/migrations/0001_initial.py | 36 +++ apps/audit/migrations/__init__.py | 0 apps/audit/models.py | 23 ++ apps/audit/serializers.py | 14 + apps/audit/service.py | 22 ++ apps/audit/urls.py | 8 + apps/audit/views.py | 12 + apps/core/__init__.py | 1 + apps/core/admin.py | 10 + apps/core/apps.py | 6 + apps/core/errors.py | 35 +++ apps/core/management/__init__.py | 1 + apps/core/management/commands/__init__.py | 1 + apps/core/management/commands/seed_demo.py | 176 ++++++++++++ apps/core/migrations/0001_initial.py | 31 +++ apps/core/migrations/__init__.py | 0 apps/core/models.py | 44 +++ apps/core/pagination.py | 7 + apps/core/settings_registry.py | 30 ++ apps/core/settings_service.py | 51 ++++ apps/core/tests.py | 21 ++ apps/core/views.py | 62 +++++ apps/integrations/__init__.py | 1 + apps/integrations/odoo/__init__.py | 1 + apps/integrations/odoo/client.py | 115 ++++++++ apps/integrations/odoo/services.py | 26 ++ apps/invoicing/__init__.py | 1 + apps/invoicing/apps.py | 6 + apps/invoicing/migrations/0001_initial.py | 33 +++ apps/invoicing/migrations/0002_initial.py | 22 ++ apps/invoicing/migrations/__init__.py | 0 apps/invoicing/models.py | 28 ++ apps/invoicing/serializers.py | 13 + apps/invoicing/tasks.py | 30 ++ apps/invoicing/urls.py | 8 + apps/invoicing/views.py | 52 ++++ apps/live_monitoring/__init__.py | 1 + apps/live_monitoring/apps.py | 6 + .../migrations/0001_initial.py | 59 ++++ apps/live_monitoring/migrations/__init__.py | 0 apps/live_monitoring/models.py | 30 ++ apps/live_monitoring/serializers.py | 10 + apps/live_monitoring/services.py | 55 ++++ apps/live_monitoring/tests.py | 26 ++ apps/live_monitoring/urls.py | 12 + apps/live_monitoring/views.py | 48 ++++ apps/notifications/__init__.py | 1 + apps/notifications/apps.py | 6 + apps/notifications/migrations/0001_initial.py | 64 +++++ apps/notifications/migrations/__init__.py | 0 apps/notifications/models.py | 36 +++ apps/notifications/providers.py | 61 ++++ apps/notifications/services.py | 39 +++ apps/orders/__init__.py | 1 + apps/orders/apps.py | 6 + apps/orders/migrations/0001_initial.py | 43 +++ apps/orders/migrations/__init__.py | 0 apps/orders/models.py | 62 +++++ apps/orders/serializers.py | 31 +++ apps/orders/services.py | 81 ++++++ apps/orders/tests.py | 72 +++++ apps/orders/urls.py | 8 + apps/orders/views.py | 101 +++++++ apps/payments/__init__.py | 1 + apps/payments/apps.py | 6 + apps/payments/gateways.py | 36 +++ apps/payments/migrations/0001_initial.py | 39 +++ ...eservation_alter_payment_order_and_more.py | 30 ++ apps/payments/migrations/__init__.py | 0 apps/payments/models.py | 41 +++ apps/payments/serializers.py | 13 + apps/payments/tests.py | 39 +++ apps/payments/urls.py | 8 + apps/payments/views.py | 82 ++++++ apps/pricing/__init__.py | 1 + apps/pricing/apps.py | 6 + apps/pricing/engine.py | 117 ++++++++ apps/pricing/migrations/0001_initial.py | 35 +++ apps/pricing/migrations/__init__.py | 0 apps/pricing/models.py | 33 +++ apps/pricing/tests.py | 41 +++ apps/processing/__init__.py | 1 + apps/processing/apps.py | 6 + apps/processing/migrations/0001_initial.py | 42 +++ apps/processing/migrations/__init__.py | 0 apps/processing/models.py | 43 +++ apps/processing/serializers.py | 16 ++ apps/processing/services.py | 94 +++++++ apps/processing/urls.py | 8 + apps/processing/views.py | 78 ++++++ apps/rbac/__init__.py | 1 + apps/rbac/apps.py | 6 + apps/rbac/migrations/0001_initial.py | 75 +++++ apps/rbac/migrations/__init__.py | 0 apps/rbac/models.py | 42 +++ apps/rbac/permissions.py | 60 ++++ apps/rbac/serializers.py | 17 ++ apps/rbac/tests.py | 23 ++ apps/rbac/views.py | 19 ++ apps/reports/__init__.py | 1 + apps/reports/apps.py | 6 + apps/reports/migrations/0001_initial.py | 63 +++++ apps/reports/migrations/__init__.py | 0 apps/reports/models.py | 48 ++++ apps/reports/serializers.py | 24 ++ apps/reports/services.py | 194 +++++++++++++ apps/reports/tests.py | 36 +++ apps/reports/urls.py | 9 + apps/reports/views.py | 69 +++++ apps/reservations/__init__.py | 1 + apps/reservations/apps.py | 6 + apps/reservations/migrations/0001_initial.py | 86 ++++++ ...ved_at_reservation_approved_by_and_more.py | 56 ++++ apps/reservations/migrations/__init__.py | 0 apps/reservations/models.py | 102 +++++++ apps/reservations/serializers.py | 53 ++++ apps/reservations/services.py | 91 ++++++ apps/reservations/slot_service.py | 56 ++++ apps/reservations/tests.py | 82 ++++++ apps/reservations/urls.py | 16 ++ apps/reservations/views.py | 171 ++++++++++++ apps/workflows/__init__.py | 1 + apps/workflows/apps.py | 6 + apps/workflows/engine.py | 169 ++++++++++++ apps/workflows/migrations/0001_initial.py | 109 ++++++++ ...step_position_x_workflowstep_position_y.py | 23 ++ apps/workflows/migrations/__init__.py | 0 apps/workflows/models.py | 111 ++++++++ apps/workflows/serializers.py | 34 +++ apps/workflows/tests.py | 75 +++++ apps/workflows/urls.py | 18 ++ apps/workflows/views.py | 66 +++++ config/__init__.py | 5 + config/asgi.py | 7 + config/celery.py | 9 + config/settings.py | 209 ++++++++++++++ config/urls.py | 32 +++ config/wsgi.py | 7 + db.sqlite3.bak | Bin 0 -> 872448 bytes dockerfile | 18 ++ manage.py | 21 ++ readme.md | 99 +++++++ requirements.txt | 12 + 165 files changed, 5963 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/cetana-backend.iml create mode 100644 .idea/deployment.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 apps/__init__.py create mode 100644 apps/accounts/__init__.py create mode 100644 apps/accounts/apps.py create mode 100644 apps/accounts/migrations/0001_initial.py create mode 100644 apps/accounts/migrations/__init__.py create mode 100644 apps/accounts/models.py create mode 100644 apps/accounts/serializers.py create mode 100644 apps/accounts/tests.py create mode 100644 apps/accounts/urls.py create mode 100644 apps/accounts/views.py create mode 100644 apps/audit/__init__.py create mode 100644 apps/audit/apps.py create mode 100644 apps/audit/migrations/0001_initial.py create mode 100644 apps/audit/migrations/__init__.py create mode 100644 apps/audit/models.py create mode 100644 apps/audit/serializers.py create mode 100644 apps/audit/service.py create mode 100644 apps/audit/urls.py create mode 100644 apps/audit/views.py create mode 100644 apps/core/__init__.py create mode 100644 apps/core/admin.py create mode 100644 apps/core/apps.py create mode 100644 apps/core/errors.py create mode 100644 apps/core/management/__init__.py create mode 100644 apps/core/management/commands/__init__.py create mode 100644 apps/core/management/commands/seed_demo.py create mode 100644 apps/core/migrations/0001_initial.py create mode 100644 apps/core/migrations/__init__.py create mode 100644 apps/core/models.py create mode 100644 apps/core/pagination.py create mode 100644 apps/core/settings_registry.py create mode 100644 apps/core/settings_service.py create mode 100644 apps/core/tests.py create mode 100644 apps/core/views.py create mode 100644 apps/integrations/__init__.py create mode 100644 apps/integrations/odoo/__init__.py create mode 100644 apps/integrations/odoo/client.py create mode 100644 apps/integrations/odoo/services.py create mode 100644 apps/invoicing/__init__.py create mode 100644 apps/invoicing/apps.py create mode 100644 apps/invoicing/migrations/0001_initial.py create mode 100644 apps/invoicing/migrations/0002_initial.py create mode 100644 apps/invoicing/migrations/__init__.py create mode 100644 apps/invoicing/models.py create mode 100644 apps/invoicing/serializers.py create mode 100644 apps/invoicing/tasks.py create mode 100644 apps/invoicing/urls.py create mode 100644 apps/invoicing/views.py create mode 100644 apps/live_monitoring/__init__.py create mode 100644 apps/live_monitoring/apps.py create mode 100644 apps/live_monitoring/migrations/0001_initial.py create mode 100644 apps/live_monitoring/migrations/__init__.py create mode 100644 apps/live_monitoring/models.py create mode 100644 apps/live_monitoring/serializers.py create mode 100644 apps/live_monitoring/services.py create mode 100644 apps/live_monitoring/tests.py create mode 100644 apps/live_monitoring/urls.py create mode 100644 apps/live_monitoring/views.py create mode 100644 apps/notifications/__init__.py create mode 100644 apps/notifications/apps.py create mode 100644 apps/notifications/migrations/0001_initial.py create mode 100644 apps/notifications/migrations/__init__.py create mode 100644 apps/notifications/models.py create mode 100644 apps/notifications/providers.py create mode 100644 apps/notifications/services.py create mode 100644 apps/orders/__init__.py create mode 100644 apps/orders/apps.py create mode 100644 apps/orders/migrations/0001_initial.py create mode 100644 apps/orders/migrations/__init__.py create mode 100644 apps/orders/models.py create mode 100644 apps/orders/serializers.py create mode 100644 apps/orders/services.py create mode 100644 apps/orders/tests.py create mode 100644 apps/orders/urls.py create mode 100644 apps/orders/views.py create mode 100644 apps/payments/__init__.py create mode 100644 apps/payments/apps.py create mode 100644 apps/payments/gateways.py create mode 100644 apps/payments/migrations/0001_initial.py create mode 100644 apps/payments/migrations/0002_payment_reservation_alter_payment_order_and_more.py create mode 100644 apps/payments/migrations/__init__.py create mode 100644 apps/payments/models.py create mode 100644 apps/payments/serializers.py create mode 100644 apps/payments/tests.py create mode 100644 apps/payments/urls.py create mode 100644 apps/payments/views.py create mode 100644 apps/pricing/__init__.py create mode 100644 apps/pricing/apps.py create mode 100644 apps/pricing/engine.py create mode 100644 apps/pricing/migrations/0001_initial.py create mode 100644 apps/pricing/migrations/__init__.py create mode 100644 apps/pricing/models.py create mode 100644 apps/pricing/tests.py create mode 100644 apps/processing/__init__.py create mode 100644 apps/processing/apps.py create mode 100644 apps/processing/migrations/0001_initial.py create mode 100644 apps/processing/migrations/__init__.py create mode 100644 apps/processing/models.py create mode 100644 apps/processing/serializers.py create mode 100644 apps/processing/services.py create mode 100644 apps/processing/urls.py create mode 100644 apps/processing/views.py create mode 100644 apps/rbac/__init__.py create mode 100644 apps/rbac/apps.py create mode 100644 apps/rbac/migrations/0001_initial.py create mode 100644 apps/rbac/migrations/__init__.py create mode 100644 apps/rbac/models.py create mode 100644 apps/rbac/permissions.py create mode 100644 apps/rbac/serializers.py create mode 100644 apps/rbac/tests.py create mode 100644 apps/rbac/views.py create mode 100644 apps/reports/__init__.py create mode 100644 apps/reports/apps.py create mode 100644 apps/reports/migrations/0001_initial.py create mode 100644 apps/reports/migrations/__init__.py create mode 100644 apps/reports/models.py create mode 100644 apps/reports/serializers.py create mode 100644 apps/reports/services.py create mode 100644 apps/reports/tests.py create mode 100644 apps/reports/urls.py create mode 100644 apps/reports/views.py create mode 100644 apps/reservations/__init__.py create mode 100644 apps/reservations/apps.py create mode 100644 apps/reservations/migrations/0001_initial.py create mode 100644 apps/reservations/migrations/0002_reservation_approved_at_reservation_approved_by_and_more.py create mode 100644 apps/reservations/migrations/__init__.py create mode 100644 apps/reservations/models.py create mode 100644 apps/reservations/serializers.py create mode 100644 apps/reservations/services.py create mode 100644 apps/reservations/slot_service.py create mode 100644 apps/reservations/tests.py create mode 100644 apps/reservations/urls.py create mode 100644 apps/reservations/views.py create mode 100644 apps/workflows/__init__.py create mode 100644 apps/workflows/apps.py create mode 100644 apps/workflows/engine.py create mode 100644 apps/workflows/migrations/0001_initial.py create mode 100644 apps/workflows/migrations/0002_workflowstep_position_x_workflowstep_position_y.py create mode 100644 apps/workflows/migrations/__init__.py create mode 100644 apps/workflows/models.py create mode 100644 apps/workflows/serializers.py create mode 100644 apps/workflows/tests.py create mode 100644 apps/workflows/urls.py create mode 100644 apps/workflows/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/celery.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 db.sqlite3.bak create mode 100644 dockerfile create mode 100644 manage.py create mode 100644 readme.md create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e392de2 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# ---- Django ---- +SECRET_KEY=change-me-in-production +DEBUG=1 +ALLOWED_HOSTS=localhost,127.0.0.1 + +# ---- Database (PostgreSQL URL; empty => local SQLite for dev/tests) ---- +DATABASE_URL= + +# ---- Odoo integration ---- +ODOO_URL= +ODOO_DATABASE= +ODOO_USERNAME= +ODOO_PASSWORD= +ODOO_API_KEY= + +# ---- Payment gateway ---- +PAYMENT_GATEWAY_PROVIDER=mock +PAYMENT_GATEWAY_MERCHANT_ID= + +# ---- SMS provider ---- +SMS_PROVIDER=mock +SMS_PROVIDER_API_KEY= + +# ---- Celery ---- +CELERY_BROKER_URL=redis://localhost:6379/0 + +# ---- Currency ---- +BASE_CURRENCY=IRR diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86220cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that +# can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, you +# could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/cetana-backend.iml b/.idea/cetana-backend.iml new file mode 100644 index 0000000..89e83f8 --- /dev/null +++ b/.idea/cetana-backend.iml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/deployment.xml b/.idea/deployment.xml new file mode 100644 index 0000000..cee9369 --- /dev/null +++ b/.idea/deployment.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..fe16759 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,260 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..90bbae1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..80e7565 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..40c51f1 --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1 @@ +# apps package diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000..45d5aa3 --- /dev/null +++ b/apps/accounts/__init__.py @@ -0,0 +1 @@ +# accounts app diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000..e3ceb5a --- /dev/null +++ b/apps/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..1f81eb6 --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('phone', models.CharField(blank=True, max_length=32)), + ('odoo_partner_id', models.CharField(blank=True, db_index=True, max_length=64)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'ordering': ['username'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('customer_code', models.CharField(blank=True, max_length=64, null=True, unique=True)), + ('company_name', models.CharField(blank=True, max_length=200)), + ('category', models.CharField(blank=True, db_index=True, max_length=64)), + ('odoo_partner_id', models.CharField(blank=True, db_index=True, max_length=64)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000..37b9814 --- /dev/null +++ b/apps/accounts/models.py @@ -0,0 +1,30 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class User(AbstractUser, UUIDModel): + """Application user. Identity may be backed by Odoo via `odoo_partner_id`.""" + + phone = models.CharField(max_length=32, blank=True) + odoo_partner_id = models.CharField(max_length=64, blank=True, db_index=True) + + class Meta: + ordering = ["username"] + + def __str__(self) -> str: + return self.username or self.email or str(self.id) + + +class Customer(UUIDModel, TimeStampedModel): + """Customer profile keyed to an external (Odoo) identity where available.""" + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="customer") + customer_code = models.CharField(max_length=64, unique=True, blank=True, null=True) + company_name = models.CharField(max_length=200, blank=True) + category = models.CharField(max_length=64, blank=True, db_index=True) + odoo_partner_id = models.CharField(max_length=64, blank=True, db_index=True) + + def __str__(self) -> str: + return self.company_name or str(self.user) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py new file mode 100644 index 0000000..e51ba9d --- /dev/null +++ b/apps/accounts/serializers.py @@ -0,0 +1,55 @@ +from rest_framework import serializers + +from apps.accounts.models import Customer, User +from apps.rbac.models import Role + + +class RoleBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Role + fields = ["id", "name", "slug"] + + +class UserSerializer(serializers.ModelSerializer): + roles = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField() + role_ids = serializers.ListField(child=serializers.UUIDField(), write_only=True, required=False) + + class Meta: + model = User + fields = [ + "id", "username", "email", "first_name", "last_name", "phone", + "is_active", "is_superuser", "roles", "permissions", "role_ids", + ] + read_only_fields = ["is_superuser"] + + def get_roles(self, obj): + return RoleBriefSerializer( + [ur.role for ur in obj.user_roles.select_related("role").all()], many=True + ).data + + def get_permissions(self, obj): + if obj.is_superuser: + return ["*"] + from apps.rbac.models import Permission + + return list( + Permission.objects.filter(roles__user_roles__user=obj).values_list("code", flat=True) + ) + + def create(self, validated_data): + # role_ids is a write-only helper; roles are synced by the viewset. + validated_data.pop("role_ids", None) + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data.pop("role_ids", None) + return super().update(instance, validated_data) + + +class CustomerSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + + class Meta: + model = Customer + fields = ["id", "user", "customer_code", "company_name", "category", "odoo_partner_id"] diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 0000000..5b5867a --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,56 @@ +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase + +from apps.rbac.models import Permission, Role + +User = get_user_model() + + +class UserManagementApiTests(APITestCase): + def setUp(self): + self.admin = User.objects.create_superuser(username="root", password="***") + self.client.force_authenticate(self.admin) + self.role = Role.objects.create(name="Operator", slug="operator-t") + self.role.permissions.add(Permission.objects.create(code="processing.start")) + + def test_create_user_with_role_ids(self): + response = self.client.post( + "/api/v1/users/", + { + "username": "newuser", + "email": "new@example.com", + "phone": "09120000000", + "password": "secret123", + "role_ids": [str(self.role.id)], + "is_active": True, + }, + format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + user = User.objects.get(username="newuser") + self.assertTrue(user.check_password("secret123")) + self.assertTrue(user.user_roles.filter(role=self.role).exists()) + + def test_update_user_role_ids(self): + user = User.objects.create_user(username="someone", password="***") + response = self.client.patch( + f"/api/v1/users/{user.id}/", {"role_ids": [str(self.role.id)]}, format="json" + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertTrue(user.user_roles.filter(role=self.role).exists()) + + def test_ban_and_unban(self): + user = User.objects.create_user(username="toban", password="***") + response = self.client.patch(f"/api/v1/users/{user.id}/", {"is_active": False}, format="json") + self.assertEqual(response.status_code, 200, response.content) + user.refresh_from_db() + self.assertFalse(user.is_active) + + def test_reset_password(self): + user = User.objects.create_user(username="pwuser", password="***") + response = self.client.post( + f"/api/v1/users/{user.id}/set-password/", {"new_password": "brandnew1"}, format="json" + ) + self.assertEqual(response.status_code, 200, response.content) + user.refresh_from_db() + self.assertTrue(user.check_password("brandnew1")) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000..c40cd29 --- /dev/null +++ b/apps/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from apps.accounts.views import ChangePasswordView, CustomerProfileView, MeView + +urlpatterns = [ + path("login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("me/", MeView.as_view(), name="me"), + path("me/change-password/", ChangePasswordView.as_view(), name="change_password"), + path("me/customer/", CustomerProfileView.as_view(), name="customer_profile"), +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py new file mode 100644 index 0000000..bc6b9d8 --- /dev/null +++ b/apps/accounts/views.py @@ -0,0 +1,106 @@ +from rest_framework import decorators, generics, permissions, response, status, viewsets +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.accounts.models import Customer, User +from apps.accounts.serializers import CustomerSerializer, UserSerializer +from apps.core.errors import error_response +from apps.rbac.permissions import ActionPermission + + +class UserViewSet(viewsets.ModelViewSet): + queryset = User.objects.prefetch_related("user_roles__role").all() + serializer_class = UserSerializer + permission_classes = [ActionPermission] + code_map = { + "list": "user.view", + "retrieve": "user.view", + "create": "user.manage", + "update": "user.manage", + "partial_update": "user.manage", + "destroy": "user.manage", + "set_password": "user.manage", + } + + def perform_create(self, serializer): + password = self.request.data.get("password") or "changeme123" + user = serializer.save() + user.set_password(password) + user.save(update_fields=["password"]) + self._sync_roles(user) + + def perform_update(self, serializer): + user = serializer.save() + self._sync_roles(user) + + def _sync_roles(self, user): + role_ids = self.request.data.get("role_ids") + if role_ids is None: + return + from apps.rbac.models import Role, UserRole + + user.user_roles.all().delete() + for role_id in role_ids: + role = Role.objects.filter(id=role_id).first() + if role is not None: + UserRole.objects.create(user=user, role=role) + + @decorators.action(detail=True, methods=["post"], url_path="set-password") + def set_password(self, request, pk=None): + user = self.get_object() + new_password = request.data.get("new_password", "") + if len(new_password) < 6: + return Response( + error_response("WEAK_PASSWORD", "Password must be at least 6 characters."), + status=status.HTTP_400_BAD_REQUEST, + ) + user.set_password(new_password) + user.save(update_fields=["password"]) + return Response({"success": True}) + + +class MeView(APIView): + """View and update the authenticated user's own profile.""" + + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + return Response(UserSerializer(request.user).data) + + def patch(self, request): + user = request.user + for field in ("first_name", "last_name", "email", "phone"): + if field in request.data: + setattr(user, field, request.data[field]) + user.save() + return Response(UserSerializer(user).data) + + +class ChangePasswordView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + old_password = request.data.get("old_password", "") + new_password = request.data.get("new_password", "") + if not request.user.check_password(old_password): + return Response( + error_response("INVALID_PASSWORD", "Current password is incorrect."), + status=400, + ) + if len(new_password) < 6: + return Response( + error_response("WEAK_PASSWORD", "New password must be at least 6 characters."), + status=400, + ) + request.user.set_password(new_password) + request.user.save(update_fields=["password"]) + return Response({"success": True}) + + +class CustomerProfileView(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated] + serializer_class = CustomerSerializer + + def get_object(self): + customer, _ = Customer.objects.get_or_create(user=self.request.user) + return customer diff --git a/apps/audit/__init__.py b/apps/audit/__init__.py new file mode 100644 index 0000000..0bead59 --- /dev/null +++ b/apps/audit/__init__.py @@ -0,0 +1 @@ +# audit app diff --git a/apps/audit/apps.py b/apps/audit/apps.py new file mode 100644 index 0000000..9ca5dc7 --- /dev/null +++ b/apps/audit/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuditConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.audit" diff --git a/apps/audit/migrations/0001_initial.py b/apps/audit/migrations/0001_initial.py new file mode 100644 index 0000000..362fe16 --- /dev/null +++ b/apps/audit/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AuditEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action', models.CharField(db_index=True, max_length=120)), + ('object_type', models.CharField(db_index=True, max_length=120)), + ('object_id', models.CharField(blank=True, db_index=True, max_length=120)), + ('ip', models.GenericIPAddressField(blank=True, null=True)), + ('before', models.JSONField(blank=True, default=dict)), + ('after', models.JSONField(blank=True, default=dict)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/audit/migrations/__init__.py b/apps/audit/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/audit/models.py b/apps/audit/models.py new file mode 100644 index 0000000..ad57b1a --- /dev/null +++ b/apps/audit/models.py @@ -0,0 +1,23 @@ +from django.db import models + +from apps.core.models import UUIDModel + + +class AuditEvent(UUIDModel): + """Immutable audit log entry. Records are never updated or deleted.""" + + actor = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL) + action = models.CharField(max_length=120, db_index=True) + object_type = models.CharField(max_length=120, db_index=True) + object_id = models.CharField(max_length=120, blank=True, db_index=True) + ip = models.GenericIPAddressField(null=True, blank=True) + before = models.JSONField(default=dict, blank=True) + after = models.JSONField(default=dict, blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.action} {self.object_type}:{self.object_id}" diff --git a/apps/audit/serializers.py b/apps/audit/serializers.py new file mode 100644 index 0000000..4f0cd1f --- /dev/null +++ b/apps/audit/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from apps.audit.models import AuditEvent + + +class AuditEventSerializer(serializers.ModelSerializer): + actor_name = serializers.CharField(source="actor.username", read_only=True) + + class Meta: + model = AuditEvent + fields = [ + "id", "actor", "actor_name", "action", "object_type", "object_id", + "ip", "before", "after", "metadata", "created_at", + ] diff --git a/apps/audit/service.py b/apps/audit/service.py new file mode 100644 index 0000000..5e6a7fe --- /dev/null +++ b/apps/audit/service.py @@ -0,0 +1,22 @@ +from apps.audit.models import AuditEvent + + +def record(actor, action, object_type, object_id="", before=None, after=None, metadata=None, request=None): + """Write an immutable audit event. Never raises to avoid breaking business flows.""" + ip = None + if request is not None: + xff = request.META.get("HTTP_X_FORWARDED_FOR") + ip = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR")) + try: + AuditEvent.objects.create( + actor=actor if actor and actor.is_authenticated else None, + action=action, + object_type=object_type, + object_id=str(object_id), + ip=ip, + before=before or {}, + after=after or {}, + metadata=metadata or {}, + ) + except Exception: + pass diff --git a/apps/audit/urls.py b/apps/audit/urls.py new file mode 100644 index 0000000..2874403 --- /dev/null +++ b/apps/audit/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from apps.audit.views import AuditEventViewSet + +router = DefaultRouter() +router.register("", AuditEventViewSet, basename="audit-event") + +urlpatterns = router.urls diff --git a/apps/audit/views.py b/apps/audit/views.py new file mode 100644 index 0000000..9699430 --- /dev/null +++ b/apps/audit/views.py @@ -0,0 +1,12 @@ +from rest_framework import mixins, viewsets + +from apps.audit.models import AuditEvent +from apps.audit.serializers import AuditEventSerializer +from apps.rbac.permissions import ActionPermission + + +class AuditEventViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = AuditEvent.objects.select_related("actor").all() + serializer_class = AuditEventSerializer + permission_classes = [ActionPermission] + code_map = {"list": "user.view", "retrieve": "user.view"} diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..f0d5083 --- /dev/null +++ b/apps/core/__init__.py @@ -0,0 +1 @@ +# core app diff --git a/apps/core/admin.py b/apps/core/admin.py new file mode 100644 index 0000000..c327dee --- /dev/null +++ b/apps/core/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from apps.core.models import SystemSetting + + +@admin.register(SystemSetting) +class SystemSettingAdmin(admin.ModelAdmin): + list_display = ("key", "category", "is_secret", "updated_at") + search_fields = ("key", "category") + list_filter = ("category", "is_secret") diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..ab0051e --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" diff --git a/apps/core/errors.py b/apps/core/errors.py new file mode 100644 index 0000000..87e4888 --- /dev/null +++ b/apps/core/errors.py @@ -0,0 +1,35 @@ +"""Consistent API error handling with stable error codes.""" + + +class ApiError(Exception): + """Raised by service/domain layers; converted to a consistent response.""" + + def __init__(self, code: str, message: str, details=None, status: int = 400): + self.code = code + self.message = message + self.details = details or {} + self.status = status + super().__init__(message) + + +def error_response(code: str, message: str, details=None) -> dict: + return {"success": False, "error": {"code": code, "message": message, "details": details or {}}} + + +def api_exception_handler(exc, context): + """DRF exception handler that emits the stable error envelope.""" + from rest_framework.views import exception_handler + from rest_framework.response import Response + + if isinstance(exc, ApiError): + return Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + + response = exception_handler(exc, context) + if response is not None: + if isinstance(response.data, dict) and "detail" in response.data and len(response.data) == 1: + return Response( + error_response("API_ERROR", str(response.data["detail"])), + status=response.status_code, + ) + return Response(error_response("API_ERROR", "Request failed", {"errors": response.data}), status=response.status_code) + return None diff --git a/apps/core/management/__init__.py b/apps/core/management/__init__.py new file mode 100644 index 0000000..0306ecd --- /dev/null +++ b/apps/core/management/__init__.py @@ -0,0 +1 @@ +# core management commands diff --git a/apps/core/management/commands/__init__.py b/apps/core/management/commands/__init__.py new file mode 100644 index 0000000..7836846 --- /dev/null +++ b/apps/core/management/commands/__init__.py @@ -0,0 +1 @@ +# commands diff --git a/apps/core/management/commands/seed_demo.py b/apps/core/management/commands/seed_demo.py new file mode 100644 index 0000000..c7d79b5 --- /dev/null +++ b/apps/core/management/commands/seed_demo.py @@ -0,0 +1,176 @@ +"""Seed demo data: roles, users, facilities/schedules/slots, a complete default +workflow (with approval + payment + invoice + Odoo-sync steps), pricing rules, +notification templates and a report definition. + +The workflow seeded here is demo DATA ONLY — the engine itself remains fully +dynamic and knows nothing about pistachio processing. +""" +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from apps.accounts.models import Customer +from apps.notifications.models import NotificationTemplate +from apps.pricing.models import PricingRule +from apps.rbac.models import Permission, Role +from apps.reports.models import ReportDefinition, ReportField +from apps.reservations.models import Facility, FacilitySchedule +from apps.reservations.slot_service import SlotService +from apps.workflows.engine import WorkflowEngine +from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion + +User = get_user_model() + +PERMISSIONS = [ + "reservation.view", "reservation.create", "reservation.update", "reservation.cancel", "reservation.approve", + "slot.manage", + "weighing.view", "weighing.create", "weighing.update", + "workflow.view", "workflow.create", "workflow.update", "workflow.publish", + "processing.view", "processing.start", "processing.pause", "processing.complete", + "invoice.view", "invoice.create", "invoice.cancel", + "report.view", "report.create", + "user.view", "user.manage", "role.view", "role.manage", + "settings.manage", +] + +ROLE_SPECS = { + "administrator": ("Administrator", PERMISSIONS), + "facility_manager": ("Facility Manager", [ + "reservation.view", "reservation.update", "reservation.cancel", "reservation.approve", "slot.manage", + "weighing.view", "weighing.create", "weighing.update", + "processing.view", "processing.start", "processing.complete", + "invoice.view", "report.view", "report.create", "user.view", + ]), + "operator": ("Operator", [ + "reservation.view", "weighing.view", "weighing.create", + "processing.view", "processing.start", "processing.pause", "processing.complete", + ]), + "customer": ("Customer", ["reservation.view", "reservation.create", "processing.view", "report.view"]), + "accountant": ("Accountant", ["invoice.view", "invoice.create", "invoice.cancel", "report.view"]), + "quality_controller": ("Quality Controller", ["processing.view", "processing.complete", "report.create"]), +} + +# (key, name, flags) +STEP_DEFS = [ + ("reservation", "Reservation", {"is_start": True}), + ("approval", "Approval", {}), + ("weighing", "Weighing", {"required": True}), + ("processing_selection", "Processing Selection", {"required": True}), + ("customer_confirmation", "Customer Confirmation", {"requires_customer_confirmation": True}), + ("payment", "Payment", {"requires_payment": True}), + ("sorting", "Sorting", {}), + ("washing", "Washing", {}), + ("drying", "Drying", {}), + ("quality_control", "Quality Control", {}), + ("packaging", "Packaging", {}), + ("final_weighing", "Final Weighing", {}), + ("final_report", "Final Report", {}), + ("invoice", "Invoice", {"triggers_invoice": True, "triggers_odoo_sync": True}), + ("completed", "Completed", {"is_terminal": True}), +] + +REPORT_FIELDS = [ + ("final_moisture", "Final Moisture", "decimal"), + ("quality_grade", "Quality Grade", "choice"), + ("broken_percentage", "Broken Percentage", "percentage"), + ("category_a_weight", "Category A Weight", "weight"), + ("category_b_weight", "Category B Weight", "weight"), + ("category_c_weight", "Category C Weight", "weight"), +] + + +class Command(BaseCommand): + help = "Seed demo roles, users, facilities, a default workflow, pricing, templates and reports." + + @transaction.atomic + def handle(self, *args, **options): + perms = {} + for code in PERMISSIONS: + perms[code], _ = Permission.objects.get_or_create(code=code, defaults={"description": code}) + + roles = {} + for slug, (name, codes) in ROLE_SPECS.items(): + role, _ = Role.objects.get_or_create(slug=slug, defaults={"name": name}) + role.permissions.set([perms[c] for c in codes]) + roles[slug] = role + + def make_user(username, role_slug, **kw): + user, _ = User.objects.get_or_create(username=username, defaults=kw) + user.set_password("demo1234") + user.save() + if not user.user_roles.filter(role=roles[role_slug]).exists(): + user.user_roles.create(role=roles[role_slug]) + return user + + make_user("admin", "administrator", is_staff=True, is_superuser=True, email="admin@example.com") + make_user("manager", "facility_manager") + make_user("operator", "operator") + make_user("accountant", "accountant") + make_user("qc", "quality_controller") + cust_user = make_user("customer", "customer", email="customer@example.com", phone="09120000000") + Customer.objects.get_or_create(user=cust_user, defaults={"company_name": "Demo Pistachio Co."}) + + # Facility + schedule + slots + facility, _ = Facility.objects.get_or_create(name="Main Facility") + tomorrow = timezone.localdate() + timezone.timedelta(days=1) + FacilitySchedule.objects.update_or_create( + facility=facility, date=tomorrow, defaults={"is_open": True, "capacity": 3} + ) + SlotService().generate_slots(facility, tomorrow) + + # Default workflow + workflow, _ = Workflow.objects.get_or_create( + slug="pistachio-processing", + defaults={"name": "Pistachio Processing", "description": "Default configurable workflow"}, + ) + version = WorkflowVersion.objects.create(workflow=workflow, version=workflow.versions.count() + 1) + steps = {} + for order, (key, name, flags) in enumerate(STEP_DEFS): + steps[key] = WorkflowStep.objects.create( + version=version, key=key, name=name, order=order, + position_x=80 + (order % 5) * 200, position_y=40 + (order // 5) * 160, + **flags, + ) + chain = [d[0] for d in STEP_DEFS] + for a, b in zip(chain, chain[1:]): + WorkflowTransition.objects.create(version=version, from_step=steps[a], to_step=steps[b]) + + # Pricing + PricingRule.objects.get_or_create(step=steps["washing"], name="Washing per kg", rule_type="per_kg", amount=Decimal("5000")) + PricingRule.objects.get_or_create(step=steps["drying"], name="Drying per kg", rule_type="per_kg", amount=Decimal("8000")) + PricingRule.objects.get_or_create(step=steps["sorting"], name="Sorting per kg", rule_type="per_kg", amount=Decimal("12000")) + + # Report definition + definition, _ = ReportDefinition.objects.get_or_create( + workflow=workflow, name="Default Processing Report", defaults={"active": True} + ) + for order, (key, label, field_type) in enumerate(REPORT_FIELDS): + ReportField.objects.get_or_create( + definition=definition, key=key, + defaults={"label": label, "field_type": field_type, "order": order}, + ) + + # Notification templates + NotificationTemplate.objects.get_or_create( + event="reservation.approved", channel="sms", + defaults={"body": "Cetana: reservation {{reservation_title}} on {{slot_date}} at {{slot_time}} is confirmed."}, + ) + NotificationTemplate.objects.get_or_create( + event="order.completed", channel="sms", + defaults={"body": "Cetana: order {{order_number}} is complete. Final report is ready."}, + ) + + try: + WorkflowEngine().publish(workflow, version) + except Exception as exc: # pragma: no cover + self.stdout.write(self.style.WARNING(f"Workflow publish skipped: {exc}")) + + # Pre-fill the Odoo server URL (credentials are entered in Settings). + from apps.core.models import SystemSetting + + SystemSetting.objects.get_or_create(key="odoo.url", defaults={"value": "https://rayayazd.ir"}) + + self.stdout.write(self.style.SUCCESS("Seed data created. Logins use password 'demo1234'.")) diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..3a0af4b --- /dev/null +++ b/apps/core/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.17 on 2026-09-15 10:31 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SystemSetting', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('key', models.CharField(max_length=200, unique=True)), + ('value', models.JSONField(blank=True, default=dict)), + ('category', models.CharField(blank=True, db_index=True, max_length=100)), + ('is_secret', models.BooleanField(default=False)), + ('description', models.CharField(blank=True, max_length=255)), + ], + options={ + 'ordering': ['key'], + }, + ), + ] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..a527578 --- /dev/null +++ b/apps/core/models.py @@ -0,0 +1,44 @@ +import uuid + +from django.db import models + + +class UUIDModel(models.Model): + """Abstract model with a UUID primary key.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + + +class TimeStampedModel(models.Model): + """Abstract model with created/updated timestamps.""" + + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class SystemSetting(UUIDModel, TimeStampedModel): + """Generic, dynamic runtime configuration (Odoo, SMS, payment, ...). + + Settings are keyed by dotted path (e.g. ``odoo.url``) and stored as JSON + values. The schema that drives the settings panel lives in + ``apps.core.settings_registry`` — adding a provider only requires adding + entries there, not schema changes. + """ + + key = models.CharField(max_length=200, unique=True) + value = models.JSONField(default=dict, blank=True) + category = models.CharField(max_length=100, db_index=True, blank=True) + is_secret = models.BooleanField(default=False) + description = models.CharField(max_length=255, blank=True) + + class Meta: + ordering = ["key"] + + def __str__(self) -> str: + return self.key diff --git a/apps/core/pagination.py b/apps/core/pagination.py new file mode 100644 index 0000000..6bb0381 --- /dev/null +++ b/apps/core/pagination.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class StandardPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 200 diff --git a/apps/core/settings_registry.py b/apps/core/settings_registry.py new file mode 100644 index 0000000..af69080 --- /dev/null +++ b/apps/core/settings_registry.py @@ -0,0 +1,30 @@ +"""Settings schema registry. + +The single source of truth for which runtime settings exist. The settings panel +is rendered from this list, so adding a new integration/provider only requires +adding entries here — no frontend or model changes. + +Field types: ``string``, ``secret``, ``boolean``, ``choice``, ``number``. +""" +SETTINGS_SCHEMA = [ + # --- Odoo integration --- + {"key": "odoo.enabled", "label": "Enable Odoo integration", "type": "boolean", "category": "Odoo", "default": False}, + {"key": "odoo.url", "label": "Odoo URL", "type": "string", "category": "Odoo", "default": "", "placeholder": "https://odoo.example.com"}, + {"key": "odoo.database", "label": "Database", "type": "string", "category": "Odoo", "default": ""}, + {"key": "odoo.username", "label": "Username", "type": "string", "category": "Odoo", "default": ""}, + {"key": "odoo.auth_method", "label": "Auth method", "type": "choice", "category": "Odoo", "options": ["password", "api_key"], "default": "password"}, + {"key": "odoo.password", "label": "Password", "type": "secret", "category": "Odoo", "default": ""}, + {"key": "odoo.api_key", "label": "API key", "type": "secret", "category": "Odoo", "default": ""}, + # --- SMS provider --- + {"key": "sms.provider", "label": "SMS provider", "type": "choice", "category": "SMS", "options": ["mock", "http"], "default": "mock"}, + {"key": "sms.endpoint", "label": "HTTP endpoint", "type": "string", "category": "SMS", "default": "", "placeholder": "https://sms.provider.example/api/send"}, + {"key": "sms.api_key", "label": "API key", "type": "secret", "category": "SMS", "default": ""}, + {"key": "sms.sender", "label": "Sender number", "type": "string", "category": "SMS", "default": ""}, + # --- Reservation schedule (dynamic slot generation) --- + {"key": "reservation.working_hours_start", "label": "Working hours start", "type": "string", "category": "Reservation", "default": "08:00", "placeholder": "HH:MM"}, + {"key": "reservation.working_hours_end", "label": "Working hours end", "type": "string", "category": "Reservation", "default": "17:00", "placeholder": "HH:MM"}, + {"key": "reservation.slot_duration_minutes", "label": "Slot duration (minutes)", "type": "number", "category": "Reservation", "default": 30}, + {"key": "reservation.default_capacity", "label": "Default capacity per slot", "type": "number", "category": "Reservation", "default": 2}, +] + +SCHEMA_BY_KEY = {entry["key"]: entry for entry in SETTINGS_SCHEMA} diff --git a/apps/core/settings_service.py b/apps/core/settings_service.py new file mode 100644 index 0000000..5eee1d9 --- /dev/null +++ b/apps/core/settings_service.py @@ -0,0 +1,51 @@ +"""Runtime settings service. + +Resolution order per key: database value → environment variable → schema +default. Writing a value persists it to the database, so changes take effect +immediately without a restart. +""" +import os + +from apps.core.models import SystemSetting +from apps.core.settings_registry import SCHEMA_BY_KEY, SETTINGS_SCHEMA + + +class SettingsService: + def schema(self): + return SETTINGS_SCHEMA + + def _env_key(self, key: str) -> str: + return key.upper().replace(".", "_") + + def get(self, key: str, default=None): + setting = SystemSetting.objects.filter(key=key).first() + if setting is not None: + return setting.value + + env_value = os.environ.get(self._env_key(key)) + if env_value is not None: + return env_value + + entry = SCHEMA_BY_KEY.get(key) + if entry and "default" in entry: + return entry["default"] + return default + + def get_bool(self, key: str, default=False) -> bool: + value = self.get(key, default) + if isinstance(value, bool): + return value + return str(value).lower() in {"1", "true", "yes", "on"} + + def set(self, key: str, value) -> None: + SystemSetting.objects.update_or_create(key=key, defaults={"value": value}) + + def all_values(self, mask_secrets: bool = True) -> dict: + result = {} + for entry in SETTINGS_SCHEMA: + value = self.get(entry["key"]) + if mask_secrets and entry.get("type") == "secret" and value: + result[entry["key"]] = "••••••" + else: + result[entry["key"]] = value + return result diff --git a/apps/core/tests.py b/apps/core/tests.py new file mode 100644 index 0000000..373a2b7 --- /dev/null +++ b/apps/core/tests.py @@ -0,0 +1,21 @@ +from django.test import TestCase + +from apps.core.settings_service import SettingsService + + +class SettingsServiceTests(TestCase): + def test_get_returns_schema_default(self): + service = SettingsService() + self.assertEqual(service.get("sms.provider"), "mock") + self.assertIs(service.get("odoo.enabled"), False) + + def test_set_and_get(self): + service = SettingsService() + service.set("odoo.url", "https://odoo.example.com") + self.assertEqual(service.get("odoo.url"), "https://odoo.example.com") + + def test_secrets_are_masked(self): + service = SettingsService() + service.set("odoo.api_key", "super-secret") + values = service.all_values() + self.assertEqual(values["odoo.api_key"], "••••••") diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..0674057 --- /dev/null +++ b/apps/core/views.py @@ -0,0 +1,62 @@ +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.core.settings_registry import SCHEMA_BY_KEY +from apps.core.settings_service import SettingsService +from apps.rbac.permissions import HasPermission + + +class SettingsView(APIView): + """Read/write runtime integration settings. + + GET returns the schema (drives the panel) and current values (secrets + masked). PUT accepts ``{"values": {"key": value, ...}}`` and persists them. + """ + + permission_classes = [HasPermission] + perm_code = "settings.manage" + + def get(self, request): + service = SettingsService() + return Response({"schema": service.schema(), "values": service.all_values()}) + + def put(self, request): + service = SettingsService() + values = request.data.get("values") or {} + for key, value in values.items(): + entry = SCHEMA_BY_KEY.get(key) + if entry is None: + continue + # Never overwrite a secret with an empty value (means "unchanged"). + if entry.get("type") == "secret" and value in ("", None): + continue + service.set(key, value) + return Response({"values": service.all_values()}) + + +class TestOdooView(APIView): + """Attempt an XML-RPC connection using the currently saved Odoo settings.""" + + permission_classes = [HasPermission] + perm_code = "settings.manage" + + def post(self, request): + from apps.integrations.odoo.client import OdooXmlRpcClient + + service = SettingsService() + url = service.get("odoo.url", "") + if not url: + return Response({"ok": False, "error": "Odoo URL is not configured."}) + client = OdooXmlRpcClient( + url=url, + db=service.get("odoo.database", ""), + username=service.get("odoo.username", ""), + password=service.get("odoo.password", ""), + api_key=service.get("odoo.api_key", ""), + auth_method=service.get("odoo.auth_method", "password"), + ) + try: + result = client.test_connection() + return Response({"ok": True, "version": result["version"], "uid": str(result["uid"])}) + except Exception as exc: + return Response({"ok": False, "error": str(exc)}) diff --git a/apps/integrations/__init__.py b/apps/integrations/__init__.py new file mode 100644 index 0000000..0f9e9b3 --- /dev/null +++ b/apps/integrations/__init__.py @@ -0,0 +1 @@ +# integrations package diff --git a/apps/integrations/odoo/__init__.py b/apps/integrations/odoo/__init__.py new file mode 100644 index 0000000..5a53ec0 --- /dev/null +++ b/apps/integrations/odoo/__init__.py @@ -0,0 +1 @@ +# odoo integration (service layer, not a Django app) diff --git a/apps/integrations/odoo/client.py b/apps/integrations/odoo/client.py new file mode 100644 index 0000000..9526662 --- /dev/null +++ b/apps/integrations/odoo/client.py @@ -0,0 +1,115 @@ +"""Odoo client abstraction. + +Cetana never spreads raw Odoo calls through the codebase. Everything goes +through this client behind an interface. The concrete client is chosen at +runtime from the dynamic settings store (apps.core.settings_service), so the +integration can be configured and switched from the admin panel without a +deploy — with a mock fallback when Odoo is not configured. +""" +import xmlrpc.client +from typing import Protocol + +from apps.core.settings_service import SettingsService + + +class OdooClientInterface(Protocol): + def search_partner(self, key: str, value) -> dict | None: ... + def create_partner(self, payload: dict) -> dict: ... + def create_invoice(self, payload: dict) -> dict: ... + + +class MockOdooClient: + """Deterministic in-process stand-in used when Odoo is not configured.""" + + def search_partner(self, key: str, value) -> dict | None: + return None + + def create_partner(self, payload: dict) -> dict: + return {"id": f"MOCK-PARTNER-{abs(hash(str(payload))) % 10_000_000}"} + + def create_invoice(self, payload: dict) -> dict: + return {"id": f"MOCK-INV-{abs(hash(str(payload))) % 10_000_000}"} + + +class OdooXmlRpcClient: + """Real Odoo client over the standard XML-RPC API (stdlib only).""" + + def __init__(self, url, db, username, password="", api_key="", auth_method="password"): + self.url = (url or "").rstrip("/") + self.db = db + self.username = username + self.password = password + self.api_key = api_key + self.auth_method = auth_method + self._uid = None + + def _authenticate(self): + common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common") + secret = self.api_key if self.auth_method == "api_key" else self.password + self._uid = common.authenticate(self.db, self.username, secret, {}) + return self._uid + + def _execute(self, model, method, method_args, kwargs=None): + if self._uid is None: + self._authenticate() + secret = self.api_key if self.auth_method == "api_key" else self.password + models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object") + return models.execute_kw(self.db, self._uid, secret, model, method, method_args, kwargs or {}) + + def search_partner(self, key: str, value) -> dict | None: + results = self._execute( + "res.partner", "search_read", + [[[key, "=", value]]], + {"limit": 1, "fields": ["id", "name"]}, + ) + return results[0] if results else None + + def create_partner(self, payload: dict) -> dict: + partner_id = self._execute("res.partner", "create", [payload]) + return {"id": partner_id} + + def create_invoice(self, payload: dict) -> dict: + ref = payload.get("partner_ref") or "" + partner = None + if ref: + partner = self.search_partner("ref", ref) or self.search_partner("name", ref) + if partner is None and ref: + partner = {"id": self._execute("res.partner", "create", [{"name": ref}])} + vals = { + "move_type": "out_invoice", + "ref": payload.get("reference"), + "partner_id": partner["id"] if partner else False, + "invoice_line_ids": [(0, 0, { + "name": payload.get("reference") or "Processing", + "quantity": 1, + "price_unit": float(payload.get("amount") or 0), + })], + } + invoice_id = self._execute("account.move", "create", [vals]) + return {"id": invoice_id} + + def test_connection(self) -> dict: + """Validate reachability + credentials. Returns server version and uid.""" + common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common") + version = common.version() + uid = self._authenticate() + if not uid: + raise ValueError("Authentication failed — check database, username and secret.") + return {"version": version, "uid": uid} + + +def get_odoo_client() -> OdooClientInterface: + service = SettingsService() + if not service.get_bool("odoo.enabled", False): + return MockOdooClient() + url = service.get("odoo.url", "") + if not url: + return MockOdooClient() + return OdooXmlRpcClient( + url=url, + db=service.get("odoo.database", ""), + username=service.get("odoo.username", ""), + password=service.get("odoo.password", ""), + api_key=service.get("odoo.api_key", ""), + auth_method=service.get("odoo.auth_method", "password"), + ) diff --git a/apps/integrations/odoo/services.py b/apps/integrations/odoo/services.py new file mode 100644 index 0000000..4a386c1 --- /dev/null +++ b/apps/integrations/odoo/services.py @@ -0,0 +1,26 @@ +from apps.integrations.odoo.client import OdooClientInterface, get_odoo_client + + +class OdooPartnerService: + def __init__(self, client: OdooClientInterface | None = None): + self.client = client or get_odoo_client() + + def lookup(self, key: str, value) -> dict | None: + return self.client.search_partner(key, value) + + def create(self, payload: dict) -> dict: + return self.client.create_partner(payload) + + +class OdooInvoiceService: + def __init__(self, client: OdooClientInterface | None = None): + self.client = client or get_odoo_client() + + def sync_invoice(self, invoice) -> dict: + payload = { + "reference": invoice.number, + "amount": str(invoice.amount), + "currency": invoice.currency, + "partner_ref": invoice.order.customer.odoo_partner_id or "", + } + return self.client.create_invoice(payload) diff --git a/apps/invoicing/__init__.py b/apps/invoicing/__init__.py new file mode 100644 index 0000000..529179d --- /dev/null +++ b/apps/invoicing/__init__.py @@ -0,0 +1 @@ +# invoicing app diff --git a/apps/invoicing/apps.py b/apps/invoicing/apps.py new file mode 100644 index 0000000..5434b07 --- /dev/null +++ b/apps/invoicing/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InvoicingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.invoicing" diff --git a/apps/invoicing/migrations/0001_initial.py b/apps/invoicing/migrations/0001_initial.py new file mode 100644 index 0000000..91c6837 --- /dev/null +++ b/apps/invoicing/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('number', models.CharField(max_length=40, unique=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=18)), + ('currency', models.CharField(default='IRR', max_length=8)), + ('sync_state', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('synchronized', 'Synchronized'), ('failed', 'Failed'), ('retrying', 'Retrying'), ('cancelled', 'Cancelled')], default='pending', max_length=16)), + ('odoo_invoice_id', models.CharField(blank=True, max_length=120)), + ('last_error', models.TextField(blank=True)), + ('synced_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/invoicing/migrations/0002_initial.py b/apps/invoicing/migrations/0002_initial.py new file mode 100644 index 0000000..3067a05 --- /dev/null +++ b/apps/invoicing/migrations/0002_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('invoicing', '0001_initial'), + ('orders', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='orders.processingorder'), + ), + ] diff --git a/apps/invoicing/migrations/__init__.py b/apps/invoicing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/invoicing/models.py b/apps/invoicing/models.py new file mode 100644 index 0000000..c249521 --- /dev/null +++ b/apps/invoicing/models.py @@ -0,0 +1,28 @@ +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class Invoice(UUIDModel, TimeStampedModel): + class SyncState(models.TextChoices): + PENDING = "pending", "Pending" + PROCESSING = "processing", "Processing" + SYNCHRONIZED = "synchronized", "Synchronized" + FAILED = "failed", "Failed" + RETRYING = "retrying", "Retrying" + CANCELLED = "cancelled", "Cancelled" + + order = models.ForeignKey("orders.ProcessingOrder", on_delete=models.PROTECT, related_name="invoices") + number = models.CharField(max_length=40, unique=True) + amount = models.DecimalField(max_digits=18, decimal_places=2) + currency = models.CharField(max_length=8, default="IRR") + sync_state = models.CharField(max_length=16, choices=SyncState.choices, default=SyncState.PENDING) + odoo_invoice_id = models.CharField(max_length=120, blank=True) + last_error = models.TextField(blank=True) + synced_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.number diff --git a/apps/invoicing/serializers.py b/apps/invoicing/serializers.py new file mode 100644 index 0000000..4a2cd6e --- /dev/null +++ b/apps/invoicing/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from apps.invoicing.models import Invoice + + +class InvoiceSerializer(serializers.ModelSerializer): + class Meta: + model = Invoice + fields = "__all__" + read_only_fields = [ + "id", "number", "amount", "currency", "sync_state", + "odoo_invoice_id", "last_error", "synced_at", + ] diff --git a/apps/invoicing/tasks.py b/apps/invoicing/tasks.py new file mode 100644 index 0000000..0f9fab9 --- /dev/null +++ b/apps/invoicing/tasks.py @@ -0,0 +1,30 @@ +from celery import shared_task +from django.utils import timezone + +from apps.integrations.odoo.services import OdooInvoiceService +from apps.invoicing.models import Invoice + + +@shared_task(bind=True, max_retries=5, default_retry_delay=60) +def sync_invoice_to_odoo(self, invoice_id): + """Synchronize an invoice to Odoo with automatic retry (idempotent).""" + try: + invoice = Invoice.objects.get(pk=invoice_id) + except Invoice.DoesNotExist: + return + if invoice.sync_state == Invoice.SyncState.SYNCHRONIZED: + return # already synchronized; retries must be idempotent + + invoice.sync_state = Invoice.SyncState.PROCESSING + invoice.save(update_fields=["sync_state"]) + try: + result = OdooInvoiceService().sync_invoice(invoice) + invoice.odoo_invoice_id = result.get("id", "") + invoice.sync_state = Invoice.SyncState.SYNCHRONIZED + invoice.synced_at = timezone.now() + invoice.save(update_fields=["odoo_invoice_id", "sync_state", "synced_at"]) + except Exception as exc: + invoice.sync_state = Invoice.SyncState.RETRYING + invoice.last_error = str(exc) + invoice.save(update_fields=["sync_state", "last_error"]) + raise self.retry(exc=exc) diff --git a/apps/invoicing/urls.py b/apps/invoicing/urls.py new file mode 100644 index 0000000..6b9d236 --- /dev/null +++ b/apps/invoicing/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from apps.invoicing.views import InvoiceViewSet + +router = DefaultRouter() +router.register("", InvoiceViewSet, basename="invoice") + +urlpatterns = router.urls diff --git a/apps/invoicing/views.py b/apps/invoicing/views.py new file mode 100644 index 0000000..0718e68 --- /dev/null +++ b/apps/invoicing/views.py @@ -0,0 +1,52 @@ +from django.utils import timezone +from rest_framework import decorators, mixins, response, viewsets + +from apps.core.errors import ApiError +from apps.integrations.odoo.services import OdooInvoiceService +from apps.invoicing.models import Invoice +from apps.invoicing.serializers import InvoiceSerializer +from apps.rbac.permissions import ActionPermission + + +class InvoiceViewSet( + mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet +): + serializer_class = InvoiceSerializer + permission_classes = [ActionPermission] + code_map = { + "list": "invoice.view", + "retrieve": "invoice.view", + "create": "invoice.create", + "sync": "invoice.create", + } + + def get_queryset(self): + user = self.request.user + qs = Invoice.objects.select_related("order").all() + if user.is_superuser: + return qs + return qs.filter(order__customer__user=user) + + def perform_create(self, serializer): + order = serializer.validated_data["order"] + total = (order.price_snapshot or {}).get("total") + if not total: + raise ApiError("NO_PRICE_SNAPSHOT", "Order has no price snapshot.") + serializer.save(number=f"INV-{order.order_number}", amount=total, currency=order.currency) + + @decorators.action(detail=True, methods=["post"]) + def sync(self, request, pk=None): + invoice = self.get_object() + invoice.sync_state = Invoice.SyncState.PROCESSING + invoice.save(update_fields=["sync_state"]) + try: + result = OdooInvoiceService().sync_invoice(invoice) + invoice.odoo_invoice_id = result.get("id", "") + invoice.sync_state = Invoice.SyncState.SYNCHRONIZED + invoice.synced_at = timezone.now() + invoice.save(update_fields=["odoo_invoice_id", "sync_state", "synced_at"]) + except Exception as exc: + invoice.sync_state = Invoice.SyncState.FAILED + invoice.last_error = str(exc) + invoice.save(update_fields=["sync_state", "last_error"]) + return response.Response(InvoiceSerializer(invoice).data) diff --git a/apps/live_monitoring/__init__.py b/apps/live_monitoring/__init__.py new file mode 100644 index 0000000..13efb80 --- /dev/null +++ b/apps/live_monitoring/__init__.py @@ -0,0 +1 @@ +# live_monitoring app diff --git a/apps/live_monitoring/apps.py b/apps/live_monitoring/apps.py new file mode 100644 index 0000000..6b6c601 --- /dev/null +++ b/apps/live_monitoring/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LiveMonitoringConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.live_monitoring" diff --git a/apps/live_monitoring/migrations/0001_initial.py b/apps/live_monitoring/migrations/0001_initial.py new file mode 100644 index 0000000..73c4070 --- /dev/null +++ b/apps/live_monitoring/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('workflows', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Camera', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('facility', models.CharField(blank=True, max_length=200)), + ('processing_line', models.CharField(blank=True, max_length=200)), + ('stream_url', models.URLField()), + ('auth_config', models.JSONField(blank=True, default=dict)), + ('active', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LiveStream', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(blank=True, max_length=200)), + ('active', models.BooleanField(default=True)), + ('camera', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='streams', to='live_monitoring.camera')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='WorkflowStepCamera', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('camera', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflow_steps', to='live_monitoring.camera')), + ('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cameras', to='workflows.workflowstep')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/live_monitoring/migrations/__init__.py b/apps/live_monitoring/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/live_monitoring/models.py b/apps/live_monitoring/models.py new file mode 100644 index 0000000..37497d2 --- /dev/null +++ b/apps/live_monitoring/models.py @@ -0,0 +1,30 @@ +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class Camera(UUIDModel, TimeStampedModel): + name = models.CharField(max_length=200) + facility = models.CharField(max_length=200, blank=True) + processing_line = models.CharField(max_length=200, blank=True) + stream_url = models.URLField() + # Never expose credentials to the frontend; keep auth config server-side. + auth_config = models.JSONField(default=dict, blank=True) + active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class LiveStream(UUIDModel, TimeStampedModel): + camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="streams") + name = models.CharField(max_length=200, blank=True) + active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name or self.camera.name + + +class WorkflowStepCamera(UUIDModel): + step = models.ForeignKey("workflows.WorkflowStep", on_delete=models.CASCADE, related_name="cameras") + camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="workflow_steps") diff --git a/apps/live_monitoring/serializers.py b/apps/live_monitoring/serializers.py new file mode 100644 index 0000000..74ffa4a --- /dev/null +++ b/apps/live_monitoring/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from apps.live_monitoring.models import Camera + + +class CameraSerializer(serializers.ModelSerializer): + # auth_config is never exposed to the frontend. + class Meta: + model = Camera + fields = ["id", "name", "facility", "processing_line", "stream_url", "active"] diff --git a/apps/live_monitoring/services.py b/apps/live_monitoring/services.py new file mode 100644 index 0000000..022f5d4 --- /dev/null +++ b/apps/live_monitoring/services.py @@ -0,0 +1,55 @@ +"""Secure live-stream access. + +Camera credentials are never exposed. A customer can request short-lived, +HMAC-signed access tokens for streams authorized for their own orders; the +`verify` endpoint validates the token and returns the stream URL. +""" +import base64 +import hashlib +import hmac +import json +import time + +from django.conf import settings + +from apps.live_monitoring.models import Camera, WorkflowStepCamera +from apps.orders.models import ProcessingOrder +from apps.workflows.models import WorkflowStep + + +class StreamAccessService: + def can_access(self, user, camera: Camera) -> bool: + if not user or not user.is_authenticated: + return False + if user.is_superuser: + return True + version_ids = ProcessingOrder.objects.filter(customer__user=user).values_list( + "workflow_version_id", flat=True + ) + step_ids = WorkflowStep.objects.filter( + version_id__in=version_ids, live_monitoring_enabled=True + ).values_list("id", flat=True) + return WorkflowStepCamera.objects.filter(camera=camera, step_id__in=step_ids).exists() + + def issue_token(self, camera: Camera, ttl: int = 3600) -> tuple: + payload = {"camera": str(camera.id), "exp": int(time.time()) + ttl} + raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + sig = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest() + return f"{raw}.{sig}", payload["exp"] + + def verify_token(self, token: str): + try: + raw, sig = token.split(".", 1) + except (ValueError, AttributeError): + return None + expected = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, sig): + return None + try: + pad = raw + "=" * (-len(raw) % 4) + payload = json.loads(base64.urlsafe_b64decode(pad).decode()) + except Exception: + return None + if payload.get("exp", 0) < int(time.time()): + return None + return payload diff --git a/apps/live_monitoring/tests.py b/apps/live_monitoring/tests.py new file mode 100644 index 0000000..1f1596e --- /dev/null +++ b/apps/live_monitoring/tests.py @@ -0,0 +1,26 @@ +from django.test import TestCase + +from apps.live_monitoring.models import Camera +from apps.live_monitoring.services import StreamAccessService + + +class StreamAccessTests(TestCase): + def setUp(self): + self.camera = Camera.objects.create(name="Cam1", stream_url="https://example.com/live.m3u8") + self.service = StreamAccessService() + + def test_issue_and_verify_token(self): + token, exp = self.service.issue_token(self.camera) + payload = self.service.verify_token(token) + self.assertIsNotNone(payload) + self.assertEqual(payload["camera"], str(self.camera.id)) + self.assertGreater(payload["exp"], exp - 1) + + def test_tampered_token_rejected(self): + token, _ = self.service.issue_token(self.camera) + raw, _sig = token.split(".", 1) + self.assertIsNone(self.service.verify_token(raw + ".deadbeef")) + + def test_expired_token_rejected(self): + token, _ = self.service.issue_token(self.camera, ttl=-1) + self.assertIsNone(self.service.verify_token(token)) diff --git a/apps/live_monitoring/urls.py b/apps/live_monitoring/urls.py new file mode 100644 index 0000000..fe09515 --- /dev/null +++ b/apps/live_monitoring/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter + +from apps.live_monitoring.views import CameraViewSet, VerifyStreamView + +router = DefaultRouter() +router.register("cameras", CameraViewSet, basename="camera") + +urlpatterns = [ + path("streams/verify/", VerifyStreamView.as_view(), name="verify-stream"), + *router.urls, +] diff --git a/apps/live_monitoring/views.py b/apps/live_monitoring/views.py new file mode 100644 index 0000000..c9f4999 --- /dev/null +++ b/apps/live_monitoring/views.py @@ -0,0 +1,48 @@ +from rest_framework import decorators, mixins, permissions, response, status, views, viewsets + +from apps.core.errors import error_response +from apps.live_monitoring.models import Camera +from apps.live_monitoring.serializers import CameraSerializer +from apps.live_monitoring.services import StreamAccessService +from apps.rbac.permissions import ActionPermission + + +class CameraViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = Camera.objects.filter(active=True) + serializer_class = CameraSerializer + permission_classes = [ActionPermission] + code_map = {"list": "processing.view", "retrieve": "processing.view", "access": "processing.view"} + + @decorators.action(detail=True, methods=["post"]) + def access(self, request, pk=None): + camera = self.get_object() + service = StreamAccessService() + if not service.can_access(request.user, camera): + return response.Response( + error_response("STREAM_FORBIDDEN", "You do not have access to this stream."), + status=status.HTTP_403_FORBIDDEN, + ) + token, expires_at = service.issue_token(camera) + access_url = request.build_absolute_uri(f"/api/v1/live-monitoring/streams/verify/?token={token}") + return response.Response({"token": token, "expires_at": expires_at, "access_url": access_url}) + + +class VerifyStreamView(views.APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + token = request.query_params.get("token") + payload = StreamAccessService().verify_token(token) + if not payload: + return response.Response( + error_response("INVALID_TOKEN", "Invalid or expired stream token."), + status=status.HTTP_403_FORBIDDEN, + ) + try: + camera = Camera.objects.get(pk=payload["camera"], active=True) + except Camera.DoesNotExist: + return response.Response( + error_response("CAMERA_NOT_FOUND", "Camera not found."), + status=status.HTTP_404_NOT_FOUND, + ) + return response.Response({"stream_url": camera.stream_url}) diff --git a/apps/notifications/__init__.py b/apps/notifications/__init__.py new file mode 100644 index 0000000..5ca09f6 --- /dev/null +++ b/apps/notifications/__init__.py @@ -0,0 +1 @@ +# notifications app diff --git a/apps/notifications/apps.py b/apps/notifications/apps.py new file mode 100644 index 0000000..e04ade9 --- /dev/null +++ b/apps/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.notifications" diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..7ee6d7b --- /dev/null +++ b/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='NotificationTemplate', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('event', models.CharField(db_index=True, max_length=120)), + ('channel', models.CharField(choices=[('sms', 'SMS'), ('email', 'Email'), ('push', 'Push'), ('inapp', 'In-app')], default='sms', max_length=12)), + ('subject', models.CharField(blank=True, max_length=255)), + ('body', models.TextField(help_text='Supports {{variable}} placeholders.')), + ('enabled', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='NotificationRule', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('event', models.CharField(db_index=True, max_length=120)), + ('recipients', models.JSONField(blank=True, default=list)), + ('enabled', models.BooleanField(default=True)), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='notifications.notificationtemplate')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='NotificationLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('recipient', models.CharField(blank=True, max_length=200)), + ('channel', models.CharField(max_length=12)), + ('status', models.CharField(default='pending', max_length=20)), + ('error', models.TextField(blank=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='notifications.notificationtemplate')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/notifications/migrations/__init__.py b/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/notifications/models.py b/apps/notifications/models.py new file mode 100644 index 0000000..ec06cec --- /dev/null +++ b/apps/notifications/models.py @@ -0,0 +1,36 @@ +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class NotificationTemplate(UUIDModel, TimeStampedModel): + class Channel(models.TextChoices): + SMS = "sms", "SMS" + EMAIL = "email", "Email" + PUSH = "push", "Push" + INAPP = "inapp", "In-app" + + event = models.CharField(max_length=120, db_index=True) + channel = models.CharField(max_length=12, choices=Channel.choices, default=Channel.SMS) + subject = models.CharField(max_length=255, blank=True) + body = models.TextField(help_text="Supports {{variable}} placeholders.") + enabled = models.BooleanField(default=True) + + def __str__(self) -> str: + return f"{self.event} ({self.channel})" + + +class NotificationRule(UUIDModel, TimeStampedModel): + event = models.CharField(max_length=120, db_index=True) + template = models.ForeignKey(NotificationTemplate, on_delete=models.CASCADE, related_name="rules") + recipients = models.JSONField(default=list, blank=True) + enabled = models.BooleanField(default=True) + + +class NotificationLog(UUIDModel, TimeStampedModel): + template = models.ForeignKey(NotificationTemplate, null=True, blank=True, on_delete=models.SET_NULL) + recipient = models.CharField(max_length=200, blank=True) + channel = models.CharField(max_length=12) + status = models.CharField(max_length=20, default="pending") + error = models.TextField(blank=True) + sent_at = models.DateTimeField(null=True, blank=True) diff --git a/apps/notifications/providers.py b/apps/notifications/providers.py new file mode 100644 index 0000000..ffd4e6d --- /dev/null +++ b/apps/notifications/providers.py @@ -0,0 +1,61 @@ +"""Pluggable SMS / notification providers. + +Providers are selected at runtime from the dynamic settings store, so an SMS +gateway can be configured from the admin panel without touching code. Business +logic only ever talks to the provider interface. +""" +import json +import re +import urllib.request +from typing import Protocol + +from apps.core.settings_service import SettingsService + + +class SmsProviderInterface(Protocol): + def send(self, recipient: str, message: str) -> str: + """Return the provider message id.""" + ... + + +class MockSmsProvider: + def send(self, recipient: str, message: str) -> str: + return f"MOCK-{recipient}-{abs(hash(message)) % 10_000_000}" + + +class HttpSmsProvider: + """Generic HTTP gateway: POSTs a JSON payload to a configurable endpoint.""" + + def __init__(self, endpoint: str, api_key: str = "", sender: str = ""): + self.endpoint = endpoint + self.api_key = api_key + self.sender = sender + + def send(self, recipient: str, message: str) -> str: + payload = {"to": recipient, "text": message, "from": self.sender, "api_key": self.api_key} + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + self.endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(request, timeout=15) as response: + return response.read().decode("utf-8", "replace")[:120] + + +def get_sms_provider() -> SmsProviderInterface: + service = SettingsService() + provider = service.get("sms.provider", "mock") + if provider == "http": + return HttpSmsProvider( + endpoint=service.get("sms.endpoint", ""), + api_key=service.get("sms.api_key", ""), + sender=service.get("sms.sender", ""), + ) + return MockSmsProvider() + + +def render_template(template_text: str, context: dict) -> str: + def _replace(match): + key = match.group(1) + return str(context.get(key, "")) + + return re.sub(r"\{\{\s*(\w+)\s*\}\}", _replace, template_text) diff --git a/apps/notifications/services.py b/apps/notifications/services.py new file mode 100644 index 0000000..267dd19 --- /dev/null +++ b/apps/notifications/services.py @@ -0,0 +1,39 @@ +"""Notification engine. + +Resolves templates for a domain event from the database, renders them and +dispatches through the configured provider. Business logic only emits events. +""" +from django.utils import timezone + +from apps.notifications.models import NotificationLog, NotificationTemplate +from apps.notifications.providers import get_sms_provider, render_template + + +class NotificationService: + def send_event(self, event: str, context: dict, recipient: str = "") -> list: + results = [] + templates = NotificationTemplate.objects.filter(event=event, enabled=True) + for template in templates: + body = render_template(template.body, context) + if template.channel == NotificationTemplate.Channel.SMS: + results.append(self._send_sms(template, recipient, body)) + return results + + def _send_sms(self, template, recipient: str, body: str) -> dict: + status = "sent" + error = "" + reference = "" + try: + reference = get_sms_provider().send(recipient, body) + except Exception as exc: # pragma: no cover - provider failures + status = "failed" + error = str(exc) + NotificationLog.objects.create( + template=template, + recipient=recipient, + channel="sms", + status=status, + error=error, + sent_at=timezone.now() if status == "sent" else None, + ) + return {"channel": "sms", "status": status, "reference": reference} diff --git a/apps/orders/__init__.py b/apps/orders/__init__.py new file mode 100644 index 0000000..63b1913 --- /dev/null +++ b/apps/orders/__init__.py @@ -0,0 +1 @@ +# orders app diff --git a/apps/orders/apps.py b/apps/orders/apps.py new file mode 100644 index 0000000..372dc93 --- /dev/null +++ b/apps/orders/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.orders" diff --git a/apps/orders/migrations/0001_initial.py b/apps/orders/migrations/0001_initial.py new file mode 100644 index 0000000..de0f606 --- /dev/null +++ b/apps/orders/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('reservations', '0001_initial'), + ('workflows', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ProcessingOrder', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('order_number', models.CharField(blank=True, max_length=32, unique=True)), + ('gross_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('tare_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('net_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('selected_step_keys', models.JSONField(blank=True, default=list)), + ('price_snapshot', models.JSONField(blank=True, default=dict)), + ('currency', models.CharField(default='IRR', max_length=8)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('awaiting_confirmation', 'Awaiting confirmation'), ('confirmed', 'Confirmed'), ('processing', 'Processing'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='draft', max_length=24)), + ('confirmed_at', models.DateTimeField(blank=True, null=True)), + ('terms_accepted', models.BooleanField(default=False)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='accounts.customer')), + ('reservation', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order', to='reservations.reservation')), + ('workflow_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='workflows.workflowversion')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/orders/migrations/__init__.py b/apps/orders/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/models.py b/apps/orders/models.py new file mode 100644 index 0000000..b3df481 --- /dev/null +++ b/apps/orders/models.py @@ -0,0 +1,62 @@ +from django.db import models +from django.utils import timezone + +from apps.core.models import TimeStampedModel, UUIDModel + + +def generate_order_number() -> str: + year = timezone.now().year + prefix = f"STN-{year}-" + last = ( + ProcessingOrder.objects.filter(order_number__startswith=prefix) + .order_by("-order_number") + .first() + ) + if last: + try: + seq = int(last.order_number.rsplit("-", 1)[1]) + 1 + except (IndexError, ValueError): + seq = 1 + else: + seq = 1 + return f"{prefix}{seq:06d}" + + +class ProcessingOrder(UUIDModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + AWAITING_CONFIRMATION = "awaiting_confirmation", "Awaiting confirmation" + CONFIRMED = "confirmed", "Confirmed" + PROCESSING = "processing", "Processing" + COMPLETED = "completed", "Completed" + CANCELLED = "cancelled", "Cancelled" + + order_number = models.CharField(max_length=32, unique=True, blank=True) + customer = models.ForeignKey("accounts.Customer", on_delete=models.PROTECT, related_name="orders") + reservation = models.OneToOneField( + "reservations.Reservation", null=True, blank=True, on_delete=models.SET_NULL, related_name="order" + ) + # Workflow version is immutable per order — historical orders never change. + workflow_version = models.ForeignKey( + "workflows.WorkflowVersion", on_delete=models.PROTECT, related_name="orders" + ) + gross_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + tare_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + net_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + selected_step_keys = models.JSONField(default=list, blank=True) + price_snapshot = models.JSONField(default=dict, blank=True) + currency = models.CharField(max_length=8, default="IRR") + status = models.CharField(max_length=24, choices=Status.choices, default=Status.DRAFT) + confirmed_at = models.DateTimeField(null=True, blank=True) + terms_accepted = models.BooleanField(default=False) + + class Meta: + ordering = ["-created_at"] + + def save(self, *args, **kwargs): + if not self.order_number: + self.order_number = generate_order_number() + super().save(*args, **kwargs) + + def __str__(self) -> str: + return self.order_number or str(self.id) diff --git a/apps/orders/serializers.py b/apps/orders/serializers.py new file mode 100644 index 0000000..6526ac5 --- /dev/null +++ b/apps/orders/serializers.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + +from apps.orders.models import ProcessingOrder + + +class ProcessingOrderSerializer(serializers.ModelSerializer): + class Meta: + model = ProcessingOrder + fields = "__all__" + read_only_fields = [ + "id", "order_number", "workflow_version", "price_snapshot", + "status", "confirmed_at", "net_weight_kg", "gross_weight_kg", "tare_weight_kg", + ] + + +class OrderCreateSerializer(serializers.Serializer): + reservation = serializers.UUIDField() + workflow = serializers.UUIDField(required=False, allow_null=True) + + +class WeighingSerializer(serializers.Serializer): + gross = serializers.DecimalField(max_digits=12, decimal_places=2) + tare = serializers.DecimalField(max_digits=12, decimal_places=2) + + +class StepSelectionSerializer(serializers.Serializer): + step_keys = serializers.ListField(child=serializers.CharField()) + + +class ConfirmSerializer(serializers.Serializer): + terms_accepted = serializers.BooleanField(default=True) diff --git a/apps/orders/services.py b/apps/orders/services.py new file mode 100644 index 0000000..4746ce1 --- /dev/null +++ b/apps/orders/services.py @@ -0,0 +1,81 @@ +"""Order lifecycle service — ties reservations, weighing, dynamic step +selection, pricing and confirmation together. All financial values are +computed by the backend and stored as immutable snapshots.""" +from decimal import Decimal + +from django.db import transaction +from django.utils import timezone + +from apps.core.errors import ApiError +from apps.orders.models import ProcessingOrder +from apps.pricing.engine import PricingEngine +from apps.workflows.models import Workflow + + +class OrderService: + def _active_workflow(self) -> Workflow: + workflow = Workflow.objects.filter(status="active", active_version__isnull=False).first() + if workflow is None: + raise ApiError("NO_ACTIVE_WORKFLOW", "No active workflow is configured.") + return workflow + + @transaction.atomic + def create_from_reservation(self, reservation, workflow=None) -> ProcessingOrder: + if ProcessingOrder.objects.filter(reservation=reservation).exists(): + raise ApiError("ORDER_EXISTS", "An order already exists for this reservation.") + if workflow is None: + workflow = self._active_workflow() + return ProcessingOrder.objects.create( + customer=reservation.customer, + reservation=reservation, + workflow_version=workflow.active_version, + ) + + @transaction.atomic + def record_weighing(self, order, gross, tare) -> ProcessingOrder: + order = ProcessingOrder.objects.select_for_update().get(pk=order.pk) + if order.net_weight_kg is not None: + raise ApiError("WEIGHING_IMMUTABLE", "Net weight is already recorded and immutable.") + gross = Decimal(str(gross)) + tare = Decimal(str(tare)) + if gross < tare: + raise ApiError("INVALID_WEIGHT", "Gross weight cannot be less than tare weight.") + order.gross_weight_kg = gross + order.tare_weight_kg = tare + order.net_weight_kg = gross - tare + order.save(update_fields=["gross_weight_kg", "tare_weight_kg", "net_weight_kg"]) + return order + + @transaction.atomic + def select_steps(self, order, step_keys) -> ProcessingOrder: + order = ProcessingOrder.objects.select_for_update().get(pk=order.pk) + if order.net_weight_kg is None: + raise ApiError("WEIGHING_REQUIRED", "Record weighing before selecting processing.") + version = order.workflow_version + steps = list(version.steps.filter(key__in=step_keys, enabled=True)) + keys = {s.key for s in steps} + for required in version.steps.filter(required=True): + if required.key not in keys: + raise ApiError( + "REQUIRED_STEP_MISSING", + f"Required step '{required.key}' must be selected.", + {"step": required.key}, + ) + order.selected_step_keys = sorted(keys) + order.price_snapshot = PricingEngine().calculate(order.net_weight_kg, steps, order.currency) + order.status = ProcessingOrder.Status.AWAITING_CONFIRMATION + order.save(update_fields=["selected_step_keys", "price_snapshot", "status"]) + return order + + @transaction.atomic + def confirm(self, order, terms_accepted=True) -> ProcessingOrder: + order = ProcessingOrder.objects.select_for_update().get(pk=order.pk) + if order.status != ProcessingOrder.Status.AWAITING_CONFIRMATION: + raise ApiError("ORDER_NOT_CONFIRMABLE", "Order is not awaiting confirmation.") + if not terms_accepted: + raise ApiError("TERMS_REQUIRED", "Terms must be accepted.") + order.status = ProcessingOrder.Status.CONFIRMED + order.confirmed_at = timezone.now() + order.terms_accepted = True + order.save(update_fields=["status", "confirmed_at", "terms_accepted"]) + return order diff --git a/apps/orders/tests.py b/apps/orders/tests.py new file mode 100644 index 0000000..1eaa9ae --- /dev/null +++ b/apps/orders/tests.py @@ -0,0 +1,72 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + +from apps.accounts.models import Customer +from apps.core.errors import ApiError +from apps.orders.services import OrderService +from apps.pricing.models import PricingRule +from apps.reservations.models import Facility, Reservation, Slot +from apps.workflows.engine import WorkflowEngine +from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion + +User = get_user_model() + + +def make_active_workflow(): + wf = Workflow.objects.create(name="W", slug="w-order") + v = WorkflowVersion.objects.create(workflow=wf, version=1) + s1 = WorkflowStep.objects.create(version=v, key="start", name="Start", order=1, is_start=True) + wash = WorkflowStep.objects.create(version=v, key="washing", name="Washing", order=2, required=True) + end = WorkflowStep.objects.create(version=v, key="end", name="End", order=3, is_terminal=True) + WorkflowTransition.objects.create(version=v, from_step=s1, to_step=wash) + WorkflowTransition.objects.create(version=v, from_step=wash, to_step=end) + WorkflowEngine().publish(wf, v) + PricingRule.objects.create(step=wash, name="Wash", rule_type="per_kg", amount=Decimal("5000")) + return wf, v + + +class OrderServiceTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="c", password="***") + self.customer = Customer.objects.create(user=self.user) + self.facility = Facility.objects.create(name="F") + self.slot = Slot.objects.create( + facility=self.facility, date=timezone.localdate(), start_time="08:00", end_time="08:30", capacity=5 + ) + self.reservation = Reservation.objects.create(customer=self.customer, slot=self.slot, status="confirmed") + make_active_workflow() + self.service = OrderService() + + def test_full_lifecycle(self): + order = self.service.create_from_reservation(self.reservation) + order = self.service.record_weighing(order, "1000", "80") + self.assertEqual(order.net_weight_kg, Decimal("920")) + order = self.service.select_steps(order, ["washing"]) + self.assertEqual(order.status, "awaiting_confirmation") + self.assertEqual(order.price_snapshot["total"], "4600000.00") + order = self.service.confirm(order) + self.assertEqual(order.status, "confirmed") + self.assertIsNotNone(order.confirmed_at) + + def test_required_step_missing(self): + order = self.service.create_from_reservation(self.reservation) + order = self.service.record_weighing(order, "1000", "0") + with self.assertRaises(ApiError) as ctx: + self.service.select_steps(order, ["end"]) + self.assertEqual(ctx.exception.code, "REQUIRED_STEP_MISSING") + + def test_weighing_immutable(self): + order = self.service.create_from_reservation(self.reservation) + order = self.service.record_weighing(order, "1000", "80") + with self.assertRaises(ApiError) as ctx: + self.service.record_weighing(order, "900", "0") + self.assertEqual(ctx.exception.code, "WEIGHING_IMMUTABLE") + + def test_duplicate_order_for_reservation(self): + self.service.create_from_reservation(self.reservation) + with self.assertRaises(ApiError) as ctx: + self.service.create_from_reservation(self.reservation) + self.assertEqual(ctx.exception.code, "ORDER_EXISTS") diff --git a/apps/orders/urls.py b/apps/orders/urls.py new file mode 100644 index 0000000..5afd4f7 --- /dev/null +++ b/apps/orders/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from apps.orders.views import ProcessingOrderViewSet + +router = DefaultRouter() +router.register("", ProcessingOrderViewSet, basename="order") + +urlpatterns = router.urls diff --git a/apps/orders/views.py b/apps/orders/views.py new file mode 100644 index 0000000..5c5e683 --- /dev/null +++ b/apps/orders/views.py @@ -0,0 +1,101 @@ +from rest_framework import decorators, mixins, response, status, viewsets + +from apps.core.errors import ApiError, error_response +from apps.orders.models import ProcessingOrder +from apps.orders.serializers import ( + ConfirmSerializer, + OrderCreateSerializer, + ProcessingOrderSerializer, + StepSelectionSerializer, + WeighingSerializer, +) +from apps.orders.services import OrderService +from apps.rbac.permissions import ActionPermission +from apps.reservations.models import Reservation +from apps.workflows.models import Workflow + + +class ProcessingOrderViewSet( + mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): + serializer_class = ProcessingOrderSerializer + permission_classes = [ActionPermission] + code_map = { + "list": "processing.view", + "retrieve": "processing.view", + "create": "reservation.create", + "weigh": "weighing.create", + "select_steps": "reservation.update", + "confirm": "reservation.update", + } + service = OrderService() + + def get_queryset(self): + user = self.request.user + qs = ProcessingOrder.objects.select_related("customer", "workflow_version").all() + if user.is_superuser: + return qs + return qs.filter(customer__user=user) + + def create(self, request, *args, **kwargs): + input_serializer = OrderCreateSerializer(data=request.data) + input_serializer.is_valid(raise_exception=True) + try: + reservation = Reservation.objects.get(pk=input_serializer.validated_data["reservation"]) + except Reservation.DoesNotExist: + return response.Response( + error_response("RESERVATION_NOT_FOUND", "Reservation not found."), + status=status.HTTP_404_NOT_FOUND, + ) + workflow_id = input_serializer.validated_data.get("workflow") + workflow = None + if workflow_id: + try: + workflow = Workflow.objects.get(pk=workflow_id) + except Workflow.DoesNotExist: + return response.Response( + error_response("WORKFLOW_NOT_FOUND", "Workflow not found."), + status=status.HTTP_404_NOT_FOUND, + ) + try: + order = self.service.create_from_reservation(reservation, workflow) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(ProcessingOrderSerializer(order).data, status=status.HTTP_201_CREATED) + + @decorators.action(detail=True, methods=["post"]) + def weigh(self, request, pk=None): + order = self.get_object() + input_serializer = WeighingSerializer(data=request.data) + input_serializer.is_valid(raise_exception=True) + try: + order = self.service.record_weighing( + order, + input_serializer.validated_data["gross"], + input_serializer.validated_data["tare"], + ) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(ProcessingOrderSerializer(order).data) + + @decorators.action(detail=True, methods=["post"]) + def select_steps(self, request, pk=None): + order = self.get_object() + input_serializer = StepSelectionSerializer(data=request.data) + input_serializer.is_valid(raise_exception=True) + try: + order = self.service.select_steps(order, input_serializer.validated_data["step_keys"]) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(ProcessingOrderSerializer(order).data) + + @decorators.action(detail=True, methods=["post"]) + def confirm(self, request, pk=None): + order = self.get_object() + input_serializer = ConfirmSerializer(data=request.data) + input_serializer.is_valid(raise_exception=True) + try: + order = self.service.confirm(order, input_serializer.validated_data["terms_accepted"]) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(ProcessingOrderSerializer(order).data) diff --git a/apps/payments/__init__.py b/apps/payments/__init__.py new file mode 100644 index 0000000..9c6e703 --- /dev/null +++ b/apps/payments/__init__.py @@ -0,0 +1 @@ +# payments app diff --git a/apps/payments/apps.py b/apps/payments/apps.py new file mode 100644 index 0000000..d94ae34 --- /dev/null +++ b/apps/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.payments" diff --git a/apps/payments/gateways.py b/apps/payments/gateways.py new file mode 100644 index 0000000..b8a3361 --- /dev/null +++ b/apps/payments/gateways.py @@ -0,0 +1,36 @@ +"""Payment gateway abstraction with idempotent callbacks. + +Payments never talk to a concrete gateway directly; the gateway is selected +from configuration and isolated behind an interface. +""" +from typing import Protocol + +from django.conf import settings + + +class PaymentGatewayInterface(Protocol): + def request_payment(self, amount, currency, order_number: str, callback_url: str) -> dict: + """Return {'authority': str, 'redirect_url': str}.""" + ... + + def verify(self, authority: str, amount) -> dict: + """Return {'transaction_id': str, 'status': 'success'|'failed'|'cancelled'}.""" + ... + + +class MockGateway: + def request_payment(self, amount, currency, order_number: str, callback_url: str) -> dict: + return { + "authority": f"MOCK-AUTH-{order_number}", + "redirect_url": f"{callback_url}?authority=MOCK-AUTH-{order_number}", + } + + def verify(self, authority: str, amount) -> dict: + return {"transaction_id": f"MOCK-TXN-{authority}", "status": "success"} + + +def get_gateway() -> PaymentGatewayInterface: + if settings.PAYMENT_GATEWAY_PROVIDER == "mock": + return MockGateway() + # Real gateways (GatewayA, GatewayB, ...) are selected here. + return MockGateway() diff --git a/apps/payments/migrations/0001_initial.py b/apps/payments/migrations/0001_initial.py new file mode 100644 index 0000000..0c6f250 --- /dev/null +++ b/apps/payments/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('orders', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('amount', models.DecimalField(decimal_places=2, max_digits=18)), + ('currency', models.CharField(default='IRR', max_length=8)), + ('gateway', models.CharField(max_length=40)), + ('transaction_id', models.CharField(max_length=120)), + ('authority', models.CharField(blank=True, max_length=120)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('success', 'Success'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=12)), + ('request_time', models.DateTimeField(blank=True, null=True)), + ('callback_time', models.DateTimeField(blank=True, null=True)), + ('verification_time', models.DateTimeField(blank=True, null=True)), + ('error', models.TextField(blank=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.processingorder')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('gateway', 'transaction_id'), name='uniq_gateway_transaction')], + }, + ), + ] diff --git a/apps/payments/migrations/0002_payment_reservation_alter_payment_order_and_more.py b/apps/payments/migrations/0002_payment_reservation_alter_payment_order_and_more.py new file mode 100644 index 0000000..08f8055 --- /dev/null +++ b/apps/payments/migrations/0002_payment_reservation_alter_payment_order_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.17 on 2026-09-15 11:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0001_initial'), + ('payments', '0001_initial'), + ('reservations', '0002_reservation_approved_at_reservation_approved_by_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='reservation', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='reservations.reservation'), + ), + migrations.AlterField( + model_name='payment', + name='order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.processingorder'), + ), + migrations.AddConstraint( + model_name='payment', + constraint=models.CheckConstraint(condition=models.Q(('order__isnull', False), ('reservation__isnull', False), _connector='OR'), name='payment_has_target'), + ), + ] diff --git a/apps/payments/migrations/__init__.py b/apps/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/models.py b/apps/payments/models.py new file mode 100644 index 0000000..3d03d50 --- /dev/null +++ b/apps/payments/models.py @@ -0,0 +1,41 @@ +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class Payment(UUIDModel, TimeStampedModel): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + SUCCESS = "success", "Success" + FAILED = "failed", "Failed" + CANCELLED = "cancelled", "Cancelled" + + # A payment targets either a reservation (upfront) or a processing order. + reservation = models.ForeignKey( + "reservations.Reservation", null=True, blank=True, on_delete=models.PROTECT, related_name="payments" + ) + order = models.ForeignKey( + "orders.ProcessingOrder", null=True, blank=True, on_delete=models.PROTECT, related_name="payments" + ) + amount = models.DecimalField(max_digits=18, decimal_places=2) + currency = models.CharField(max_length=8, default="IRR") + gateway = models.CharField(max_length=40) + transaction_id = models.CharField(max_length=120) + authority = models.CharField(max_length=120, blank=True) + status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING) + request_time = models.DateTimeField(null=True, blank=True) + callback_time = models.DateTimeField(null=True, blank=True) + verification_time = models.DateTimeField(null=True, blank=True) + error = models.TextField(blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["gateway", "transaction_id"], name="uniq_gateway_transaction"), + models.CheckConstraint( + condition=models.Q(order__isnull=False) | models.Q(reservation__isnull=False), + name="payment_has_target", + ), + ] + + def __str__(self) -> str: + return f"{self.gateway}:{self.transaction_id}" diff --git a/apps/payments/serializers.py b/apps/payments/serializers.py new file mode 100644 index 0000000..2708565 --- /dev/null +++ b/apps/payments/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from apps.payments.models import Payment + + +class PaymentSerializer(serializers.ModelSerializer): + class Meta: + model = Payment + fields = "__all__" + read_only_fields = [ + "id", "gateway", "transaction_id", "authority", "status", + "request_time", "callback_time", "verification_time", "error", + ] diff --git a/apps/payments/tests.py b/apps/payments/tests.py new file mode 100644 index 0000000..56e83c2 --- /dev/null +++ b/apps/payments/tests.py @@ -0,0 +1,39 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.test import TestCase + +from apps.accounts.models import Customer +from apps.orders.models import ProcessingOrder +from apps.payments.gateways import get_gateway +from apps.payments.models import Payment +from apps.workflows.models import Workflow, WorkflowVersion + +User = get_user_model() + + +class PaymentTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="c", password="***") + self.customer = Customer.objects.create(user=self.user) + wf = Workflow.objects.create(name="W", slug="w-pay") + version = WorkflowVersion.objects.create(workflow=wf, version=1) + self.order = ProcessingOrder.objects.create(customer=self.customer, workflow_version=version) + + def test_mock_gateway_verify_returns_success(self): + result = get_gateway().verify("AUTH-1", "1000") + self.assertEqual(result["status"], "success") + self.assertTrue(result["transaction_id"]) + + def test_duplicate_gateway_transaction_rejected(self): + Payment.objects.create( + order=self.order, amount=Decimal("1000"), currency="IRR", + gateway="mock", transaction_id="TXN-1", status="success", + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Payment.objects.create( + order=self.order, amount=Decimal("1000"), currency="IRR", + gateway="mock", transaction_id="TXN-1", status="success", + ) diff --git a/apps/payments/urls.py b/apps/payments/urls.py new file mode 100644 index 0000000..bdbeb9f --- /dev/null +++ b/apps/payments/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from apps.payments.views import PaymentViewSet + +router = DefaultRouter() +router.register("", PaymentViewSet, basename="payment") + +urlpatterns = router.urls diff --git a/apps/payments/views.py b/apps/payments/views.py new file mode 100644 index 0000000..9572fab --- /dev/null +++ b/apps/payments/views.py @@ -0,0 +1,82 @@ +from django.conf import settings +from django.db import transaction +from django.db.models import Q +from django.utils import timezone +from rest_framework import decorators, mixins, response, status, viewsets + +from apps.core.errors import error_response +from apps.payments.gateways import get_gateway +from apps.payments.models import Payment +from apps.payments.serializers import PaymentSerializer +from apps.rbac.permissions import ActionPermission + + +class PaymentViewSet( + mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet +): + serializer_class = PaymentSerializer + permission_classes = [ActionPermission] + code_map = { + "list": "invoice.view", + "retrieve": "invoice.view", + "create": "reservation.create", + "verify": "invoice.create", + } + + def get_queryset(self): + user = self.request.user + qs = Payment.objects.select_related("order", "reservation").all() + if user.is_superuser: + return qs + return qs.filter(Q(order__customer__user=user) | Q(reservation__customer__user=user)) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + order = serializer.validated_data.get("order") + reservation = serializer.validated_data.get("reservation") + if not order and not reservation: + return response.Response( + error_response("PAYMENT_TARGET_REQUIRED", "An order or reservation is required."), + status=status.HTTP_400_BAD_REQUEST, + ) + amount = serializer.validated_data["amount"] + currency = order.currency if order else getattr(settings, "BASE_CURRENCY", "IRR") + reference = order.order_number if order else f"RES-{str(reservation.id)[:8]}" + + gateway = get_gateway() + callback_url = request.build_absolute_uri("/api/v1/payments/callback/") + result = gateway.request_payment(str(amount), currency, reference, callback_url) + payment = Payment.objects.create( + order=order, + reservation=reservation, + amount=amount, + currency=currency, + gateway=settings.PAYMENT_GATEWAY_PROVIDER or "mock", + authority=result["authority"], + status=Payment.Status.PENDING, + request_time=timezone.now(), + ) + return response.Response( + {"id": str(payment.id), "authority": result["authority"], "redirect_url": result["redirect_url"]}, + status=status.HTTP_201_CREATED, + ) + + @decorators.action(detail=True, methods=["post"]) + @transaction.atomic + def verify(self, request, pk=None): + payment = Payment.objects.select_for_update().get(pk=pk) + # Idempotent: a duplicate callback must not create a duplicate success. + if payment.status == Payment.Status.SUCCESS: + return response.Response(PaymentSerializer(payment).data) + gateway = get_gateway() + result = gateway.verify(payment.authority, str(payment.amount)) + payment.verification_time = timezone.now() + if result.get("status") == "success": + payment.status = Payment.Status.SUCCESS + payment.transaction_id = result["transaction_id"] + else: + payment.status = Payment.Status.FAILED + payment.error = result.get("error", "") + payment.save() + return response.Response(PaymentSerializer(payment).data) diff --git a/apps/pricing/__init__.py b/apps/pricing/__init__.py new file mode 100644 index 0000000..674627a --- /dev/null +++ b/apps/pricing/__init__.py @@ -0,0 +1 @@ +# pricing app diff --git a/apps/pricing/apps.py b/apps/pricing/apps.py new file mode 100644 index 0000000..736e4c5 --- /dev/null +++ b/apps/pricing/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PricingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.pricing" diff --git a/apps/pricing/engine.py b/apps/pricing/engine.py new file mode 100644 index 0000000..13d5fbc --- /dev/null +++ b/apps/pricing/engine.py @@ -0,0 +1,117 @@ +"""Backend-authoritative pricing engine. + +Produces a full, explainable breakdown and is always the source of truth — +the frontend may display estimates but must never be trusted for financial +values. +""" +from decimal import Decimal, ROUND_HALF_UP + +CHARGE_TYPES = {"fixed", "per_kg", "per_unit", "percentage", "tiered", "fee"} + + +class PricingEngine: + def calculate(self, weight_kg, selected_steps, currency: str = "IRR") -> dict: + weight = Decimal(str(weight_kg)) + lines = [] + subtotal = Decimal("0") + + # Pass 1 — charge rules. + for step in selected_steps: + for rule in step.pricing_rules.filter(active=True, rule_type__in=CHARGE_TYPES).order_by("priority"): + line = self._apply_charge_rule(rule, weight) + if line is None: + continue + lines.append(line) + subtotal += Decimal(line["amount"]) + + # Pass 2 — discounts (fixed or percentage of subtotal). + discounts = Decimal("0") + for step in selected_steps: + for rule in step.pricing_rules.filter(active=True, rule_type="discount"): + amt = Decimal(str(rule.amount)) + if (rule.config or {}).get("percentage"): + amt = subtotal * amt / Decimal("100") + discounts += amt + lines.append( + self._line(rule, weight, "discount", Decimal("0"), amt) + ) + + # Pass 3 — taxes on (subtotal - discounts). + taxes = Decimal("0") + taxable = subtotal - discounts + for step in selected_steps: + for rule in step.pricing_rules.filter(active=True, rule_type="tax"): + amt = taxable * Decimal(str(rule.amount)) / Decimal("100") + taxes += amt + lines.append(self._line(rule, weight, "tax", Decimal("0"), amt)) + + total = subtotal - discounts + taxes + if total < 0: + total = Decimal("0") + + return { + "currency": currency, + "lines": lines, + "subtotal": str(self._q(subtotal)), + "discounts": str(self._q(discounts)), + "taxes": str(self._q(taxes)), + "total": str(self._q(total)), + } + + def _q(self, v: Decimal) -> Decimal: + return v.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + def _line(self, rule, weight, kind, unit_price, amount, quantity=None) -> dict: + return { + "step_key": rule.step.key, + "rule": rule.name, + "kind": kind, + "quantity": str(quantity if quantity is not None else weight), + "unit_price": str(self._q(unit_price)), + "amount": str(self._q(amount)), + } + + def _apply_charge_rule(self, rule, weight): + kind = rule.rule_type + cfg = rule.config or {} + amount = Decimal(str(rule.amount)) + unit_price = Decimal("0") + quantity = weight + if kind == "fixed": + unit_price = amount + elif kind == "per_kg": + unit_price = amount + amount = amount * weight + elif kind == "per_unit": + units = Decimal(str(cfg.get("units", 1))) + unit_price = amount + amount = amount * units + quantity = units + elif kind == "percentage": + base = Decimal(str(cfg.get("base", 0))) + unit_price = amount + amount = base * amount / Decimal("100") + elif kind == "tiered": + unit_price = self._tier_unit_price(cfg.get("tiers", []), weight) + amount = unit_price * weight + elif kind == "fee": + unit_price = Decimal("0") + else: + return None + + min_c = cfg.get("min_charge") + max_c = cfg.get("max_charge") + if min_c is not None and amount < Decimal(str(min_c)): + amount = Decimal(str(min_c)) + if max_c is not None and amount > Decimal(str(max_c)): + amount = Decimal(str(max_c)) + + return self._line(rule, weight, kind, unit_price, amount, quantity) + + def _tier_unit_price(self, tiers, weight) -> Decimal: + best = Decimal("0") + for tier in tiers or []: + low = Decimal(str(tier.get("from", 0))) + if weight >= low: + best = Decimal(str(tier.get("price", 0))) + return best diff --git a/apps/pricing/migrations/0001_initial.py b/apps/pricing/migrations/0001_initial.py new file mode 100644 index 0000000..2297d4c --- /dev/null +++ b/apps/pricing/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('workflows', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PricingRule', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('rule_type', models.CharField(choices=[('fixed', 'Fixed'), ('per_kg', 'Per kilogram'), ('per_unit', 'Per unit'), ('percentage', 'Percentage'), ('tiered', 'Tiered'), ('discount', 'Discount'), ('tax', 'Tax'), ('fee', 'Additional fee')], max_length=16)), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=18)), + ('config', models.JSONField(blank=True, default=dict)), + ('active', models.BooleanField(default=True)), + ('priority', models.IntegerField(default=0)), + ('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricing_rules', to='workflows.workflowstep')), + ], + options={ + 'ordering': ['priority'], + }, + ), + ] diff --git a/apps/pricing/migrations/__init__.py b/apps/pricing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pricing/models.py b/apps/pricing/models.py new file mode 100644 index 0000000..cbc8bc0 --- /dev/null +++ b/apps/pricing/models.py @@ -0,0 +1,33 @@ +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class PricingRule(UUIDModel, TimeStampedModel): + class RuleType(models.TextChoices): + FIXED = "fixed", "Fixed" + PER_KG = "per_kg", "Per kilogram" + PER_UNIT = "per_unit", "Per unit" + PERCENTAGE = "percentage", "Percentage" + TIERED = "tiered", "Tiered" + DISCOUNT = "discount", "Discount" + TAX = "tax", "Tax" + FEE = "fee", "Additional fee" + + step = models.ForeignKey( + "workflows.WorkflowStep", on_delete=models.CASCADE, related_name="pricing_rules" + ) + name = models.CharField(max_length=200) + rule_type = models.CharField(max_length=16, choices=RuleType.choices) + # Base amount. Meaning depends on rule_type: + # fixed/per_kg/per_unit -> money; percentage/discount/tax -> percent. + amount = models.DecimalField(max_digits=18, decimal_places=2, default=0) + config = models.JSONField(default=dict, blank=True) + active = models.BooleanField(default=True) + priority = models.IntegerField(default=0) + + class Meta: + ordering = ["priority"] + + def __str__(self) -> str: + return f"{self.name} ({self.rule_type})" diff --git a/apps/pricing/tests.py b/apps/pricing/tests.py new file mode 100644 index 0000000..890f52e --- /dev/null +++ b/apps/pricing/tests.py @@ -0,0 +1,41 @@ +from decimal import Decimal + +from django.test import TestCase + +from apps.pricing.engine import PricingEngine +from apps.pricing.models import PricingRule +from apps.workflows.models import Workflow, WorkflowStep, WorkflowVersion + + +class PricingEngineTests(TestCase): + def setUp(self): + wf = Workflow.objects.create(name="P", slug="p") + self.version = WorkflowVersion.objects.create(workflow=wf, version=1) + self.washing = WorkflowStep.objects.create(version=self.version, key="washing", name="Washing") + self.drying = WorkflowStep.objects.create(version=self.version, key="drying", name="Drying") + + def test_per_kg(self): + PricingRule.objects.create(step=self.washing, name="Wash", rule_type="per_kg", amount=Decimal("5000")) + result = PricingEngine().calculate(850, [self.washing]) + self.assertEqual(result["total"], "4250000.00") + + def test_tiered(self): + PricingRule.objects.create(step=self.washing, name="Tiered", rule_type="tiered", config={ + "tiers": [{"from": 0, "price": "5000"}, {"from": 100, "price": "4000"}, {"from": 500, "price": "3000"}], + }) + result = PricingEngine().calculate(600, [self.washing]) + self.assertEqual(result["total"], "1800000.00") + + def test_discount_and_tax(self): + PricingRule.objects.create(step=self.washing, name="Wash", rule_type="per_kg", amount=Decimal("1000")) + PricingRule.objects.create(step=self.washing, name="Disc", rule_type="discount", amount=Decimal("10"), config={"percentage": True}) + PricingRule.objects.create(step=self.washing, name="Tax", rule_type="tax", amount=Decimal("9")) + result = PricingEngine().calculate(100, [self.washing]) + # subtotal 100000, discount 10% => 10000, taxable 90000, tax 9% => 8100, total 98100 + self.assertEqual(result["total"], "98100.00") + + def test_multiple_steps(self): + PricingRule.objects.create(step=self.washing, name="Wash", rule_type="per_kg", amount=Decimal("5000")) + PricingRule.objects.create(step=self.drying, name="Dry", rule_type="per_kg", amount=Decimal("8000")) + result = PricingEngine().calculate(100, [self.washing, self.drying]) + self.assertEqual(result["total"], "1300000.00") diff --git a/apps/processing/__init__.py b/apps/processing/__init__.py new file mode 100644 index 0000000..da9dceb --- /dev/null +++ b/apps/processing/__init__.py @@ -0,0 +1 @@ +# processing app diff --git a/apps/processing/apps.py b/apps/processing/apps.py new file mode 100644 index 0000000..ddc9c4e --- /dev/null +++ b/apps/processing/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProcessingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.processing" diff --git a/apps/processing/migrations/0001_initial.py b/apps/processing/migrations/0001_initial.py new file mode 100644 index 0000000..3939f1e --- /dev/null +++ b/apps/processing/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('orders', '0001_initial'), + ('workflows', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='StepExecution', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('state', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready'), ('in_progress', 'In progress'), ('paused', 'Paused'), ('waiting', 'Waiting'), ('completed', 'Completed'), ('failed', 'Failed'), ('rejected', 'Rejected'), ('skipped', 'Skipped'), ('cancelled', 'Cancelled'), ('rework_required', 'Rework required')], default='pending', max_length=20)), + ('attempt', models.PositiveIntegerField(default=1)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('measurements', models.JSONField(blank=True, default=dict)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_steps', to=settings.AUTH_USER_MODEL)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='step_executions', to='orders.processingorder')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rework_children', to='processing.stepexecution')), + ('step', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='workflows.workflowstep')), + ], + options={ + 'ordering': ['created_at'], + 'constraints': [models.UniqueConstraint(fields=('order', 'step', 'attempt'), name='uniq_step_execution_attempt')], + }, + ), + ] diff --git a/apps/processing/migrations/__init__.py b/apps/processing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/processing/models.py b/apps/processing/models.py new file mode 100644 index 0000000..0bb0abf --- /dev/null +++ b/apps/processing/models.py @@ -0,0 +1,43 @@ +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class StepExecution(UUIDModel, TimeStampedModel): + class State(models.TextChoices): + PENDING = "pending", "Pending" + READY = "ready", "Ready" + IN_PROGRESS = "in_progress", "In progress" + PAUSED = "paused", "Paused" + WAITING = "waiting", "Waiting" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + REJECTED = "rejected", "Rejected" + SKIPPED = "skipped", "Skipped" + CANCELLED = "cancelled", "Cancelled" + REWORK_REQUIRED = "rework_required", "Rework required" + + order = models.ForeignKey("orders.ProcessingOrder", on_delete=models.CASCADE, related_name="step_executions") + step = models.ForeignKey("workflows.WorkflowStep", on_delete=models.PROTECT) + state = models.CharField(max_length=20, choices=State.choices, default=State.PENDING) + attempt = models.PositiveIntegerField(default=1) + assigned_to = models.ForeignKey( + "accounts.User", null=True, blank=True, on_delete=models.SET_NULL, related_name="assigned_steps" + ) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + notes = models.TextField(blank=True) + measurements = models.JSONField(default=dict, blank=True) + # For rework history: a rework execution points back to the failed one. + parent = models.ForeignKey( + "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="rework_children" + ) + + class Meta: + ordering = ["created_at"] + constraints = [ + models.UniqueConstraint(fields=["order", "step", "attempt"], name="uniq_step_execution_attempt") + ] + + def __str__(self) -> str: + return f"{self.order.order_number} / {self.step.key} (attempt {self.attempt})" diff --git a/apps/processing/serializers.py b/apps/processing/serializers.py new file mode 100644 index 0000000..f1dbdc6 --- /dev/null +++ b/apps/processing/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from apps.processing.models import StepExecution + + +class StepExecutionSerializer(serializers.ModelSerializer): + order_number = serializers.CharField(source="order.order_number", read_only=True) + step_key = serializers.CharField(source="step.key", read_only=True) + assigned_to_name = serializers.CharField(source="assigned_to.username", read_only=True) + + class Meta: + model = StepExecution + fields = [ + "id", "order", "order_number", "step", "step_key", "state", "attempt", + "assigned_to", "assigned_to_name", "started_at", "completed_at", + ] diff --git a/apps/processing/services.py b/apps/processing/services.py new file mode 100644 index 0000000..5f2964f --- /dev/null +++ b/apps/processing/services.py @@ -0,0 +1,94 @@ +from django.db import transaction +from django.utils import timezone + +from apps.core.errors import ApiError +from apps.processing.models import StepExecution + + +class ProcessingService: + """Step execution state machine. Transitions are validated server-side.""" + + _STARTABLE = {"pending", "ready", "rework_required"} + _PAUSABLE = {"in_progress"} + _RESUMABLE = {"paused"} + _COMPLETABLE = {"in_progress"} + _REJECTABLE = {"in_progress", "waiting"} + + @transaction.atomic + def start(self, execution, user) -> StepExecution: + execution = self._locked(execution) + if execution.state not in self._STARTABLE: + raise ApiError("INVALID_STEP_TRANSITION", f"Cannot start from state '{execution.state}'.") + execution.state = StepExecution.State.IN_PROGRESS + execution.started_at = timezone.now() + execution.assigned_to = user + execution.save() + return execution + + @transaction.atomic + def pause(self, execution) -> StepExecution: + execution = self._locked(execution) + if execution.state not in self._PAUSABLE: + raise ApiError("INVALID_STEP_TRANSITION", f"Cannot pause from state '{execution.state}'.") + execution.state = StepExecution.State.PAUSED + execution.save() + return execution + + @transaction.atomic + def resume(self, execution) -> StepExecution: + execution = self._locked(execution) + if execution.state not in self._RESUMABLE: + raise ApiError("INVALID_STEP_TRANSITION", f"Cannot resume from state '{execution.state}'.") + execution.state = StepExecution.State.IN_PROGRESS + execution.save() + return execution + + @transaction.atomic + def complete(self, execution, measurements=None, notes="") -> StepExecution: + execution = self._locked(execution) + if execution.state not in self._COMPLETABLE: + raise ApiError("INVALID_STEP_TRANSITION", f"Cannot complete from state '{execution.state}'.") + execution.state = StepExecution.State.COMPLETED + execution.completed_at = timezone.now() + if measurements is not None: + execution.measurements = measurements + if notes: + execution.notes = notes + execution.save() + return execution + + @transaction.atomic + def reject(self, execution, notes="") -> StepExecution: + execution = self._locked(execution) + if execution.state not in self._REJECTABLE: + raise ApiError("INVALID_STEP_TRANSITION", f"Cannot reject from state '{execution.state}'.") + execution.state = StepExecution.State.REJECTED + execution.notes = notes or execution.notes + execution.save() + return execution + + @transaction.atomic + def rework(self, execution, target_step) -> StepExecution: + """Create a new execution on `target_step`; preserve history via parent link.""" + execution = self._locked(execution) + if execution.state != StepExecution.State.COMPLETED: + raise ApiError("INVALID_STEP_TRANSITION", "Only a completed step can trigger rework.") + execution.state = StepExecution.State.REWORK_REQUIRED + execution.save() + latest = ( + StepExecution.objects.filter(order=execution.order, step=target_step) + .order_by("-attempt") + .first() + ) + attempt = (latest.attempt if latest else 0) + 1 + return StepExecution.objects.create( + order=execution.order, + step=target_step, + state=StepExecution.State.READY, + attempt=attempt, + parent=execution, + ) + + @staticmethod + def _locked(execution) -> StepExecution: + return StepExecution.objects.select_for_update().get(pk=execution.pk) diff --git a/apps/processing/urls.py b/apps/processing/urls.py new file mode 100644 index 0000000..2949bca --- /dev/null +++ b/apps/processing/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from apps.processing.views import StepExecutionViewSet + +router = DefaultRouter() +router.register("", StepExecutionViewSet, basename="step-execution") + +urlpatterns = router.urls diff --git a/apps/processing/views.py b/apps/processing/views.py new file mode 100644 index 0000000..d01bca3 --- /dev/null +++ b/apps/processing/views.py @@ -0,0 +1,78 @@ +from rest_framework import decorators, mixins, response, status, viewsets + +from apps.core.errors import ApiError, error_response +from apps.processing.models import StepExecution +from apps.processing.serializers import StepExecutionSerializer +from apps.processing.services import ProcessingService +from apps.rbac.permissions import ActionPermission + + +class StepExecutionViewSet( + mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): + serializer_class = StepExecutionSerializer + permission_classes = [ActionPermission] + code_map = { + "list": "processing.view", + "retrieve": "processing.view", + "start": "processing.start", + "pause": "processing.pause", + "resume": "processing.start", + "complete": "processing.complete", + "reject": "processing.complete", + "rework": "processing.complete", + } + service = ProcessingService() + + def get_queryset(self): + user = self.request.user + qs = StepExecution.objects.select_related("order", "step", "assigned_to").all() + if user.is_superuser: + return qs + return qs.filter(order__customer__user=user) + + def _run(self, request, pk, method, **kwargs): + execution = self.get_object() + try: + if method == "start": + execution = self.service.start(execution, request.user) + elif method == "rework": + from apps.workflows.models import WorkflowStep + + target = WorkflowStep.objects.get(pk=kwargs["target_step"]) + execution = self.service.rework(execution, target) + else: + execution = getattr(self.service, method)(execution) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(StepExecutionSerializer(execution).data) + + @decorators.action(detail=True, methods=["post"]) + def start(self, request, pk=None): + return self._run(request, pk, "start") + + @decorators.action(detail=True, methods=["post"]) + def pause(self, request, pk=None): + return self._run(request, pk, "pause") + + @decorators.action(detail=True, methods=["post"]) + def resume(self, request, pk=None): + return self._run(request, pk, "resume") + + @decorators.action(detail=True, methods=["post"]) + def complete(self, request, pk=None): + return self._run(request, pk, "complete") + + @decorators.action(detail=True, methods=["post"]) + def reject(self, request, pk=None): + return self._run(request, pk, "reject") + + @decorators.action(detail=True, methods=["post"]) + def rework(self, request, pk=None): + target_step = request.data.get("target_step") + if not target_step: + return response.Response( + error_response("TARGET_STEP_REQUIRED", "A target_step id is required."), + status=status.HTTP_400_BAD_REQUEST, + ) + return self._run(request, pk, "rework", target_step=target_step) diff --git a/apps/rbac/__init__.py b/apps/rbac/__init__.py new file mode 100644 index 0000000..c220857 --- /dev/null +++ b/apps/rbac/__init__.py @@ -0,0 +1 @@ +# rbac app diff --git a/apps/rbac/apps.py b/apps/rbac/apps.py new file mode 100644 index 0000000..cdcde88 --- /dev/null +++ b/apps/rbac/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RbacConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.rbac" diff --git a/apps/rbac/migrations/0001_initial.py b/apps/rbac/migrations/0001_initial.py new file mode 100644 index 0000000..c23e77c --- /dev/null +++ b/apps/rbac/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Permission', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(max_length=120, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=120)), + ('slug', models.SlugField(max_length=120, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RolePermission', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rbac.permission')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rbac.role')), + ], + ), + migrations.AddField( + model_name='role', + name='permissions', + field=models.ManyToManyField(blank=True, related_name='roles', through='rbac.RolePermission', to='rbac.permission'), + ), + migrations.CreateModel( + name='UserRole', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('scope', models.JSONField(blank=True, default=dict)), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to='rbac.role')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='rolepermission', + constraint=models.UniqueConstraint(fields=('role', 'permission'), name='uniq_role_permission'), + ), + migrations.AddConstraint( + model_name='userrole', + constraint=models.UniqueConstraint(fields=('user', 'role'), name='uniq_user_role'), + ), + ] diff --git a/apps/rbac/migrations/__init__.py b/apps/rbac/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/rbac/models.py b/apps/rbac/models.py new file mode 100644 index 0000000..88c2b07 --- /dev/null +++ b/apps/rbac/models.py @@ -0,0 +1,42 @@ +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class Permission(UUIDModel): + code = models.CharField(max_length=120, unique=True) + description = models.CharField(max_length=255, blank=True) + + def __str__(self) -> str: + return self.code + + +class Role(UUIDModel, TimeStampedModel): + name = models.CharField(max_length=120) + slug = models.SlugField(max_length=120, unique=True) + description = models.CharField(max_length=255, blank=True) + permissions = models.ManyToManyField(Permission, through="RolePermission", related_name="roles", blank=True) + + def __str__(self) -> str: + return self.name + + +class RolePermission(UUIDModel): + role = models.ForeignKey(Role, on_delete=models.CASCADE) + permission = models.ForeignKey(Permission, on_delete=models.CASCADE) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["role", "permission"], name="uniq_role_permission") + ] + + +class UserRole(UUIDModel, TimeStampedModel): + user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="user_roles") + role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="user_roles") + scope = models.JSONField(default=dict, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["user", "role"], name="uniq_user_role") + ] diff --git a/apps/rbac/permissions.py b/apps/rbac/permissions.py new file mode 100644 index 0000000..2bbd96b --- /dev/null +++ b/apps/rbac/permissions.py @@ -0,0 +1,60 @@ +"""RBAC helpers and DRF permission classes. + +Authorization is always enforced on the backend. The frontend may hide UI +elements, but that is never treated as a security boundary. +""" +from rest_framework.permissions import BasePermission + +from apps.rbac.models import Permission + + +def has_perm(user, code: str) -> bool: + if not user or not user.is_authenticated: + return False + if user.is_superuser: + return True + return Permission.objects.filter(roles__user_roles__user=user, code=code).exists() + + +def has_any_perm(user, codes) -> bool: + return any(has_perm(user, c) for c in codes) + + +def user_role_slugs(user): + if not user or not user.is_authenticated: + return [] + return list(user.user_roles.values_list("role__slug", flat=True)) + + +class HasPermission(BasePermission): + """Global permission check. Set `perm_code` (or `perm_codes`) on the view.""" + + perm_code = None + perm_codes = None + + def has_permission(self, request, view): + codes = self.perm_codes or ([self.perm_code] if self.perm_code else None) + if not codes: + return True + return has_any_perm(request.user, codes) + + +class ActionPermission(BasePermission): + """Maps a DRF viewset action name to a permission code.""" + + code_map = {} + + def has_permission(self, request, view): + code = self.code_map.get(getattr(view, "action", None)) + if code is None: + return True + return has_perm(request.user, code) + + +class IsOwner(BasePermission): + """Object-level ownership for customers accessing their own resources.""" + + owner_field = "user" + + def has_object_permission(self, request, view, obj): + return getattr(obj, self.owner_field, None) == request.user diff --git a/apps/rbac/serializers.py b/apps/rbac/serializers.py new file mode 100644 index 0000000..8077cb8 --- /dev/null +++ b/apps/rbac/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers + +from apps.rbac.models import Permission, Role + + +class PermissionSerializer(serializers.ModelSerializer): + class Meta: + model = Permission + fields = ["id", "code", "description"] + + +class RoleSerializer(serializers.ModelSerializer): + permissions = PermissionSerializer(many=True, read_only=True) + + class Meta: + model = Role + fields = ["id", "name", "slug", "description", "permissions"] diff --git a/apps/rbac/tests.py b/apps/rbac/tests.py new file mode 100644 index 0000000..f878f03 --- /dev/null +++ b/apps/rbac/tests.py @@ -0,0 +1,23 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from apps.rbac.models import Permission, Role +from apps.rbac.permissions import has_perm + +User = get_user_model() + + +class RbacTests(TestCase): + def test_has_perm_grants_and_denies(self): + user = User.objects.create_user(username="op", password="x") + perm = Permission.objects.create(code="processing.start") + role = Role.objects.create(name="Operator", slug="operator") + role.permissions.add(perm) + user.user_roles.create(role=role) + + self.assertTrue(has_perm(user, "processing.start")) + self.assertFalse(has_perm(user, "workflow.publish")) + + def test_superuser_has_all(self): + user = User.objects.create_superuser(username="admin", password="x") + self.assertTrue(has_perm(user, "anything.at.all")) diff --git a/apps/rbac/views.py b/apps/rbac/views.py new file mode 100644 index 0000000..1348d48 --- /dev/null +++ b/apps/rbac/views.py @@ -0,0 +1,19 @@ +from rest_framework import viewsets + +from apps.rbac.models import Role +from apps.rbac.permissions import ActionPermission +from apps.rbac.serializers import RoleSerializer + + +class RoleViewSet(viewsets.ModelViewSet): + queryset = Role.objects.prefetch_related("permissions").all() + serializer_class = RoleSerializer + permission_classes = [ActionPermission] + code_map = { + "list": "role.view", + "retrieve": "role.view", + "create": "role.manage", + "update": "role.manage", + "partial_update": "role.manage", + "destroy": "role.manage", + } diff --git a/apps/reports/__init__.py b/apps/reports/__init__.py new file mode 100644 index 0000000..24e0765 --- /dev/null +++ b/apps/reports/__init__.py @@ -0,0 +1 @@ +# reports app diff --git a/apps/reports/apps.py b/apps/reports/apps.py new file mode 100644 index 0000000..64e72e6 --- /dev/null +++ b/apps/reports/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.reports" diff --git a/apps/reports/migrations/0001_initial.py b/apps/reports/migrations/0001_initial.py new file mode 100644 index 0000000..827ae5f --- /dev/null +++ b/apps/reports/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('orders', '0001_initial'), + ('workflows', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='FinalReport', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('data', models.JSONField(blank=True, default=dict)), + ('generated_at', models.DateTimeField(auto_now_add=True)), + ('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='final_report', to='orders.processingorder')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ReportDefinition', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('active', models.BooleanField(default=True)), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_definitions', to='workflows.workflow')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ReportField', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('key', models.SlugField(max_length=100)), + ('label', models.CharField(max_length=200)), + ('field_type', models.CharField(choices=[('text', 'Text'), ('integer', 'Integer'), ('decimal', 'Decimal'), ('boolean', 'Boolean'), ('date', 'Date'), ('datetime', 'DateTime'), ('choice', 'Choice'), ('multichoice', 'Multi-choice'), ('measurement', 'Measurement'), ('percentage', 'Percentage'), ('weight', 'Weight'), ('file', 'File / image')], default='text', max_length=16)), + ('required', models.BooleanField(default=False)), + ('choices', models.JSONField(blank=True, default=list)), + ('order', models.PositiveIntegerField(default=0)), + ('definition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='reports.reportdefinition')), + ], + options={ + 'ordering': ['order'], + 'constraints': [models.UniqueConstraint(fields=('definition', 'key'), name='uniq_report_field_key')], + }, + ), + ] diff --git a/apps/reports/migrations/__init__.py b/apps/reports/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reports/models.py b/apps/reports/models.py new file mode 100644 index 0000000..66e70e2 --- /dev/null +++ b/apps/reports/models.py @@ -0,0 +1,48 @@ +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class ReportDefinition(UUIDModel, TimeStampedModel): + workflow = models.ForeignKey("workflows.Workflow", on_delete=models.CASCADE, related_name="report_definitions") + name = models.CharField(max_length=200) + active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class ReportField(UUIDModel): + class FieldType(models.TextChoices): + TEXT = "text", "Text" + INTEGER = "integer", "Integer" + DECIMAL = "decimal", "Decimal" + BOOLEAN = "boolean", "Boolean" + DATE = "date", "Date" + DATETIME = "datetime", "DateTime" + CHOICE = "choice", "Choice" + MULTICHOICE = "multichoice", "Multi-choice" + MEASUREMENT = "measurement", "Measurement" + PERCENTAGE = "percentage", "Percentage" + WEIGHT = "weight", "Weight" + FILE = "file", "File / image" + + definition = models.ForeignKey(ReportDefinition, on_delete=models.CASCADE, related_name="fields") + key = models.SlugField(max_length=100) + label = models.CharField(max_length=200) + field_type = models.CharField(max_length=16, choices=FieldType.choices, default=FieldType.TEXT) + required = models.BooleanField(default=False) + choices = models.JSONField(default=list, blank=True) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + constraints = [ + models.UniqueConstraint(fields=["definition", "key"], name="uniq_report_field_key") + ] + + +class FinalReport(UUIDModel, TimeStampedModel): + order = models.OneToOneField("orders.ProcessingOrder", on_delete=models.CASCADE, related_name="final_report") + data = models.JSONField(default=dict, blank=True) + generated_at = models.DateTimeField(auto_now_add=True) diff --git a/apps/reports/serializers.py b/apps/reports/serializers.py new file mode 100644 index 0000000..09f4069 --- /dev/null +++ b/apps/reports/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + +from apps.reports.models import FinalReport, ReportDefinition, ReportField + + +class ReportFieldSerializer(serializers.ModelSerializer): + class Meta: + model = ReportField + fields = "__all__" + read_only_fields = ["id"] + + +class ReportDefinitionSerializer(serializers.ModelSerializer): + fields = ReportFieldSerializer(many=True, read_only=True) + + class Meta: + model = ReportDefinition + fields = ["id", "workflow", "name", "active", "fields"] + + +class FinalReportSerializer(serializers.ModelSerializer): + class Meta: + model = FinalReport + fields = "__all__" diff --git a/apps/reports/services.py b/apps/reports/services.py new file mode 100644 index 0000000..de0a6fc --- /dev/null +++ b/apps/reports/services.py @@ -0,0 +1,194 @@ +"""Final report generation. + +Reports are built from the workflow's active report definition and the order's +data (customer, weights, selected steps, price breakdown, execution timeline, +measurements, payments and invoices). The schema is configurable — there is no +hard-coded pistachio quality schema. +""" +from io import BytesIO + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from apps.reports.models import FinalReport, ReportDefinition + +GREEN = colors.HexColor("#166534") +DARK = colors.HexColor("#123B24") +LINE = colors.HexColor("#E7E1D4") +CREAM = colors.HexColor("#FAF6EE") + + +class ReportService: + def generate(self, order) -> FinalReport: + definition = ReportDefinition.objects.filter( + workflow=order.workflow_version.workflow, active=True + ).first() + data = self._build_data(order, definition) + report, _ = FinalReport.objects.update_or_create(order=order, defaults={"data": data}) + return report + + def _build_data(self, order, definition) -> dict: + measurements = {} + timeline = [] + for execution in order.step_executions.select_related("step", "assigned_to").order_by("created_at"): + for key, value in (execution.measurements or {}).items(): + measurements.setdefault(key, value) + timeline.append({ + "step": execution.step.name, + "state": execution.state, + "attempt": execution.attempt, + "operator": execution.assigned_to.username if execution.assigned_to else "", + "started_at": execution.started_at.isoformat() if execution.started_at else "", + "completed_at": execution.completed_at.isoformat() if execution.completed_at else "", + }) + + fields = [] + if definition: + for field in definition.fields.all(): + fields.append({ + "key": field.key, + "label": field.label, + "type": field.field_type, + "value": measurements.get(field.key, ""), + }) + + customer = order.customer + customer_name = customer.company_name or str(customer.user) + return { + "order_number": order.order_number, + "customer": { + "name": customer_name, + "code": customer.customer_code or "", + "phone": customer.user.phone, + "email": customer.user.email, + }, + "customer_name": customer_name, + "gross_weight_kg": str(order.gross_weight_kg or ""), + "tare_weight_kg": str(order.tare_weight_kg or ""), + "net_weight_kg": str(order.net_weight_kg or ""), + "selected_steps": order.selected_step_keys or [], + "price": order.price_snapshot or {}, + "status": order.status, + "created_at": order.created_at.isoformat(), + "confirmed_at": order.confirmed_at.isoformat() if order.confirmed_at else "", + "timeline": timeline, + "fields": fields, + "measurements": measurements, + "payments": [ + {"status": p.status, "amount": str(p.amount), "currency": p.currency} + for p in order.payments.all() + ], + "invoices": [ + {"number": i.number, "sync_state": i.sync_state, "amount": str(i.amount)} + for i in order.invoices.all() + ], + } + + # ------------------------------------------------------------------ PDF + def render_pdf(self, report) -> bytes: + buf = BytesIO() + doc = SimpleDocTemplate( + buf, pagesize=A4, + rightMargin=18 * mm, leftMargin=18 * mm, topMargin=18 * mm, bottomMargin=18 * mm, + title=f"Cetana report {report.order.order_number}", + ) + styles = getSampleStyleSheet() + h2 = ParagraphStyle("H2", parent=styles["Heading2"], textColor=GREEN, spaceBefore=10, spaceAfter=6) + title = ParagraphStyle("T", parent=styles["Title"], fontSize=20, textColor=DARK) + + data = report.data or {} + story = [Paragraph("Cetana — Processing Report", title), Spacer(1, 4 * mm)] + + # Summary + summary = [ + ["Order", data.get("order_number", "")], + ["Customer", data.get("customer_name", "")], + ["Status", data.get("status", "")], + ["Net weight (kg)", data.get("net_weight_kg", "")], + ["Gross / Tare (kg)", f"{data.get('gross_weight_kg', '')} / {data.get('tare_weight_kg', '')}"], + ] + story.append(self._table(summary, [50 * mm, 120 * mm])) + story.append(Spacer(1, 4 * mm)) + + # Selected steps + steps = data.get("selected_steps") or [] + if steps: + story.append(Paragraph("Selected processing steps", h2)) + story.append(Paragraph(", ".join(steps), styles["Normal"])) + story.append(Spacer(1, 3 * mm)) + + # Execution timeline + timeline = data.get("timeline") or [] + if timeline: + story.append(Paragraph("Execution timeline", h2)) + rows = [["Step", "State", "Attempt", "Operator", "Completed"]] + for item in timeline: + rows.append([ + item.get("step", ""), item.get("state", ""), str(item.get("attempt", "")), + item.get("operator", ""), item.get("completed_at", ""), + ]) + story.append(self._table(rows, [45 * mm, 30 * mm, 18 * mm, 35 * mm, 42 * mm], header=True)) + story.append(Spacer(1, 3 * mm)) + + # Quality / dynamic fields + fields = data.get("fields") or [] + if fields: + story.append(Paragraph("Quality & results", h2)) + rows = [["Field", "Value"]] + for field in fields: + rows.append([field.get("label", ""), str(field.get("value", "") or "—")]) + story.append(self._table(rows, [70 * mm, 100 * mm], header=True)) + story.append(Spacer(1, 3 * mm)) + + # Price breakdown + price = data.get("price") or {} + if price: + story.append(Paragraph("Price breakdown", h2)) + rows = [["Item", "Quantity", "Unit price", "Amount"]] + for line in price.get("lines", []): + rows.append([ + line.get("rule", ""), line.get("quantity", ""), + line.get("unit_price", ""), line.get("amount", ""), + ]) + rows.append(["Subtotal", "", "", price.get("subtotal", "")]) + rows.append(["Discounts", "", "", price.get("discounts", "")]) + rows.append(["Taxes", "", "", price.get("taxes", "")]) + rows.append(["TOTAL", "", "", f"{price.get('total', '')} {price.get('currency', '')}"]) + story.append(self._table(rows, [70 * mm, 25 * mm, 30 * mm, 45 * mm], header=True)) + story.append(Spacer(1, 3 * mm)) + + # Payments / invoices + payments = data.get("payments") or [] + invoices = data.get("invoices") or [] + if payments or invoices: + story.append(Paragraph("Payments & invoices", h2)) + rows = [["Type", "Reference", "Status", "Amount"]] + for p in payments: + rows.append(["Payment", "—", p.get("status", ""), f"{p.get('amount', '')} {p.get('currency', '')}"]) + for i in invoices: + rows.append(["Invoice", i.get("number", ""), i.get("sync_state", ""), i.get("amount", "")]) + story.append(self._table(rows, [30 * mm, 60 * mm, 40 * mm, 40 * mm], header=True)) + + doc.build(story) + return buf.getvalue() + + def _table(self, rows, col_widths, header=False): + table = Table(rows, colWidths=col_widths) + style = [ + ("GRID", (0, 0), (-1, -1), 0.5, LINE), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("PADDING", (0, 0), (-1, -1), 5), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.white, CREAM]), + ] + if header: + style += [ + ("BACKGROUND", (0, 0), (-1, 0), GREEN), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ] + table.setStyle(TableStyle(style)) + return table diff --git a/apps/reports/tests.py b/apps/reports/tests.py new file mode 100644 index 0000000..c9f2926 --- /dev/null +++ b/apps/reports/tests.py @@ -0,0 +1,36 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from apps.accounts.models import Customer +from apps.orders.models import ProcessingOrder +from apps.reports.services import ReportService +from apps.workflows.models import Workflow, WorkflowVersion + +User = get_user_model() + + +class ReportServiceTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="c", password="***") + self.customer = Customer.objects.create(user=self.user) + wf = Workflow.objects.create(name="W", slug="w-report") + version = WorkflowVersion.objects.create(workflow=wf, version=1) + self.order = ProcessingOrder.objects.create( + customer=self.customer, + workflow_version=version, + net_weight_kg=Decimal("920"), + price_snapshot={"total": "4600000.00", "currency": "IRR"}, + ) + + def test_generate_builds_data(self): + report = ReportService().generate(self.order) + self.assertEqual(report.data["order_number"], self.order.order_number) + self.assertEqual(report.data["net_weight_kg"], "920") + + def test_render_pdf(self): + report = ReportService().generate(self.order) + pdf = ReportService().render_pdf(report) + self.assertGreater(len(pdf), 1000) + self.assertTrue(pdf.startswith(b"%PDF")) diff --git a/apps/reports/urls.py b/apps/reports/urls.py new file mode 100644 index 0000000..58f9e17 --- /dev/null +++ b/apps/reports/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter + +from apps.reports.views import FinalReportViewSet, ReportDefinitionViewSet + +router = DefaultRouter() +router.register("definitions", ReportDefinitionViewSet, basename="report-definition") +router.register("", FinalReportViewSet, basename="final-report") + +urlpatterns = router.urls diff --git a/apps/reports/views.py b/apps/reports/views.py new file mode 100644 index 0000000..4038ad6 --- /dev/null +++ b/apps/reports/views.py @@ -0,0 +1,69 @@ +from django.http import HttpResponse +from rest_framework import decorators, mixins, response, status, viewsets + +from apps.core.errors import error_response +from apps.orders.models import ProcessingOrder +from apps.rbac.permissions import ActionPermission +from apps.reports.models import FinalReport, ReportDefinition +from apps.reports.serializers import FinalReportSerializer, ReportDefinitionSerializer +from apps.reports.services import ReportService + + +class ReportDefinitionViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = ReportDefinition.objects.prefetch_related("fields").all() + serializer_class = ReportDefinitionSerializer + permission_classes = [ActionPermission] + code_map = {"list": "report.view", "retrieve": "report.view"} + + +class FinalReportViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = FinalReport.objects.select_related("order").all() + serializer_class = FinalReportSerializer + permission_classes = [ActionPermission] + code_map = { + "list": "report.view", + "retrieve": "report.view", + "generate": "report.create", + "pdf": "report.view", + } + + def get_queryset(self): + user = self.request.user + qs = FinalReport.objects.select_related("order").all() + if user.is_superuser: + return qs + return qs.filter(order__customer__user=user) + + @decorators.action(detail=False, methods=["post"]) + def generate(self, request): + order_id = request.data.get("order") + if not order_id: + return response.Response( + error_response("ORDER_REQUIRED", "An order id is required."), + status=status.HTTP_400_BAD_REQUEST, + ) + try: + order = ProcessingOrder.objects.select_related("customer").get(pk=order_id) + except ProcessingOrder.DoesNotExist: + return response.Response( + error_response("ORDER_NOT_FOUND", "Order not found."), + status=status.HTTP_404_NOT_FOUND, + ) + if not request.user.is_superuser and order.customer.user_id != request.user.id: + return response.Response( + error_response("FORBIDDEN", "You do not have access to this order."), + status=status.HTTP_403_FORBIDDEN, + ) + report = ReportService().generate(order) + return response.Response(FinalReportSerializer(report).data) + + @decorators.action(detail=True, methods=["get"]) + def pdf(self, request, pk=None): + report = self.get_object() + pdf_bytes = ReportService().render_pdf(report) + filename = f"{report.order.order_number}.pdf" + return HttpResponse( + pdf_bytes, + content_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/apps/reservations/__init__.py b/apps/reservations/__init__.py new file mode 100644 index 0000000..3146e9d --- /dev/null +++ b/apps/reservations/__init__.py @@ -0,0 +1 @@ +# reservations app diff --git a/apps/reservations/apps.py b/apps/reservations/apps.py new file mode 100644 index 0000000..bad87a6 --- /dev/null +++ b/apps/reservations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReservationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.reservations" diff --git a/apps/reservations/migrations/0001_initial.py b/apps/reservations/migrations/0001_initial.py new file mode 100644 index 0000000..59a070a --- /dev/null +++ b/apps/reservations/migrations/0001_initial.py @@ -0,0 +1,86 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:29 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Facility', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Holiday', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('date', models.DateField(unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ProcessingLine', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='reservations.facility')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Slot', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('date', models.DateField(db_index=True)), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('capacity', models.PositiveIntegerField(default=1)), + ('max_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('booked_count', models.PositiveIntegerField(default=0)), + ('blocked', models.BooleanField(default=False)), + ('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='reservations.facility')), + ('line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='reservations.processingline')), + ], + ), + migrations.CreateModel( + name='Reservation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.CharField(choices=[('requested', 'Requested'), ('confirmed', 'Confirmed'), ('arrived', 'Arrived'), ('weighed', 'Weighed'), ('cancelled', 'Cancelled'), ('expired', 'Expired')], default='requested', max_length=16)), + ('notes', models.TextField(blank=True)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='accounts.customer')), + ('slot', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reservations', to='reservations.slot')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddConstraint( + model_name='slot', + constraint=models.UniqueConstraint(fields=('facility', 'line', 'date', 'start_time'), name='uniq_slot_time'), + ), + ] diff --git a/apps/reservations/migrations/0002_reservation_approved_at_reservation_approved_by_and_more.py b/apps/reservations/migrations/0002_reservation_approved_at_reservation_approved_by_and_more.py new file mode 100644 index 0000000..76c9d06 --- /dev/null +++ b/apps/reservations/migrations/0002_reservation_approved_at_reservation_approved_by_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.17 on 2026-09-15 11:56 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reservations', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='reservation', + name='approved_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='reservation', + name='approved_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_reservations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='reservation', + name='title', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AlterField( + model_name='reservation', + name='status', + field=models.CharField(choices=[('pending', 'Pending approval'), ('confirmed', 'Confirmed'), ('rejected', 'Rejected'), ('arrived', 'Arrived'), ('weighed', 'Weighed'), ('cancelled', 'Cancelled'), ('expired', 'Expired')], default='pending', max_length=16), + ), + migrations.CreateModel( + name='FacilitySchedule', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('date', models.DateField(db_index=True)), + ('is_open', models.BooleanField(default=True)), + ('capacity', models.PositiveIntegerField(default=2)), + ('start_time', models.TimeField(blank=True, null=True)), + ('end_time', models.TimeField(blank=True, null=True)), + ('note', models.CharField(blank=True, max_length=200)), + ('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='reservations.facility')), + ], + options={ + 'ordering': ['-date'], + 'constraints': [models.UniqueConstraint(fields=('facility', 'date'), name='uniq_facility_schedule_date')], + }, + ), + ] diff --git a/apps/reservations/migrations/__init__.py b/apps/reservations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reservations/models.py b/apps/reservations/models.py new file mode 100644 index 0000000..1e65ba2 --- /dev/null +++ b/apps/reservations/models.py @@ -0,0 +1,102 @@ +from django.db import models +from django.utils import timezone + +from apps.core.models import TimeStampedModel, UUIDModel + + +class Facility(UUIDModel, TimeStampedModel): + name = models.CharField(max_length=200) + + def __str__(self) -> str: + return self.name + + +class ProcessingLine(UUIDModel, TimeStampedModel): + facility = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="lines") + name = models.CharField(max_length=200) + + def __str__(self) -> str: + return f"{self.facility.name} / {self.name}" + + +class Holiday(UUIDModel): + date = models.DateField(unique=True) + + +class FacilitySchedule(UUIDModel, TimeStampedModel): + """Per-facility, per-date availability: capacity for the day and days off. + + If a schedule row exists for a date and ``is_open`` is False, no slots are + generated for that day. ``capacity``/``start_time``/``end_time`` override the + global Reservation settings for that date. + """ + + facility = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="schedules") + date = models.DateField(db_index=True) + is_open = models.BooleanField(default=True) + capacity = models.PositiveIntegerField(default=2) + start_time = models.TimeField(null=True, blank=True) + end_time = models.TimeField(null=True, blank=True) + note = models.CharField(max_length=200, blank=True) + + class Meta: + ordering = ["-date"] + constraints = [ + models.UniqueConstraint(fields=["facility", "date"], name="uniq_facility_schedule_date") + ] + + def __str__(self) -> str: + return f"{self.facility.name} {self.date}" + + +class Slot(UUIDModel): + facility = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="slots") + line = models.ForeignKey( + ProcessingLine, on_delete=models.CASCADE, related_name="slots", null=True, blank=True + ) + date = models.DateField(db_index=True) + start_time = models.TimeField() + end_time = models.TimeField() + capacity = models.PositiveIntegerField(default=1) + max_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + booked_count = models.PositiveIntegerField(default=0) + blocked = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["facility", "line", "date", "start_time"], name="uniq_slot_time") + ] + + def is_available(self) -> bool: + return ( + not self.blocked + and self.booked_count < self.capacity + and self.date >= timezone.localdate() + ) + + +class Reservation(UUIDModel, TimeStampedModel): + class Status(models.TextChoices): + PENDING = "pending", "Pending approval" + CONFIRMED = "confirmed", "Confirmed" + REJECTED = "rejected", "Rejected" + ARRIVED = "arrived", "Arrived" + WEIGHED = "weighed", "Weighed" + CANCELLED = "cancelled", "Cancelled" + EXPIRED = "expired", "Expired" + + title = models.CharField(max_length=200, blank=True) + customer = models.ForeignKey("accounts.Customer", on_delete=models.CASCADE, related_name="reservations") + slot = models.ForeignKey(Slot, on_delete=models.PROTECT, related_name="reservations") + status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING) + notes = models.TextField(blank=True) + approved_by = models.ForeignKey( + "accounts.User", null=True, blank=True, on_delete=models.SET_NULL, related_name="approved_reservations" + ) + approved_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.title or f"Reservation {self.id}" diff --git a/apps/reservations/serializers.py b/apps/reservations/serializers.py new file mode 100644 index 0000000..4af5d44 --- /dev/null +++ b/apps/reservations/serializers.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + +from apps.reservations.models import Facility, FacilitySchedule, Reservation, Slot + + +class FacilitySerializer(serializers.ModelSerializer): + class Meta: + model = Facility + fields = ["id", "name"] + + +class SlotSerializer(serializers.ModelSerializer): + facility_name = serializers.CharField(source="facility.name", read_only=True) + available = serializers.SerializerMethodField() + + class Meta: + model = Slot + fields = [ + "id", "facility", "facility_name", "line", "date", "start_time", + "end_time", "capacity", "max_weight_kg", "booked_count", "blocked", "available", + ] + + def get_available(self, obj): + return obj.is_available() + + +class FacilityScheduleSerializer(serializers.ModelSerializer): + facility_name = serializers.CharField(source="facility.name", read_only=True) + + class Meta: + model = FacilitySchedule + fields = [ + "id", "facility", "facility_name", "date", "is_open", + "capacity", "start_time", "end_time", "note", + ] + + +class ReservationSerializer(serializers.ModelSerializer): + slot = SlotSerializer(read_only=True) + customer_id = serializers.UUIDField(source="customer.id", read_only=True) + customer_name = serializers.SerializerMethodField() + + class Meta: + model = Reservation + fields = [ + "id", "title", "customer", "customer_id", "customer_name", "slot", + "status", "notes", "approved_at", "created_at", + ] + read_only_fields = ["customer", "status", "approved_at", "created_at"] + + def get_customer_name(self, obj): + user = obj.customer.user + return obj.customer.company_name or user.get_full_name() or user.username diff --git a/apps/reservations/services.py b/apps/reservations/services.py new file mode 100644 index 0000000..9605bb0 --- /dev/null +++ b/apps/reservations/services.py @@ -0,0 +1,91 @@ +from django.db import transaction +from django.utils import timezone + +from apps.core.errors import ApiError +from apps.reservations.models import Reservation, Slot + + +class ReservationService: + """Booking with backend-enforced availability, double-booking prevention, + and admin approval (which triggers a customer SMS).""" + + @transaction.atomic + def book(self, customer, slot, title: str = "", notes: str = "") -> Reservation: + slot = Slot.objects.select_for_update().get(pk=slot.pk) + if slot.blocked: + raise ApiError("SLOT_BLOCKED", "This slot is blocked.") + if slot.date < timezone.localdate(): + raise ApiError("SLOT_PAST", "Cannot book a slot in the past.") + if slot.booked_count >= slot.capacity: + raise ApiError("SLOT_FULL", "This slot is fully booked.") + slot.booked_count += 1 + slot.save(update_fields=["booked_count"]) + return Reservation.objects.create( + customer=customer, + slot=slot, + title=title, + status=Reservation.Status.PENDING, + notes=notes, + ) + + @transaction.atomic + def approve(self, reservation, user) -> Reservation: + reservation = Reservation.objects.select_for_update().select_related("slot", "customer").get(pk=reservation.pk) + if reservation.status != Reservation.Status.PENDING: + raise ApiError("INVALID_STATUS", "Only pending reservations can be approved.") + reservation.status = Reservation.Status.CONFIRMED + reservation.approved_by = user + reservation.approved_at = timezone.now() + reservation.save(update_fields=["status", "approved_by", "approved_at"]) + + from apps.notifications.services import NotificationService + + customer = reservation.customer + NotificationService().send_event( + "reservation.approved", + { + "customer_name": customer.company_name or customer.user.get_full_name() or customer.user.username, + "reservation_title": reservation.title, + "slot_date": str(reservation.slot.date), + "slot_time": str(reservation.slot.start_time), + }, + recipient=customer.user.phone or customer.user.username, + ) + return reservation + + @transaction.atomic + def reject(self, reservation, user) -> Reservation: + reservation = Reservation.objects.select_for_update().select_related("slot").get(pk=reservation.pk) + if reservation.status not in (Reservation.Status.PENDING, Reservation.Status.CONFIRMED): + raise ApiError("INVALID_STATUS", "This reservation cannot be rejected.") + reservation.status = Reservation.Status.REJECTED + reservation.approved_by = user + reservation.approved_at = timezone.now() + reservation.save(update_fields=["status", "approved_by", "approved_at"]) + slot = reservation.slot + slot.booked_count = max(0, slot.booked_count - 1) + slot.save(update_fields=["booked_count"]) + return reservation + + @transaction.atomic + def cancel(self, reservation) -> Reservation: + reservation = ( + Reservation.objects.select_for_update() + .select_related("slot") + .get(pk=reservation.pk) + ) + if reservation.status == Reservation.Status.CANCELLED: + return reservation + if reservation.status == Reservation.Status.WEIGHED: + raise ApiError("RESERVATION_LOCKED", "Cannot cancel a reservation that has been weighed.") + reservation.status = Reservation.Status.CANCELLED + reservation.save(update_fields=["status"]) + slot = reservation.slot + slot.booked_count = max(0, slot.booked_count - 1) + slot.save(update_fields=["booked_count"]) + return reservation + + @transaction.atomic + def reschedule(self, reservation, new_slot) -> Reservation: + self.cancel(reservation) + return self.book(reservation.customer, new_slot, title=reservation.title) diff --git a/apps/reservations/slot_service.py b/apps/reservations/slot_service.py new file mode 100644 index 0000000..ae8d2c5 --- /dev/null +++ b/apps/reservations/slot_service.py @@ -0,0 +1,56 @@ +"""Dynamic slot generation. + +Slots are generated from runtime settings (working hours, slot duration, +default capacity) and per-day `FacilitySchedule` overrides (capacity for the +day, days off). Generation is idempotent — re-running updates existing slots +instead of duplicating them. +""" +from datetime import datetime, timedelta + +from apps.core.settings_service import SettingsService +from apps.reservations.models import FacilitySchedule, Slot + + +class SlotService: + def generate_slots(self, facility, day, line=None) -> int: + schedule = FacilitySchedule.objects.filter(facility=facility, date=day).first() + if schedule is not None and not schedule.is_open: + return 0 # day off + + service = SettingsService() + if schedule is not None and schedule.start_time: + start_str = schedule.start_time.strftime("%H:%M") + else: + start_str = service.get("reservation.working_hours_start", "08:00") + if schedule is not None and schedule.end_time: + end_str = schedule.end_time.strftime("%H:%M") + else: + end_str = service.get("reservation.working_hours_end", "17:00") + duration = int(service.get("reservation.slot_duration_minutes", 30)) + if schedule is not None: + capacity = schedule.capacity + else: + capacity = int(service.get("reservation.default_capacity", 2)) + + if duration <= 0: + raise ValueError("Slot duration must be positive.") + + start_t = datetime.strptime(start_str, "%H:%M").time() + end_t = datetime.strptime(end_str, "%H:%M").time() + current = datetime.combine(day, start_t) + end_dt = datetime.combine(day, end_t) + + created = 0 + while current + timedelta(minutes=duration) <= end_dt: + slot_end = (current + timedelta(minutes=duration)).time() + _, was_created = Slot.objects.update_or_create( + facility=facility, + line=line, + date=day, + start_time=current.time(), + defaults={"end_time": slot_end, "capacity": capacity}, + ) + if was_created: + created += 1 + current += timedelta(minutes=duration) + return created diff --git a/apps/reservations/tests.py b/apps/reservations/tests.py new file mode 100644 index 0000000..aaad97d --- /dev/null +++ b/apps/reservations/tests.py @@ -0,0 +1,82 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + +from apps.accounts.models import Customer +from apps.core.errors import ApiError +from apps.reservations.models import Facility, Slot +from apps.reservations.services import ReservationService + +User = get_user_model() + + +class ReservationTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="c1", password="x") + self.customer = Customer.objects.create(user=self.user) + self.facility = Facility.objects.create(name="F1") + self.slot = Slot.objects.create( + facility=self.facility, + date=timezone.localdate(), + start_time="08:00", + end_time="08:30", + capacity=1, + ) + self.service = ReservationService() + + def test_double_booking_prevented(self): + self.service.book(self.customer, self.slot) + with self.assertRaises(ApiError) as ctx: + self.service.book(self.customer, self.slot) + self.assertEqual(ctx.exception.code, "SLOT_FULL") + + def test_cancel_frees_slot(self): + reservation = self.service.book(self.customer, self.slot) + self.service.cancel(reservation) + self.slot.refresh_from_db() + self.assertEqual(self.slot.booked_count, 0) + + def test_cannot_book_blocked_slot(self): + self.slot.blocked = True + self.slot.save() + with self.assertRaises(ApiError) as ctx: + self.service.book(self.customer, self.slot) + self.assertEqual(ctx.exception.code, "SLOT_BLOCKED") + + def test_generate_slots_from_dynamic_settings(self): + from apps.core.settings_service import SettingsService + from apps.reservations.slot_service import SlotService + + facility2 = Facility.objects.create(name="F2") + svc = SettingsService() + svc.set("reservation.working_hours_start", "08:00") + svc.set("reservation.working_hours_end", "09:00") + svc.set("reservation.slot_duration_minutes", 30) + svc.set("reservation.default_capacity", 3) + + created = SlotService().generate_slots(facility2, timezone.localdate()) + self.assertEqual(created, 2) + slots = Slot.objects.filter(facility=facility2).order_by("start_time") + self.assertEqual(slots.count(), 2) + self.assertEqual(slots.first().capacity, 3) + self.assertEqual(str(slots.first().end_time), "08:30:00") + + def test_day_off_generates_no_slots(self): + from apps.reservations.models import FacilitySchedule + from apps.reservations.slot_service import SlotService + + facility3 = Facility.objects.create(name="F3") + FacilitySchedule.objects.create(facility=facility3, date=timezone.localdate(), is_open=False) + created = SlotService().generate_slots(facility3, timezone.localdate()) + self.assertEqual(created, 0) + + def test_approve_sends_sms(self): + from apps.notifications.models import NotificationLog, NotificationTemplate + + NotificationTemplate.objects.create(event="reservation.approved", channel="sms", body="Hi {{customer_name}}") + reservation = self.service.book(self.customer, self.slot, title="Batch A") + self.assertEqual(reservation.status, "pending") + self.service.approve(reservation, self.user) + reservation.refresh_from_db() + self.assertEqual(reservation.status, "confirmed") + self.assertEqual(NotificationLog.objects.filter(status="sent").count(), 1) diff --git a/apps/reservations/urls.py b/apps/reservations/urls.py new file mode 100644 index 0000000..37c85a1 --- /dev/null +++ b/apps/reservations/urls.py @@ -0,0 +1,16 @@ +from rest_framework.routers import DefaultRouter + +from apps.reservations.views import ( + FacilityScheduleViewSet, + FacilityViewSet, + ReservationViewSet, + SlotViewSet, +) + +router = DefaultRouter() +router.register("facilities", FacilityViewSet, basename="facility") +router.register("schedules", FacilityScheduleViewSet, basename="facility-schedule") +router.register("slots", SlotViewSet, basename="slot") +router.register("", ReservationViewSet, basename="reservation") + +urlpatterns = router.urls diff --git a/apps/reservations/views.py b/apps/reservations/views.py new file mode 100644 index 0000000..3aaf55e --- /dev/null +++ b/apps/reservations/views.py @@ -0,0 +1,171 @@ +from datetime import date as date_cls + +from rest_framework import decorators, mixins, response, status, viewsets +from rest_framework.permissions import IsAuthenticated + +from apps.core.errors import ApiError, error_response +from apps.rbac.permissions import ActionPermission +from apps.reservations.models import Facility, FacilitySchedule, ProcessingLine, Reservation, Slot +from apps.reservations.serializers import ( + FacilityScheduleSerializer, + FacilitySerializer, + ReservationSerializer, + SlotSerializer, +) +from apps.reservations.services import ReservationService +from apps.reservations.slot_service import SlotService + + +class FacilityViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Facility.objects.all() + serializer_class = FacilitySerializer + permission_classes = [IsAuthenticated] + + +class FacilityScheduleViewSet(viewsets.ModelViewSet): + queryset = FacilitySchedule.objects.select_related("facility").all() + serializer_class = FacilityScheduleSerializer + permission_classes = [ActionPermission] + filterset_fields = ["facility", "date"] + code_map = { + "list": "reservation.view", + "retrieve": "reservation.view", + "create": "slot.manage", + "update": "slot.manage", + "partial_update": "slot.manage", + "destroy": "slot.manage", + } + + +class SlotViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = Slot.objects.select_related("facility", "line").all() + serializer_class = SlotSerializer + permission_classes = [ActionPermission] + filterset_fields = ["facility", "date", "blocked"] + code_map = { + "list": "reservation.view", + "retrieve": "reservation.view", + "create": "slot.manage", + "update": "slot.manage", + "partial_update": "slot.manage", + "destroy": "slot.manage", + "generate": "slot.manage", + } + + @decorators.action(detail=False, methods=["post"]) + def generate(self, request): + facility_id = request.data.get("facility") + date_str = request.data.get("date") + line_id = request.data.get("line") + if not facility_id or not date_str: + return response.Response( + error_response("INVALID_INPUT", "facility and date are required."), + status=status.HTTP_400_BAD_REQUEST, + ) + try: + facility = Facility.objects.get(pk=facility_id) + except Facility.DoesNotExist: + return response.Response( + error_response("FACILITY_NOT_FOUND", "Facility not found."), + status=status.HTTP_404_NOT_FOUND, + ) + line = None + if line_id: + try: + line = ProcessingLine.objects.get(pk=line_id) + except ProcessingLine.DoesNotExist: + return response.Response( + error_response("LINE_NOT_FOUND", "Processing line not found."), + status=status.HTTP_404_NOT_FOUND, + ) + try: + day = date_cls.fromisoformat(date_str) + except ValueError: + return response.Response( + error_response("INVALID_DATE", "Invalid date."), + status=status.HTTP_400_BAD_REQUEST, + ) + try: + created = SlotService().generate_slots(facility, day, line) + except ValueError as exc: + return response.Response( + error_response("INVALID_SETTINGS", str(exc)), + status=status.HTTP_400_BAD_REQUEST, + ) + return response.Response({"created": created}) + + +class ReservationViewSet(viewsets.ModelViewSet): + serializer_class = ReservationSerializer + permission_classes = [ActionPermission] + filterset_fields = ["status"] + code_map = { + "list": "reservation.view", + "retrieve": "reservation.view", + "create": "reservation.create", + "cancel": "reservation.cancel", + "approve": "reservation.approve", + "reject": "reservation.approve", + } + service = ReservationService() + + def get_queryset(self): + user = self.request.user + if user.is_superuser or user.user_roles.filter(role__slug__in=["administrator", "facility_manager"]).exists(): + return Reservation.objects.select_related("customer__user", "slot").all() + return Reservation.objects.select_related("customer__user", "slot").filter(customer__user=user) + + def perform_create(self, serializer): + slot_id = self.request.data.get("slot") + if not slot_id: + raise ApiError("SLOT_REQUIRED", "A slot id is required.") + try: + reservation = self.service.book( + self._customer(), + Slot.objects.get(pk=slot_id), + title=self.request.data.get("title", ""), + notes=self.request.data.get("notes", ""), + ) + except Slot.DoesNotExist: + raise ApiError("SLOT_NOT_FOUND", "Slot not found.") + serializer.instance = reservation + + def _customer(self): + from apps.accounts.models import Customer + + customer, _ = Customer.objects.get_or_create(user=self.request.user) + return customer + + @decorators.action(detail=True, methods=["post"]) + def cancel(self, request, pk=None): + reservation = self.get_object() + try: + self.service.cancel(reservation) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(ReservationSerializer(reservation).data) + + @decorators.action(detail=True, methods=["post"]) + def approve(self, request, pk=None): + reservation = self.get_object() + try: + reservation = self.service.approve(reservation, request.user) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(ReservationSerializer(reservation).data) + + @decorators.action(detail=True, methods=["post"]) + def reject(self, request, pk=None): + reservation = self.get_object() + try: + reservation = self.service.reject(reservation, request.user) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(ReservationSerializer(reservation).data) diff --git a/apps/workflows/__init__.py b/apps/workflows/__init__.py new file mode 100644 index 0000000..b5bbf69 --- /dev/null +++ b/apps/workflows/__init__.py @@ -0,0 +1 @@ +# workflows app diff --git a/apps/workflows/apps.py b/apps/workflows/apps.py new file mode 100644 index 0000000..489da51 --- /dev/null +++ b/apps/workflows/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WorkflowsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.workflows" diff --git a/apps/workflows/engine.py b/apps/workflows/engine.py new file mode 100644 index 0000000..3ea79ec --- /dev/null +++ b/apps/workflows/engine.py @@ -0,0 +1,169 @@ +"""Generic, database-driven workflow engine. + +The engine understands only generic concepts: steps, transitions, conditions, +and versions. It has no knowledge of "washing", "drying", or any other +business-specific term. +""" +from django.db import transaction +from django.utils import timezone + +from apps.core.errors import ApiError + + +class WorkflowError(ApiError): + def __init__(self, message: str, details=None, code: str = "WORKFLOW_INVALID"): + super().__init__(code, message, details) + + +# --------------------------------------------------------------------------- +# Condition registry. New condition types can be added without touching the +# rest of the system. +# --------------------------------------------------------------------------- +CONDITION_HANDLERS = {} + + +def register_condition(key: str): + def decorator(fn): + CONDITION_HANDLERS[key] = fn + return fn + + return decorator + + +@register_condition("step_selected") +def _cond_step_selected(ctx, cond): + return cond.get("step") in ctx.get("selected_step_keys", []) + + +@register_condition("step_completed") +def _cond_step_completed(ctx, cond): + return cond.get("step") in ctx.get("completed_step_keys", []) + + +@register_condition("weight_gte") +def _cond_weight_gte(ctx, cond): + try: + return float(ctx.get("net_weight") or 0) >= float(cond.get("value", 0)) + except (TypeError, ValueError): + return False + + +@register_condition("weight_lte") +def _cond_weight_lte(ctx, cond): + try: + return float(ctx.get("net_weight") or 0) <= float(cond.get("value", 0)) + except (TypeError, ValueError): + return False + + +@register_condition("payment_status") +def _cond_payment_status(ctx, cond): + return ctx.get("payment_status") == cond.get("value") + + +@register_condition("customer_category") +def _cond_customer_category(ctx, cond): + return ctx.get("customer_category") == cond.get("value") + + +@register_condition("role") +def _cond_role(ctx, cond): + return cond.get("value") in ctx.get("user_roles", []) + + +@register_condition("field") +def _cond_field(ctx, cond): + return ctx.get("fields", {}).get(cond.get("key")) == cond.get("value") + + +def evaluate_condition(cond, context) -> bool: + handler = CONDITION_HANDLERS.get(cond.get("type")) + if handler is None: + return False + try: + return bool(handler(context, cond)) + except Exception: + return False + + +def evaluate_conditions(conditions, context) -> bool: + if not conditions: + return True + return all(evaluate_condition(c, context) for c in conditions) + + +class WorkflowEngine: + """Validates and publishes workflow versions and evaluates transitions.""" + + def validate_version(self, version) -> bool: + errors = [] + steps = list(version.steps.all()) + if not steps: + errors.append("workflow has no steps") + if not any(s.is_start for s in steps): + errors.append("missing start step") + if not any(s.is_terminal for s in steps): + errors.append("missing terminal step") + step_ids = {s.id for s in steps} + transitions = list(version.transitions.select_related("from_step", "to_step").all()) + for t in transitions: + if t.from_step_id not in step_ids or t.to_step_id not in step_ids: + errors.append(f"broken transition: {t}") + if self._has_cycle(steps, transitions): + errors.append("circular dependency detected") + if errors: + raise WorkflowError("Workflow validation failed", {"errors": errors}) + return True + + def _has_cycle(self, steps, transitions) -> bool: + adj = {s.id: [] for s in steps} + for t in transitions: + adj.setdefault(t.from_step_id, []).append(t.to_step_id) + WHITE, GRAY, BLACK = 0, 1, 2 + color = {s.id: WHITE for s in steps} + + def dfs(u): + color[u] = GRAY + for v in adj.get(u, []): + if v not in color: + continue + if color[v] == WHITE and dfs(v): + return True + if color[v] == GRAY: + return True + color[u] = BLACK + return False + + for s in steps: + if color[s.id] == WHITE and dfs(s.id): + return True + return False + + @transaction.atomic + def publish(self, workflow, version): + self.validate_version(version) + version.status = "active" + version.published_at = timezone.now() + version.save(update_fields=["status", "published_at"]) + workflow.active_version = version + workflow.status = "active" + workflow.save(update_fields=["active_version", "status"]) + return version + + def start_steps(self, version): + return list(version.steps.filter(is_start=True, enabled=True)) + + def available_next_steps(self, version, current_step, context): + """Return the list of steps reachable from `current_step` given context.""" + result = [] + qs = version.transitions.filter(from_step=current_step).select_related("to_step") + for t in qs: + if evaluate_conditions(t.conditions, context): + result.append(t.to_step) + return result + + def can_transition(self, version, from_step, to_step, context) -> bool: + t = version.transitions.filter(from_step=from_step, to_step=to_step).first() + if t is None: + return False + return evaluate_conditions(t.conditions, context) diff --git a/apps/workflows/migrations/0001_initial.py b/apps/workflows/migrations/0001_initial.py new file mode 100644 index 0000000..5df0458 --- /dev/null +++ b/apps/workflows/migrations/0001_initial.py @@ -0,0 +1,109 @@ +# Generated by Django 5.2.17 on 2026-09-15 09:28 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('rbac', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='Workflow', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ('slug', models.SlugField(max_length=200, unique=True)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('active', 'Active'), ('inactive', 'Inactive'), ('archived', 'Archived')], default='draft', max_length=16)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='WorkflowStep', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('key', models.SlugField(max_length=100)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0)), + ('step_type', models.CharField(choices=[('task', 'Task'), ('decision', 'Decision'), ('input', 'Input'), ('output', 'Output'), ('integration', 'Integration')], default='task', max_length=16)), + ('required', models.BooleanField(default=False)), + ('enabled', models.BooleanField(default=True)), + ('estimated_duration_minutes', models.PositiveIntegerField(default=0)), + ('is_start', models.BooleanField(default=False)), + ('is_terminal', models.BooleanField(default=False)), + ('requires_customer_confirmation', models.BooleanField(default=False)), + ('requires_payment', models.BooleanField(default=False)), + ('triggers_invoice', models.BooleanField(default=False)), + ('triggers_odoo_sync', models.BooleanField(default=False)), + ('customer_visible', models.BooleanField(default=True)), + ('operator_visible', models.BooleanField(default=True)), + ('live_monitoring_enabled', models.BooleanField(default=False)), + ('input_schema', models.JSONField(blank=True, default=dict)), + ('output_schema', models.JSONField(blank=True, default=dict)), + ('availability_condition', models.JSONField(blank=True, default=dict)), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='WorkflowVersion', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('version', models.PositiveIntegerField()), + ('status', models.CharField(choices=[('draft', 'Draft'), ('active', 'Active'), ('inactive', 'Inactive'), ('archived', 'Archived')], default='draft', max_length=16)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='workflows.workflow')), + ], + options={ + 'ordering': ['-version'], + }, + ), + migrations.CreateModel( + name='WorkflowTransition', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('conditions', models.JSONField(blank=True, default=list)), + ('from_step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outgoing', to='workflows.workflowstep')), + ('required_roles', models.ManyToManyField(blank=True, to='rbac.role')), + ('to_step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incoming', to='workflows.workflowstep')), + ('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transitions', to='workflows.workflowversion')), + ], + ), + migrations.AddField( + model_name='workflowstep', + name='version', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='workflows.workflowversion'), + ), + migrations.AddField( + model_name='workflow', + name='active_version', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='workflows.workflowversion'), + ), + migrations.AddConstraint( + model_name='workflowversion', + constraint=models.UniqueConstraint(fields=('workflow', 'version'), name='uniq_workflow_version'), + ), + migrations.AddConstraint( + model_name='workflowtransition', + constraint=models.UniqueConstraint(fields=('version', 'from_step', 'to_step'), name='uniq_transition'), + ), + migrations.AddConstraint( + model_name='workflowstep', + constraint=models.UniqueConstraint(fields=('version', 'key'), name='uniq_step_key'), + ), + ] diff --git a/apps/workflows/migrations/0002_workflowstep_position_x_workflowstep_position_y.py b/apps/workflows/migrations/0002_workflowstep_position_x_workflowstep_position_y.py new file mode 100644 index 0000000..e1ee1fd --- /dev/null +++ b/apps/workflows/migrations/0002_workflowstep_position_x_workflowstep_position_y.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.17 on 2026-09-15 10:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='workflowstep', + name='position_x', + field=models.FloatField(default=0), + ), + migrations.AddField( + model_name='workflowstep', + name='position_y', + field=models.FloatField(default=0), + ), + ] diff --git a/apps/workflows/migrations/__init__.py b/apps/workflows/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/workflows/models.py b/apps/workflows/models.py new file mode 100644 index 0000000..f20c210 --- /dev/null +++ b/apps/workflows/models.py @@ -0,0 +1,111 @@ +"""Database-driven workflow definitions. + +The engine knows only generic concepts (steps, transitions, conditions, +versions). Business processes (washing, drying, sorting, ...) are data, never +code. Do NOT add hard-coded step names or branching here. +""" +from django.db import models + +from apps.core.models import TimeStampedModel, UUIDModel + + +class Workflow(UUIDModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + ACTIVE = "active", "Active" + INACTIVE = "inactive", "Inactive" + ARCHIVED = "archived", "Archived" + + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=200, unique=True) + description = models.TextField(blank=True) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) + active_version = models.OneToOneField( + "WorkflowVersion", null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) + + def __str__(self) -> str: + return self.name + + +class WorkflowVersion(UUIDModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + ACTIVE = "active", "Active" + INACTIVE = "inactive", "Inactive" + ARCHIVED = "archived", "Archived" + + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="versions") + version = models.PositiveIntegerField() + status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) + published_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-version"] + constraints = [ + models.UniqueConstraint(fields=["workflow", "version"], name="uniq_workflow_version") + ] + + def __str__(self) -> str: + return f"{self.workflow.slug} v{self.version}" + + +class WorkflowStep(UUIDModel): + class StepType(models.TextChoices): + TASK = "task", "Task" + DECISION = "decision", "Decision" + INPUT = "input", "Input" + OUTPUT = "output", "Output" + INTEGRATION = "integration", "Integration" + + version = models.ForeignKey(WorkflowVersion, on_delete=models.CASCADE, related_name="steps") + key = models.SlugField(max_length=100) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0) + step_type = models.CharField(max_length=16, choices=StepType.choices, default=StepType.TASK) + required = models.BooleanField(default=False) + enabled = models.BooleanField(default=True) + estimated_duration_minutes = models.PositiveIntegerField(default=0) + is_start = models.BooleanField(default=False) + is_terminal = models.BooleanField(default=False) + requires_customer_confirmation = models.BooleanField(default=False) + requires_payment = models.BooleanField(default=False) + triggers_invoice = models.BooleanField(default=False) + triggers_odoo_sync = models.BooleanField(default=False) + customer_visible = models.BooleanField(default=True) + operator_visible = models.BooleanField(default=True) + live_monitoring_enabled = models.BooleanField(default=False) + input_schema = models.JSONField(default=dict, blank=True) + output_schema = models.JSONField(default=dict, blank=True) + # Optional condition (dict) that gates whether this step is available. + availability_condition = models.JSONField(default=dict, blank=True) + # Canvas position for the visual workflow designer. + position_x = models.FloatField(default=0) + position_y = models.FloatField(default=0) + + class Meta: + ordering = ["order"] + constraints = [ + models.UniqueConstraint(fields=["version", "key"], name="uniq_step_key") + ] + + def __str__(self) -> str: + return f"{self.key} ({self.version})" + + +class WorkflowTransition(UUIDModel): + version = models.ForeignKey(WorkflowVersion, on_delete=models.CASCADE, related_name="transitions") + from_step = models.ForeignKey(WorkflowStep, on_delete=models.CASCADE, related_name="outgoing") + to_step = models.ForeignKey(WorkflowStep, on_delete=models.CASCADE, related_name="incoming") + # List of condition dicts, e.g. [{"type": "step_completed", "step": "drying"}] + conditions = models.JSONField(default=list, blank=True) + required_roles = models.ManyToManyField("rbac.Role", blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["version", "from_step", "to_step"], name="uniq_transition") + ] + + def __str__(self) -> str: + return f"{self.from_step.key} -> {self.to_step.key}" diff --git a/apps/workflows/serializers.py b/apps/workflows/serializers.py new file mode 100644 index 0000000..51468e8 --- /dev/null +++ b/apps/workflows/serializers.py @@ -0,0 +1,34 @@ +from rest_framework import serializers + +from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion + + +class WorkflowStepSerializer(serializers.ModelSerializer): + class Meta: + model = WorkflowStep + fields = "__all__" + read_only_fields = ["id"] + + +class WorkflowTransitionSerializer(serializers.ModelSerializer): + class Meta: + model = WorkflowTransition + fields = "__all__" + read_only_fields = ["id"] + + +class WorkflowVersionSerializer(serializers.ModelSerializer): + steps = WorkflowStepSerializer(many=True, read_only=True) + transitions = WorkflowTransitionSerializer(many=True, read_only=True) + + class Meta: + model = WorkflowVersion + fields = "__all__" + + +class WorkflowSerializer(serializers.ModelSerializer): + active_version = WorkflowVersionSerializer(read_only=True) + + class Meta: + model = Workflow + fields = "__all__" diff --git a/apps/workflows/tests.py b/apps/workflows/tests.py new file mode 100644 index 0000000..0f5c7dc --- /dev/null +++ b/apps/workflows/tests.py @@ -0,0 +1,75 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from apps.accounts.models import Customer +from apps.orders.models import ProcessingOrder +from apps.workflows.engine import WorkflowEngine +from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion + +User = get_user_model() + + +def make_workflow(slug="test"): + wf = Workflow.objects.create(name="Test", slug=slug) + v = WorkflowVersion.objects.create(workflow=wf, version=1, status="draft") + start = WorkflowStep.objects.create(version=v, key="start", name="Start", order=1, is_start=True) + mid = WorkflowStep.objects.create(version=v, key="mid", name="Mid", order=2) + end = WorkflowStep.objects.create(version=v, key="end", name="End", order=3, is_terminal=True) + WorkflowTransition.objects.create(version=v, from_step=start, to_step=mid) + WorkflowTransition.objects.create(version=v, from_step=mid, to_step=end) + return wf, v, start, mid, end + + +class WorkflowEngineTests(TestCase): + def test_publish_valid_version(self): + wf, v, *_ = make_workflow() + WorkflowEngine().publish(wf, v) + wf.refresh_from_db() + v.refresh_from_db() + self.assertEqual(wf.status, "active") + self.assertEqual(v.status, "active") + self.assertEqual(wf.active_version, v) + + def test_publish_rejects_missing_terminal(self): + wf = Workflow.objects.create(name="T2", slug="t2") + v = WorkflowVersion.objects.create(workflow=wf, version=1) + WorkflowStep.objects.create(version=v, key="start", name="Start", is_start=True) + WorkflowStep.objects.create(version=v, key="mid", name="Mid") + with self.assertRaises(Exception) as ctx: + WorkflowEngine().publish(wf, v) + errors = ctx.exception.details.get("errors", []) + self.assertTrue(any("terminal" in e for e in errors), errors) + + def test_available_next_steps_respect_conditions(self): + wf, v, start, mid, end = make_workflow() + WorkflowTransition.objects.create( + version=v, from_step=start, to_step=end, + conditions=[{"type": "step_selected", "step": "mid"}], + ) + engine = WorkflowEngine() + keys = {s.key for s in engine.available_next_steps(v, start, {"selected_step_keys": []})} + self.assertIn("mid", keys) + self.assertNotIn("end", keys) + keys2 = {s.key for s in engine.available_next_steps(v, start, {"selected_step_keys": ["mid"]})} + self.assertIn("end", keys2) + + +class WorkflowVersioningTests(TestCase): + def test_order_keeps_its_version_after_new_publish(self): + wf, v, *_ = make_workflow() + WorkflowEngine().publish(wf, v) + user = User.objects.create_user(username="cust", password="x") + customer = Customer.objects.create(user=user) + order = ProcessingOrder.objects.create(customer=customer, workflow_version=v) + + # Publish a second version. + v2 = WorkflowVersion.objects.create(workflow=wf, version=2, status="draft") + s2 = WorkflowStep.objects.create(version=v2, key="start", name="Start", is_start=True) + e2 = WorkflowStep.objects.create(version=v2, key="end", name="End", is_terminal=True) + WorkflowTransition.objects.create(version=v2, from_step=s2, to_step=e2) + WorkflowEngine().publish(wf, v2) + + wf.refresh_from_db() + order.refresh_from_db() + self.assertEqual(wf.active_version, v2) + self.assertEqual(order.workflow_version, v) diff --git a/apps/workflows/urls.py b/apps/workflows/urls.py new file mode 100644 index 0000000..ef700a2 --- /dev/null +++ b/apps/workflows/urls.py @@ -0,0 +1,18 @@ +from rest_framework.routers import DefaultRouter + +from apps.workflows.views import ( + WorkflowStepViewSet, + WorkflowTransitionViewSet, + WorkflowVersionViewSet, + WorkflowViewSet, +) + +router = DefaultRouter() +# Register specific prefixes BEFORE the empty prefix, otherwise "/workflows/steps/" +# matches the empty-prefix detail route (^(?P[^/.]+)/$) and POST is not allowed. +router.register("versions", WorkflowVersionViewSet, basename="workflow-version") +router.register("steps", WorkflowStepViewSet, basename="workflow-step") +router.register("transitions", WorkflowTransitionViewSet, basename="workflow-transition") +router.register("", WorkflowViewSet, basename="workflow") + +urlpatterns = router.urls diff --git a/apps/workflows/views.py b/apps/workflows/views.py new file mode 100644 index 0000000..722091f --- /dev/null +++ b/apps/workflows/views.py @@ -0,0 +1,66 @@ +from rest_framework import decorators, response, status, viewsets + +from apps.core.errors import ApiError, error_response +from apps.rbac.permissions import ActionPermission +from apps.workflows.engine import WorkflowEngine +from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion +from apps.workflows.serializers import ( + WorkflowSerializer, + WorkflowStepSerializer, + WorkflowTransitionSerializer, + WorkflowVersionSerializer, +) + + +class WorkflowViewSet(viewsets.ModelViewSet): + queryset = Workflow.objects.all() + serializer_class = WorkflowSerializer + permission_classes = [ActionPermission] + code_map = { + "list": "workflow.view", + "retrieve": "workflow.view", + "create": "workflow.create", + "update": "workflow.update", + "partial_update": "workflow.update", + "destroy": "workflow.update", + "publish": "workflow.publish", + } + + @decorators.action(detail=True, methods=["post"]) + def publish(self, request, pk=None): + workflow = self.get_object() + version = workflow.versions.filter(status="draft").order_by("-version").first() + if version is None: + return response.Response( + error_response("WORKFLOW_INVALID", "No draft version to publish"), + status=status.HTTP_400_BAD_REQUEST, + ) + try: + WorkflowEngine().publish(workflow, version) + except ApiError as exc: + return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status) + return response.Response(WorkflowSerializer(workflow).data) + + +class WorkflowVersionViewSet(viewsets.ModelViewSet): + queryset = WorkflowVersion.objects.all() + serializer_class = WorkflowVersionSerializer + permission_classes = [ActionPermission] + filterset_fields = ["workflow"] + code_map = {"create": "workflow.create", "update": "workflow.update", "partial_update": "workflow.update"} + + +class WorkflowStepViewSet(viewsets.ModelViewSet): + queryset = WorkflowStep.objects.all() + serializer_class = WorkflowStepSerializer + permission_classes = [ActionPermission] + filterset_fields = ["version", "key"] + code_map = {"create": "workflow.create", "update": "workflow.update", "partial_update": "workflow.update"} + + +class WorkflowTransitionViewSet(viewsets.ModelViewSet): + queryset = WorkflowTransition.objects.all() + serializer_class = WorkflowTransitionSerializer + permission_classes = [ActionPermission] + filterset_fields = ["version"] + code_map = {"create": "workflow.create", "update": "workflow.update", "partial_update": "workflow.update"} diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..41b75cc --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,5 @@ +try: + from .celery import app as celery_app + __all__ = ("celery_app",) +except Exception: # celery is optional for basic dev runs + celery_app = None diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..738e28e --- /dev/null +++ b/config/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +app = Celery("cetana") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..83e9b6b --- /dev/null +++ b/config/settings.py @@ -0,0 +1,209 @@ +"""Django settings for the Cetana backend. + +All secrets and environment-specific values are read from environment +variables (optionally loaded from a local .env file). Never commit secrets. +""" +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Best-effort .env loading (dependency-free path; python-dotenv used when available). +try: + from dotenv import load_dotenv + + load_dotenv(BASE_DIR / ".env") +except Exception: # pragma: no cover - dotenv is optional + pass + + +def env(name: str, default: str = "") -> str: + return os.environ.get(name, default) + + +def env_bool(name: str, default: bool = False) -> bool: + val = os.environ.get(name) + if val is None: + return default + return val.lower() in {"1", "true", "yes", "on"} + + +SECRET_KEY = env("SECRET_KEY", "insecure-dev-key-change-me") +DEBUG = env_bool("DEBUG", True) +ALLOWED_HOSTS = [h.strip() for h in env("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h.strip()] + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- +DATABASE_URL = env("DATABASE_URL", "") +if DATABASE_URL.startswith("postgres"): + import urllib.parse + + _url = urllib.parse.urlparse(DATABASE_URL) + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": _url.path.lstrip("/") or "cetana", + "USER": _url.username or "", + "PASSWORD": _url.password or "", + "HOST": _url.hostname or "localhost", + "PORT": _url.port or 5432, + } + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + } + +# --------------------------------------------------------------------------- +# Applications +# --------------------------------------------------------------------------- +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # third-party + "rest_framework", + "rest_framework_simplejwt", + "corsheaders", + "django_filters", + "drf_spectacular", + # local + "apps.core", + "apps.accounts", + "apps.rbac", + "apps.reservations", + "apps.orders", + "apps.workflows", + "apps.processing", + "apps.pricing", + "apps.payments", + "apps.invoicing", + "apps.notifications", + "apps.reports", + "apps.live_monitoring", + "apps.audit", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +AUTH_USER_MODEL = "accounts.User" + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +MEDIA_URL = "media/" +MEDIA_ROOT = BASE_DIR / "media" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# --------------------------------------------------------------------------- +# DRF +# --------------------------------------------------------------------------- +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), + "DEFAULT_PAGINATION_CLASS": "apps.core.pagination.StandardPagination", + "PAGE_SIZE": 20, + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "EXCEPTION_HANDLER": "apps.core.errors.api_exception_handler", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Cetana API", + "DESCRIPTION": "Configurable pistachio processing reservation & management platform", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, +} + +from datetime import timedelta # noqa: E402 + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "AUTH_HEADER_TYPES": ("Bearer",), +} + +# --------------------------------------------------------------------------- +# CORS +# --------------------------------------------------------------------------- +CORS_ALLOW_ALL_ORIGINS = DEBUG +CORS_ALLOWED_ORIGINS = [ + o.strip() for o in env("CORS_ALLOWED_ORIGINS", "http://localhost:5173").split(",") if o.strip() +] + +# --------------------------------------------------------------------------- +# Celery +# --------------------------------------------------------------------------- +CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://localhost:6379/0") +CELERY_RESULT_BACKEND = CELERY_BROKER_URL +CELERY_TASK_ACKS_LATE = True +CELERY_TASK_DEFAULT_RETRY_DELAY = 60 +CELERY_TASK_MAX_RETRIES = 5 + +# --------------------------------------------------------------------------- +# Cetana settings +# --------------------------------------------------------------------------- +BASE_CURRENCY = env("BASE_CURRENCY", "IRR") + +ODOO_URL = env("ODOO_URL", "") +ODOO_DATABASE = env("ODOO_DATABASE", "") +ODOO_USERNAME = env("ODOO_USERNAME", "") +ODOO_PASSWORD = env("ODOO_PASSWORD", "") +ODOO_API_KEY = env("ODOO_API_KEY", "") + +PAYMENT_GATEWAY_PROVIDER = env("PAYMENT_GATEWAY_PROVIDER", "mock") +PAYMENT_GATEWAY_MERCHANT_ID = env("PAYMENT_GATEWAY_MERCHANT_ID", "") + +SMS_PROVIDER = env("SMS_PROVIDER", "mock") +SMS_PROVIDER_API_KEY = env("SMS_PROVIDER_API_KEY", "") diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..cd6f80d --- /dev/null +++ b/config/urls.py @@ -0,0 +1,32 @@ +"""Root URL configuration for the Cetana backend.""" +from django.contrib import admin +from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from rest_framework.routers import DefaultRouter + +from apps.accounts.views import UserViewSet +from apps.core.views import SettingsView, TestOdooView +from apps.rbac.views import RoleViewSet + +router = DefaultRouter() +router.register("users", UserViewSet, basename="user") +router.register("roles", RoleViewSet, basename="role") + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/v1/auth/", include("apps.accounts.urls")), + path("api/v1/workflows/", include("apps.workflows.urls")), + path("api/v1/reservations/", include("apps.reservations.urls")), + path("api/v1/orders/", include("apps.orders.urls")), + path("api/v1/processing/", include("apps.processing.urls")), + path("api/v1/payments/", include("apps.payments.urls")), + path("api/v1/invoices/", include("apps.invoicing.urls")), + path("api/v1/audit/", include("apps.audit.urls")), + path("api/v1/reports/", include("apps.reports.urls")), + path("api/v1/live-monitoring/", include("apps.live_monitoring.urls")), + path("api/v1/settings/", SettingsView.as_view(), name="settings"), + path("api/v1/settings/test-odoo/", TestOdooView.as_view(), name="settings-test-odoo"), + path("api/v1/", include(router.urls)), + path("api/v1/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/v1/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/db.sqlite3.bak b/db.sqlite3.bak new file mode 100644 index 0000000000000000000000000000000000000000..11ec5398f416f0127d62ba3e22645d6129c4188f GIT binary patch literal 872448 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 G!~Y-u;&GM$ literal 0 HcmV?d00001 diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..055a5a3 --- /dev/null +++ b/dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/schema/')" || exit 1 + +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..689b3f6 --- /dev/null +++ b/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..a571cb7 --- /dev/null +++ b/readme.md @@ -0,0 +1,99 @@ +# Cetana — Backend + +Django + Django REST Framework backend for the **Cetana** configurable +pistachio-processing reservation & management platform. + +The single most important architectural property: the **workflow engine is +database-driven**. Processing steps, transitions, conditions, pricing rules and +notifications are configuration — never hard-coded business logic. + +## Architecture + +``` +apps/ +├── core/ # base models, error envelope, pagination +├── accounts/ # User (JWT auth), Customer profile (Odoo-keyed identity) +├── rbac/ # Role / Permission / UserRole + DRF permission classes +├── reservations/ # Slot availability, double-booking prevention +├── orders/ # ProcessingOrder + immutable snapshots + order numbers +├── workflows/ # Workflow / Version / Step / Transition + engine +├── processing/ # StepExecution state machine + rework +├── pricing/ # PricingRule + PricingEngine (backend-authoritative) +├── payments/ # Payment + gateway abstraction (idempotent) +├── invoicing/ # Invoice + Odoo sync state machine + Celery task +├── notifications/ # Templates, rules, SMS provider abstraction +├── reports/ # Dynamic report fields +├── live_monitoring/ # Cameras, streams, per-step authorization +├── audit/ # Immutable audit log +└── integrations/ # odoo/ client + services (mock-backed when unconfigured) +``` + +Key service/domain classes: + +- `apps.workflows.engine.WorkflowEngine` — version validation, publish, + condition evaluation, transition availability. +- `apps.pricing.engine.PricingEngine` — full price breakdown with snapshots. +- `apps.reservations.services.ReservationService` — availability + locking. +- `apps.processing.services.ProcessingService` — step state machine + rework. + +## Requirements + +- Python 3.11+ +- PostgreSQL (optional for local dev — SQLite is used when `DATABASE_URL` is unset) + +## Setup + +```bash +cd cetana-backend +python -m venv .venv +# Windows: .venv\Scripts\activate | Linux/macOS: source .venv/bin/activate +pip install -r requirements.txt + +cp .env.example .env # then edit secrets +python manage.py migrate +python manage.py seed_demo +python manage.py runserver +``` + +## Environment variables + +See `.env.example`. Important ones: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`, +`DATABASE_URL`, `ODOO_*`, `PAYMENT_GATEWAY_*`, `SMS_PROVIDER_*`, +`CELERY_BROKER_URL`, `BASE_CURRENCY`. Secrets are never committed. + +## Workers + +```bash +celery -A config worker -l info +celery -A config beat -l info +``` + +## Tests + +```bash +python manage.py test +``` + +## API + +- Auth: `POST /api/v1/auth/login/` (JWT), `POST /api/v1/auth/refresh/` +- Workflows: `/api/v1/workflows/`, `/api/v1/workflows/versions/`, + `/api/v1/workflows/steps/`, `/api/v1/workflows/transitions/`, + `POST /api/v1/workflows/{id}/publish/` +- Reservations: `/api/v1/reservations/`, `/api/v1/reservations/slots/` +- OpenAPI: `/api/v1/schema/`, Swagger UI: `/api/v1/docs/` + +## Docker + +From the repository root: + +```bash +docker compose up -d +``` + +## Production notes + +- Use PostgreSQL (`DATABASE_URL`), set a strong `SECRET_KEY`, `DEBUG=0`, + restrict `ALLOWED_HOSTS` and `CORS_ALLOWED_ORIGINS`. +- Run with gunicorn behind a reverse proxy; configure secure headers there. +- Keep Odoo / payment / SMS / camera credentials in environment variables. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..07be307 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +Django>=5.1,<6.0 +djangorestframework>=3.15,<4.0 +djangorestframework-simplejwt>=5.3,<6.0 +psycopg[binary]>=3.1,<4.0 +django-cors-headers>=4.3,<5.0 +django-filter>=24.2,<26.0 +drf-spectacular>=0.27,<1.0 +celery>=5.3,<6.0 +redis>=5.0,<6.0 +python-dotenv>=1.0,<2.0 +gunicorn>=21.2,<23.0 +reportlab>=4.2,<5.0