fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# apps package
|
||||
@@ -0,0 +1 @@
|
||||
# accounts app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"))
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# audit app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.audit"
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
# core app
|
||||
@@ -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")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.core"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# core management commands
|
||||
@@ -0,0 +1 @@
|
||||
# commands
|
||||
@@ -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'."))
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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"], "••••••")
|
||||
@@ -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)})
|
||||
@@ -0,0 +1 @@
|
||||
# integrations package
|
||||
@@ -0,0 +1 @@
|
||||
# odoo integration (service layer, not a Django app)
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# invoicing app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InvoicingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.invoicing"
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# live_monitoring app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LiveMonitoringConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.live_monitoring"
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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})
|
||||
@@ -0,0 +1 @@
|
||||
# notifications app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notifications"
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
# orders app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OrdersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.orders"
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# payments app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.payments"
|
||||
@@ -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()
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# pricing app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PricingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.pricing"
|
||||
@@ -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
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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})"
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
# processing app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProcessingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.processing"
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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})"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user