commit 86c117ee2a1491f44c10c5e1ac29840c9a2704e0 Author: Emad modaresi Date: Thu Sep 24 12:32:59 2026 +0330 fix error in gitignore 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 0000000..11ec539 Binary files /dev/null and b/db.sqlite3.bak differ 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