fix error in gitignore
This commit is contained in:
@@ -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)})
|
||||
Reference in New Issue
Block a user