fix error in gitignore

This commit is contained in:
2026-09-24 12:32:59 +03:30
commit 86c117ee2a
165 changed files with 5963 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# core app
+10
View File
@@ -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")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
+35
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# core management commands
@@ -0,0 +1 @@
# commands
+176
View File
@@ -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'."))
+31
View File
@@ -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'],
},
),
]
View File
+44
View File
@@ -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
+7
View File
@@ -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
+30
View File
@@ -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}
+51
View File
@@ -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
+21
View File
@@ -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"], "••••••")
+62
View File
@@ -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)})