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
+28
View File
@@ -0,0 +1,28 @@
# ---- Django ----
SECRET_KEY=change-me-in-production
DEBUG=1
ALLOWED_HOSTS=localhost,127.0.0.1
# ---- Database (PostgreSQL URL; empty => local SQLite for dev/tests) ----
DATABASE_URL=
# ---- Odoo integration ----
ODOO_URL=
ODOO_DATABASE=
ODOO_USERNAME=
ODOO_PASSWORD=
ODOO_API_KEY=
# ---- Payment gateway ----
PAYMENT_GATEWAY_PROVIDER=mock
PAYMENT_GATEWAY_MERCHANT_ID=
# ---- SMS provider ----
SMS_PROVIDER=mock
SMS_PROVIDER_API_KEY=
# ---- Celery ----
CELERY_BROKER_URL=redis://localhost:6379/0
# ---- Currency ----
BASE_CURRENCY=IRR
+220
View File
@@ -0,0 +1,220 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
*.lcov
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi/*
!.pixi/config.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule*
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
# can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer, you
# could uncomment the following to ignore the entire vscode folder
# .vscode/
# Temporary file for partial code execution
tempCodeRunnerFile.py
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="" />
<option name="settingsModule" value="" />
<option name="manageScript" value="" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.12 (cetana-backend)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
</component>
</module>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PublishConfigData" remoteFilesAllowedToDisappearOnAutoupload="false">
<serverData>
<paths name="root@atibin.info:22 password">
<serverdata>
<mappings>
<mapping local="$PROJECT_DIR$" web="/" />
</mappings>
</serverdata>
</paths>
</serverData>
</component>
</project>
+260
View File
@@ -0,0 +1,260 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyInterpreterInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="239">
<item index="0" class="java.lang.String" itemvalue="psycopg2-binary" />
<item index="1" class="java.lang.String" itemvalue="python-dotenv" />
<item index="2" class="java.lang.String" itemvalue="MarkupSafe" />
<item index="3" class="java.lang.String" itemvalue="requests" />
<item index="4" class="java.lang.String" itemvalue="drf-yasg" />
<item index="5" class="java.lang.String" itemvalue="coreschema" />
<item index="6" class="java.lang.String" itemvalue="sqlparse" />
<item index="7" class="java.lang.String" itemvalue="djangorestframework-simplejwt" />
<item index="8" class="java.lang.String" itemvalue="ruamel.yaml.clib" />
<item index="9" class="java.lang.String" itemvalue="certifi" />
<item index="10" class="java.lang.String" itemvalue="urllib3" />
<item index="11" class="java.lang.String" itemvalue="django-cors-headers" />
<item index="12" class="java.lang.String" itemvalue="ruamel.yaml" />
<item index="13" class="java.lang.String" itemvalue="asgiref" />
<item index="14" class="java.lang.String" itemvalue="packaging" />
<item index="15" class="java.lang.String" itemvalue="python-socketio" />
<item index="16" class="java.lang.String" itemvalue="channels-redis" />
<item index="17" class="java.lang.String" itemvalue="coreapi" />
<item index="18" class="java.lang.String" itemvalue="channels" />
<item index="19" class="java.lang.String" itemvalue="Django" />
<item index="20" class="java.lang.String" itemvalue="charset-normalizer" />
<item index="21" class="java.lang.String" itemvalue="itypes" />
<item index="22" class="java.lang.String" itemvalue="paho-mqtt" />
<item index="23" class="java.lang.String" itemvalue="aiohttp" />
<item index="24" class="java.lang.String" itemvalue="pytz" />
<item index="25" class="java.lang.String" itemvalue="idna" />
<item index="26" class="java.lang.String" itemvalue="uritemplate" />
<item index="27" class="java.lang.String" itemvalue="django-guardian" />
<item index="28" class="java.lang.String" itemvalue="djangorestframework" />
<item index="29" class="java.lang.String" itemvalue="daphne" />
<item index="30" class="java.lang.String" itemvalue="Jinja2" />
<item index="31" class="java.lang.String" itemvalue="inflection" />
<item index="32" class="java.lang.String" itemvalue="typing_extensions" />
<item index="33" class="java.lang.String" itemvalue="pandas" />
<item index="34" class="java.lang.String" itemvalue="tzlocal" />
<item index="35" class="java.lang.String" itemvalue="django-apscheduler" />
<item index="36" class="java.lang.String" itemvalue="six" />
<item index="37" class="java.lang.String" itemvalue="python-dateutil" />
<item index="38" class="java.lang.String" itemvalue="tzdata" />
<item index="39" class="java.lang.String" itemvalue="et-xmlfile" />
<item index="40" class="java.lang.String" itemvalue="numpy" />
<item index="41" class="java.lang.String" itemvalue="importlib-metadata" />
<item index="42" class="java.lang.String" itemvalue="APScheduler" />
<item index="43" class="java.lang.String" itemvalue="pytz-deprecation-shim" />
<item index="44" class="java.lang.String" itemvalue="django-widget-tweaks" />
<item index="45" class="java.lang.String" itemvalue="django-jalali-date" />
<item index="46" class="java.lang.String" itemvalue="django-filter" />
<item index="47" class="java.lang.String" itemvalue="backports.zoneinfo" />
<item index="48" class="java.lang.String" itemvalue="zipp" />
<item index="49" class="java.lang.String" itemvalue="oauthlib" />
<item index="50" class="java.lang.String" itemvalue="python-decouple" />
<item index="51" class="java.lang.String" itemvalue="django-jalali" />
<item index="52" class="java.lang.String" itemvalue="jdatetime" />
<item index="53" class="java.lang.String" itemvalue="Markdown" />
<item index="54" class="java.lang.String" itemvalue="openpyxl" />
<item index="55" class="java.lang.String" itemvalue="Pillow" />
<item index="56" class="java.lang.String" itemvalue="factory-boy" />
<item index="57" class="java.lang.String" itemvalue="Babel" />
<item index="58" class="java.lang.String" itemvalue="PyYAML" />
<item index="59" class="java.lang.String" itemvalue="pickleshare" />
<item index="60" class="java.lang.String" itemvalue="defusedxml" />
<item index="61" class="java.lang.String" itemvalue="django-nested-admin" />
<item index="62" class="java.lang.String" itemvalue="pycparser" />
<item index="63" class="java.lang.String" itemvalue="redis" />
<item index="64" class="java.lang.String" itemvalue="pyasn1-modules" />
<item index="65" class="java.lang.String" itemvalue="ipython-genutils" />
<item index="66" class="java.lang.String" itemvalue="Pygments" />
<item index="67" class="java.lang.String" itemvalue="mccabe" />
<item index="68" class="java.lang.String" itemvalue="bleach" />
<item index="69" class="java.lang.String" itemvalue="django-braces" />
<item index="70" class="java.lang.String" itemvalue="lxml" />
<item index="71" class="java.lang.String" itemvalue="soupsieve" />
<item index="72" class="java.lang.String" itemvalue="django-model-utils" />
<item index="73" class="java.lang.String" itemvalue="shortuuid" />
<item index="74" class="java.lang.String" itemvalue="PyPDF2" />
<item index="75" class="java.lang.String" itemvalue="jsonschema" />
<item index="76" class="java.lang.String" itemvalue="xlrd" />
<item index="77" class="java.lang.String" itemvalue="django-simple-history" />
<item index="78" class="java.lang.String" itemvalue="qtconsole" />
<item index="79" class="java.lang.String" itemvalue="terminado" />
<item index="80" class="java.lang.String" itemvalue="django-celery-email" />
<item index="81" class="java.lang.String" itemvalue="django-oauth-toolkit" />
<item index="82" class="java.lang.String" itemvalue="reportlab" />
<item index="83" class="java.lang.String" itemvalue="typing-extensions" />
<item index="84" class="java.lang.String" itemvalue="jupyter-client" />
<item index="85" class="java.lang.String" itemvalue="gunicorn" />
<item index="86" class="java.lang.String" itemvalue="jupyterlab-pygments" />
<item index="87" class="java.lang.String" itemvalue="click" />
<item index="88" class="java.lang.String" itemvalue="ipykernel" />
<item index="89" class="java.lang.String" itemvalue="nbconvert" />
<item index="90" class="java.lang.String" itemvalue="python3-openid" />
<item index="91" class="java.lang.String" itemvalue="zeep" />
<item index="92" class="java.lang.String" itemvalue="attrs" />
<item index="93" class="java.lang.String" itemvalue="psutil" />
<item index="94" class="java.lang.String" itemvalue="python-bidi" />
<item index="95" class="java.lang.String" itemvalue="simplejson" />
<item index="96" class="java.lang.String" itemvalue="jedi" />
<item index="97" class="java.lang.String" itemvalue="regex" />
<item index="98" class="java.lang.String" itemvalue="matplotlib" />
<item index="99" class="java.lang.String" itemvalue="PyJWT" />
<item index="100" class="java.lang.String" itemvalue="rsa" />
<item index="101" class="java.lang.String" itemvalue="decorator" />
<item index="102" class="java.lang.String" itemvalue="django-environ" />
<item index="103" class="java.lang.String" itemvalue="isodate" />
<item index="104" class="java.lang.String" itemvalue="pkgutil-resolve-name" />
<item index="105" class="java.lang.String" itemvalue="cffi" />
<item index="106" class="java.lang.String" itemvalue="pandocfilters" />
<item index="107" class="java.lang.String" itemvalue="pycodestyle" />
<item index="108" class="java.lang.String" itemvalue="django-faicon" />
<item index="109" class="java.lang.String" itemvalue="pyasn1" />
<item index="110" class="java.lang.String" itemvalue="rest-pandas" />
<item index="111" class="java.lang.String" itemvalue="pyrsistent" />
<item index="112" class="java.lang.String" itemvalue="XlsxWriter" />
<item index="113" class="java.lang.String" itemvalue="celery" />
<item index="114" class="java.lang.String" itemvalue="jupyter" />
<item index="115" class="java.lang.String" itemvalue="nest-asyncio" />
<item index="116" class="java.lang.String" itemvalue="prompt-toolkit" />
<item index="117" class="java.lang.String" itemvalue="cached-property" />
<item index="118" class="java.lang.String" itemvalue="ipywidgets" />
<item index="119" class="java.lang.String" itemvalue="django-3-jet" />
<item index="120" class="java.lang.String" itemvalue="tornado" />
<item index="121" class="java.lang.String" itemvalue="phonenumbers" />
<item index="122" class="java.lang.String" itemvalue="amqp" />
<item index="123" class="java.lang.String" itemvalue="django-advanced-filters" />
<item index="124" class="java.lang.String" itemvalue="django-taggit-autosuggest" />
<item index="125" class="java.lang.String" itemvalue="Send2Trash" />
<item index="126" class="java.lang.String" itemvalue="django-admin-numeric-filter" />
<item index="127" class="java.lang.String" itemvalue="toml" />
<item index="128" class="java.lang.String" itemvalue="social-auth-core" />
<item index="129" class="java.lang.String" itemvalue="mistune" />
<item index="130" class="java.lang.String" itemvalue="django-fontawesome-5" />
<item index="131" class="java.lang.String" itemvalue="elastic-apm" />
<item index="132" class="java.lang.String" itemvalue="importlib-resources" />
<item index="133" class="java.lang.String" itemvalue="django-admin-rangefilter-jalali" />
<item index="134" class="java.lang.String" itemvalue="python-monkey-business" />
<item index="135" class="java.lang.String" itemvalue="social-auth-app-django" />
<item index="136" class="java.lang.String" itemvalue="future" />
<item index="137" class="java.lang.String" itemvalue="django-redis" />
<item index="138" class="java.lang.String" itemvalue="jupyter-console" />
<item index="139" class="java.lang.String" itemvalue="cachetools" />
<item index="140" class="java.lang.String" itemvalue="debugpy" />
<item index="141" class="java.lang.String" itemvalue="argon2-cffi" />
<item index="142" class="java.lang.String" itemvalue="isort" />
<item index="143" class="java.lang.String" itemvalue="click-repl" />
<item index="144" class="java.lang.String" itemvalue="django-admin-rangefilter" />
<item index="145" class="java.lang.String" itemvalue="webencodings" />
<item index="146" class="java.lang.String" itemvalue="requests-toolbelt" />
<item index="147" class="java.lang.String" itemvalue="mysqlclient" />
<item index="148" class="java.lang.String" itemvalue="traitlets" />
<item index="149" class="java.lang.String" itemvalue="html5lib" />
<item index="150" class="java.lang.String" itemvalue="typed-ast" />
<item index="151" class="java.lang.String" itemvalue="nbclient" />
<item index="152" class="java.lang.String" itemvalue="QtPy" />
<item index="153" class="java.lang.String" itemvalue="astroid" />
<item index="154" class="java.lang.String" itemvalue="cycler" />
<item index="155" class="java.lang.String" itemvalue="kombu" />
<item index="156" class="java.lang.String" itemvalue="tinycss2" />
<item index="157" class="java.lang.String" itemvalue="django-extensions" />
<item index="158" class="java.lang.String" itemvalue="appdirs" />
<item index="159" class="java.lang.String" itemvalue="Faker" />
<item index="160" class="java.lang.String" itemvalue="jupyterlab-widgets" />
<item index="161" class="java.lang.String" itemvalue="pyzmq" />
<item index="162" class="java.lang.String" itemvalue="zibal" />
<item index="163" class="java.lang.String" itemvalue="entrypoints" />
<item index="164" class="java.lang.String" itemvalue="pyparsing" />
<item index="165" class="java.lang.String" itemvalue="vine" />
<item index="166" class="java.lang.String" itemvalue="django-contrib-comments" />
<item index="167" class="java.lang.String" itemvalue="notebook" />
<item index="168" class="java.lang.String" itemvalue="beautifulsoup4" />
<item index="169" class="java.lang.String" itemvalue="django-treebeard" />
<item index="170" class="java.lang.String" itemvalue="django-crispy-forms" />
<item index="171" class="java.lang.String" itemvalue="djangorestframework-jwt" />
<item index="172" class="java.lang.String" itemvalue="tablib" />
<item index="173" class="java.lang.String" itemvalue="django-import-export" />
<item index="174" class="java.lang.String" itemvalue="wrapt" />
<item index="175" class="java.lang.String" itemvalue="cryptography" />
<item index="176" class="java.lang.String" itemvalue="kiwisolver" />
<item index="177" class="java.lang.String" itemvalue="django-appconf" />
<item index="178" class="java.lang.String" itemvalue="django-jsonfield" />
<item index="179" class="java.lang.String" itemvalue="Khayyam" />
<item index="180" class="java.lang.String" itemvalue="odfpy" />
<item index="181" class="java.lang.String" itemvalue="django-admin-search" />
<item index="182" class="java.lang.String" itemvalue="backcall" />
<item index="183" class="java.lang.String" itemvalue="widgetsnbextension" />
<item index="184" class="java.lang.String" itemvalue="argon2-cffi-bindings" />
<item index="185" class="java.lang.String" itemvalue="django-taggit" />
<item index="186" class="java.lang.String" itemvalue="pathspec" />
<item index="187" class="java.lang.String" itemvalue="jupyter-core" />
<item index="188" class="java.lang.String" itemvalue="pydot" />
<item index="189" class="java.lang.String" itemvalue="diff-match-patch" />
<item index="190" class="java.lang.String" itemvalue="django-ckeditor" />
<item index="191" class="java.lang.String" itemvalue="django-daterangefilter" />
<item index="192" class="java.lang.String" itemvalue="django-reverse-admin" />
<item index="193" class="java.lang.String" itemvalue="matplotlib-inline" />
<item index="194" class="java.lang.String" itemvalue="tblib" />
<item index="195" class="java.lang.String" itemvalue="mypy-extensions" />
<item index="196" class="java.lang.String" itemvalue="xhtml2pdf" />
<item index="197" class="java.lang.String" itemvalue="wcwidth" />
<item index="198" class="java.lang.String" itemvalue="drf-writable-nested" />
<item index="199" class="java.lang.String" itemvalue="click-didyoumean" />
<item index="200" class="java.lang.String" itemvalue="flake8" />
<item index="201" class="java.lang.String" itemvalue="requests-oauthlib" />
<item index="202" class="java.lang.String" itemvalue="django-phonenumber-field" />
<item index="203" class="java.lang.String" itemvalue="billiard" />
<item index="204" class="java.lang.String" itemvalue="coverage" />
<item index="205" class="java.lang.String" itemvalue="pyflakes" />
<item index="206" class="java.lang.String" itemvalue="parso" />
<item index="207" class="java.lang.String" itemvalue="text-unidecode" />
<item index="208" class="java.lang.String" itemvalue="nbformat" />
<item index="209" class="java.lang.String" itemvalue="r-django-jet" />
<item index="210" class="java.lang.String" itemvalue="ipython" />
<item index="211" class="java.lang.String" itemvalue="xlwt" />
<item index="212" class="java.lang.String" itemvalue="django-tags-input" />
<item index="213" class="java.lang.String" itemvalue="MarkupPy" />
<item index="214" class="java.lang.String" itemvalue="arabic-reshaper" />
<item index="215" class="java.lang.String" itemvalue="fastjsonschema" />
<item index="216" class="java.lang.String" itemvalue="logilab-common" />
<item index="217" class="java.lang.String" itemvalue="prometheus-client" />
<item index="218" class="java.lang.String" itemvalue="chardet" />
<item index="219" class="java.lang.String" itemvalue="lazy-object-proxy" />
<item index="220" class="java.lang.String" itemvalue="colorama" />
<item index="221" class="java.lang.String" itemvalue="django-js-asset" />
<item index="222" class="java.lang.String" itemvalue="click-plugins" />
<item index="223" class="java.lang.String" itemvalue="kavenegar" />
<item index="224" class="java.lang.String" itemvalue="google-auth" />
<item index="225" class="java.lang.String" itemvalue="pyyaml" />
<item index="226" class="java.lang.String" itemvalue="django-autocomplete-light" />
<item index="227" class="java.lang.String" itemvalue="django-pandas" />
<item index="228" class="java.lang.String" itemvalue="tqdm" />
<item index="229" class="java.lang.String" itemvalue="sentry-sdk" />
<item index="230" class="java.lang.String" itemvalue="drf-dx-datagrid" />
<item index="231" class="java.lang.String" itemvalue="httpx" />
<item index="232" class="java.lang.String" itemvalue="pydantic-settings" />
<item index="233" class="java.lang.String" itemvalue="fastapi" />
<item index="234" class="java.lang.String" itemvalue="pydantic" />
<item index="235" class="java.lang.String" itemvalue="alembic" />
<item index="236" class="java.lang.String" itemvalue="asyncpg" />
<item index="237" class="java.lang.String" itemvalue="SQLAlchemy" />
<item index="238" class="java.lang.String" itemvalue="uvicorn" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N803" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (cetana-backend)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (cetana-backend)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/cetana-backend.iml" filepath="$PROJECT_DIR$/.idea/cetana-backend.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+1
View File
@@ -0,0 +1 @@
# apps package
+1
View File
@@ -0,0 +1 @@
# accounts app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
+63
View File
@@ -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,
},
),
]
+30
View File
@@ -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)
+55
View File
@@ -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"]
+56
View File
@@ -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"))
+12
View File
@@ -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"),
]
+106
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# audit app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuditConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.audit"
+36
View File
@@ -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'],
},
),
]
View File
+23
View File
@@ -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}"
+14
View File
@@ -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",
]
+22
View File
@@ -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
+8
View File
@@ -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
+12
View File
@@ -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"}
+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)})
+1
View File
@@ -0,0 +1 @@
# integrations package
+1
View File
@@ -0,0 +1 @@
# odoo integration (service layer, not a Django app)
+115
View File
@@ -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"),
)
+26
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# invoicing app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class InvoicingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.invoicing"
+33
View File
@@ -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'],
},
),
]
+22
View File
@@ -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'),
),
]
+28
View File
@@ -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
+13
View File
@@ -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",
]
+30
View File
@@ -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)
+8
View File
@@ -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
+52
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# live_monitoring app
+6
View File
@@ -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,
},
),
]
+30
View File
@@ -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")
+10
View File
@@ -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"]
+55
View File
@@ -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
+26
View File
@@ -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))
+12
View File
@@ -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,
]
+48
View File
@@ -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})
+1
View File
@@ -0,0 +1 @@
# notifications app
+6
View File
@@ -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,
},
),
]
+36
View File
@@ -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)
+61
View File
@@ -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)
+39
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
# orders app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.orders"
+43
View File
@@ -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'],
},
),
]
View File
+62
View File
@@ -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)
+31
View File
@@ -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)
+81
View File
@@ -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
+72
View File
@@ -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")
+8
View File
@@ -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
+101
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# payments app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.payments"
+36
View File
@@ -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()
+39
View File
@@ -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'),
),
]
+41
View File
@@ -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}"
+13
View File
@@ -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",
]
+39
View File
@@ -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",
)
+8
View File
@@ -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
+82
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# pricing app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PricingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.pricing"
+117
View File
@@ -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
+35
View File
@@ -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'],
},
),
]

Some files were not shown because too many files have changed in this diff Show More