fix error in gitignore
This commit is contained in:
@@ -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
@@ -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
|
||||
Generated
+8
@@ -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
|
||||
Generated
+23
@@ -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="<map/>" />
|
||||
<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>
|
||||
Generated
+14
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+7
@@ -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>
|
||||
Generated
+8
@@ -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
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
# apps package
|
||||
@@ -0,0 +1 @@
|
||||
# accounts app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('phone', models.CharField(blank=True, max_length=32)),
|
||||
('odoo_partner_id', models.CharField(blank=True, db_index=True, max_length=64)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['username'],
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Customer',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('customer_code', models.CharField(blank=True, max_length=64, null=True, unique=True)),
|
||||
('company_name', models.CharField(blank=True, max_length=200)),
|
||||
('category', models.CharField(blank=True, db_index=True, max_length=64)),
|
||||
('odoo_partner_id', models.CharField(blank=True, db_index=True, max_length=64)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class User(AbstractUser, UUIDModel):
|
||||
"""Application user. Identity may be backed by Odoo via `odoo_partner_id`."""
|
||||
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
odoo_partner_id = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["username"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.username or self.email or str(self.id)
|
||||
|
||||
|
||||
class Customer(UUIDModel, TimeStampedModel):
|
||||
"""Customer profile keyed to an external (Odoo) identity where available."""
|
||||
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="customer")
|
||||
customer_code = models.CharField(max_length=64, unique=True, blank=True, null=True)
|
||||
company_name = models.CharField(max_length=200, blank=True)
|
||||
category = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
odoo_partner_id = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.company_name or str(self.user)
|
||||
@@ -0,0 +1,55 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import Customer, User
|
||||
from apps.rbac.models import Role
|
||||
|
||||
|
||||
class RoleBriefSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ["id", "name", "slug"]
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
roles = serializers.SerializerMethodField()
|
||||
permissions = serializers.SerializerMethodField()
|
||||
role_ids = serializers.ListField(child=serializers.UUIDField(), write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id", "username", "email", "first_name", "last_name", "phone",
|
||||
"is_active", "is_superuser", "roles", "permissions", "role_ids",
|
||||
]
|
||||
read_only_fields = ["is_superuser"]
|
||||
|
||||
def get_roles(self, obj):
|
||||
return RoleBriefSerializer(
|
||||
[ur.role for ur in obj.user_roles.select_related("role").all()], many=True
|
||||
).data
|
||||
|
||||
def get_permissions(self, obj):
|
||||
if obj.is_superuser:
|
||||
return ["*"]
|
||||
from apps.rbac.models import Permission
|
||||
|
||||
return list(
|
||||
Permission.objects.filter(roles__user_roles__user=obj).values_list("code", flat=True)
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
# role_ids is a write-only helper; roles are synced by the viewset.
|
||||
validated_data.pop("role_ids", None)
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data.pop("role_ids", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class CustomerSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ["id", "user", "customer_code", "company_name", "category", "odoo_partner_id"]
|
||||
@@ -0,0 +1,56 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.rbac.models import Permission, Role
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserManagementApiTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_superuser(username="root", password="***")
|
||||
self.client.force_authenticate(self.admin)
|
||||
self.role = Role.objects.create(name="Operator", slug="operator-t")
|
||||
self.role.permissions.add(Permission.objects.create(code="processing.start"))
|
||||
|
||||
def test_create_user_with_role_ids(self):
|
||||
response = self.client.post(
|
||||
"/api/v1/users/",
|
||||
{
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"phone": "09120000000",
|
||||
"password": "secret123",
|
||||
"role_ids": [str(self.role.id)],
|
||||
"is_active": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
user = User.objects.get(username="newuser")
|
||||
self.assertTrue(user.check_password("secret123"))
|
||||
self.assertTrue(user.user_roles.filter(role=self.role).exists())
|
||||
|
||||
def test_update_user_role_ids(self):
|
||||
user = User.objects.create_user(username="someone", password="***")
|
||||
response = self.client.patch(
|
||||
f"/api/v1/users/{user.id}/", {"role_ids": [str(self.role.id)]}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertTrue(user.user_roles.filter(role=self.role).exists())
|
||||
|
||||
def test_ban_and_unban(self):
|
||||
user = User.objects.create_user(username="toban", password="***")
|
||||
response = self.client.patch(f"/api/v1/users/{user.id}/", {"is_active": False}, format="json")
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
|
||||
def test_reset_password(self):
|
||||
user = User.objects.create_user(username="pwuser", password="***")
|
||||
response = self.client.post(
|
||||
f"/api/v1/users/{user.id}/set-password/", {"new_password": "brandnew1"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
user.refresh_from_db()
|
||||
self.assertTrue(user.check_password("brandnew1"))
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
|
||||
from apps.accounts.views import ChangePasswordView, CustomerProfileView, MeView
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||
path("me/", MeView.as_view(), name="me"),
|
||||
path("me/change-password/", ChangePasswordView.as_view(), name="change_password"),
|
||||
path("me/customer/", CustomerProfileView.as_view(), name="customer_profile"),
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
from rest_framework import decorators, generics, permissions, response, status, viewsets
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.accounts.models import Customer, User
|
||||
from apps.accounts.serializers import CustomerSerializer, UserSerializer
|
||||
from apps.core.errors import error_response
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
queryset = User.objects.prefetch_related("user_roles__role").all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "user.view",
|
||||
"retrieve": "user.view",
|
||||
"create": "user.manage",
|
||||
"update": "user.manage",
|
||||
"partial_update": "user.manage",
|
||||
"destroy": "user.manage",
|
||||
"set_password": "user.manage",
|
||||
}
|
||||
|
||||
def perform_create(self, serializer):
|
||||
password = self.request.data.get("password") or "changeme123"
|
||||
user = serializer.save()
|
||||
user.set_password(password)
|
||||
user.save(update_fields=["password"])
|
||||
self._sync_roles(user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
user = serializer.save()
|
||||
self._sync_roles(user)
|
||||
|
||||
def _sync_roles(self, user):
|
||||
role_ids = self.request.data.get("role_ids")
|
||||
if role_ids is None:
|
||||
return
|
||||
from apps.rbac.models import Role, UserRole
|
||||
|
||||
user.user_roles.all().delete()
|
||||
for role_id in role_ids:
|
||||
role = Role.objects.filter(id=role_id).first()
|
||||
if role is not None:
|
||||
UserRole.objects.create(user=user, role=role)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="set-password")
|
||||
def set_password(self, request, pk=None):
|
||||
user = self.get_object()
|
||||
new_password = request.data.get("new_password", "")
|
||||
if len(new_password) < 6:
|
||||
return Response(
|
||||
error_response("WEAK_PASSWORD", "Password must be at least 6 characters."),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user.set_password(new_password)
|
||||
user.save(update_fields=["password"])
|
||||
return Response({"success": True})
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
"""View and update the authenticated user's own profile."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response(UserSerializer(request.user).data)
|
||||
|
||||
def patch(self, request):
|
||||
user = request.user
|
||||
for field in ("first_name", "last_name", "email", "phone"):
|
||||
if field in request.data:
|
||||
setattr(user, field, request.data[field])
|
||||
user.save()
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
|
||||
class ChangePasswordView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
old_password = request.data.get("old_password", "")
|
||||
new_password = request.data.get("new_password", "")
|
||||
if not request.user.check_password(old_password):
|
||||
return Response(
|
||||
error_response("INVALID_PASSWORD", "Current password is incorrect."),
|
||||
status=400,
|
||||
)
|
||||
if len(new_password) < 6:
|
||||
return Response(
|
||||
error_response("WEAK_PASSWORD", "New password must be at least 6 characters."),
|
||||
status=400,
|
||||
)
|
||||
request.user.set_password(new_password)
|
||||
request.user.save(update_fields=["password"])
|
||||
return Response({"success": True})
|
||||
|
||||
|
||||
class CustomerProfileView(generics.RetrieveAPIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = CustomerSerializer
|
||||
|
||||
def get_object(self):
|
||||
customer, _ = Customer.objects.get_or_create(user=self.request.user)
|
||||
return customer
|
||||
@@ -0,0 +1 @@
|
||||
# audit app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.audit"
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AuditEvent',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('action', models.CharField(db_index=True, max_length=120)),
|
||||
('object_type', models.CharField(db_index=True, max_length=120)),
|
||||
('object_id', models.CharField(blank=True, db_index=True, max_length=120)),
|
||||
('ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('before', models.JSONField(blank=True, default=dict)),
|
||||
('after', models.JSONField(blank=True, default=dict)),
|
||||
('metadata', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import UUIDModel
|
||||
|
||||
|
||||
class AuditEvent(UUIDModel):
|
||||
"""Immutable audit log entry. Records are never updated or deleted."""
|
||||
|
||||
actor = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
action = models.CharField(max_length=120, db_index=True)
|
||||
object_type = models.CharField(max_length=120, db_index=True)
|
||||
object_id = models.CharField(max_length=120, blank=True, db_index=True)
|
||||
ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
before = models.JSONField(default=dict, blank=True)
|
||||
after = models.JSONField(default=dict, blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.action} {self.object_type}:{self.object_id}"
|
||||
@@ -0,0 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.audit.models import AuditEvent
|
||||
|
||||
|
||||
class AuditEventSerializer(serializers.ModelSerializer):
|
||||
actor_name = serializers.CharField(source="actor.username", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AuditEvent
|
||||
fields = [
|
||||
"id", "actor", "actor_name", "action", "object_type", "object_id",
|
||||
"ip", "before", "after", "metadata", "created_at",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from apps.audit.models import AuditEvent
|
||||
|
||||
|
||||
def record(actor, action, object_type, object_id="", before=None, after=None, metadata=None, request=None):
|
||||
"""Write an immutable audit event. Never raises to avoid breaking business flows."""
|
||||
ip = None
|
||||
if request is not None:
|
||||
xff = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
ip = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR"))
|
||||
try:
|
||||
AuditEvent.objects.create(
|
||||
actor=actor if actor and actor.is_authenticated else None,
|
||||
action=action,
|
||||
object_type=object_type,
|
||||
object_id=str(object_id),
|
||||
ip=ip,
|
||||
before=before or {},
|
||||
after=after or {},
|
||||
metadata=metadata or {},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.audit.views import AuditEventViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", AuditEventViewSet, basename="audit-event")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,12 @@
|
||||
from rest_framework import mixins, viewsets
|
||||
|
||||
from apps.audit.models import AuditEvent
|
||||
from apps.audit.serializers import AuditEventSerializer
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class AuditEventViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
queryset = AuditEvent.objects.select_related("actor").all()
|
||||
serializer_class = AuditEventSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {"list": "user.view", "retrieve": "user.view"}
|
||||
@@ -0,0 +1 @@
|
||||
# core app
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.core.models import SystemSetting
|
||||
|
||||
|
||||
@admin.register(SystemSetting)
|
||||
class SystemSettingAdmin(admin.ModelAdmin):
|
||||
list_display = ("key", "category", "is_secret", "updated_at")
|
||||
search_fields = ("key", "category")
|
||||
list_filter = ("category", "is_secret")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.core"
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Consistent API error handling with stable error codes."""
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""Raised by service/domain layers; converted to a consistent response."""
|
||||
|
||||
def __init__(self, code: str, message: str, details=None, status: int = 400):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
self.status = status
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def error_response(code: str, message: str, details=None) -> dict:
|
||||
return {"success": False, "error": {"code": code, "message": message, "details": details or {}}}
|
||||
|
||||
|
||||
def api_exception_handler(exc, context):
|
||||
"""DRF exception handler that emits the stable error envelope."""
|
||||
from rest_framework.views import exception_handler
|
||||
from rest_framework.response import Response
|
||||
|
||||
if isinstance(exc, ApiError):
|
||||
return Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
|
||||
response = exception_handler(exc, context)
|
||||
if response is not None:
|
||||
if isinstance(response.data, dict) and "detail" in response.data and len(response.data) == 1:
|
||||
return Response(
|
||||
error_response("API_ERROR", str(response.data["detail"])),
|
||||
status=response.status_code,
|
||||
)
|
||||
return Response(error_response("API_ERROR", "Request failed", {"errors": response.data}), status=response.status_code)
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
# core management commands
|
||||
@@ -0,0 +1 @@
|
||||
# commands
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Seed demo data: roles, users, facilities/schedules/slots, a complete default
|
||||
workflow (with approval + payment + invoice + Odoo-sync steps), pricing rules,
|
||||
notification templates and a report definition.
|
||||
|
||||
The workflow seeded here is demo DATA ONLY — the engine itself remains fully
|
||||
dynamic and knows nothing about pistachio processing.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import Customer
|
||||
from apps.notifications.models import NotificationTemplate
|
||||
from apps.pricing.models import PricingRule
|
||||
from apps.rbac.models import Permission, Role
|
||||
from apps.reports.models import ReportDefinition, ReportField
|
||||
from apps.reservations.models import Facility, FacilitySchedule
|
||||
from apps.reservations.slot_service import SlotService
|
||||
from apps.workflows.engine import WorkflowEngine
|
||||
from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
PERMISSIONS = [
|
||||
"reservation.view", "reservation.create", "reservation.update", "reservation.cancel", "reservation.approve",
|
||||
"slot.manage",
|
||||
"weighing.view", "weighing.create", "weighing.update",
|
||||
"workflow.view", "workflow.create", "workflow.update", "workflow.publish",
|
||||
"processing.view", "processing.start", "processing.pause", "processing.complete",
|
||||
"invoice.view", "invoice.create", "invoice.cancel",
|
||||
"report.view", "report.create",
|
||||
"user.view", "user.manage", "role.view", "role.manage",
|
||||
"settings.manage",
|
||||
]
|
||||
|
||||
ROLE_SPECS = {
|
||||
"administrator": ("Administrator", PERMISSIONS),
|
||||
"facility_manager": ("Facility Manager", [
|
||||
"reservation.view", "reservation.update", "reservation.cancel", "reservation.approve", "slot.manage",
|
||||
"weighing.view", "weighing.create", "weighing.update",
|
||||
"processing.view", "processing.start", "processing.complete",
|
||||
"invoice.view", "report.view", "report.create", "user.view",
|
||||
]),
|
||||
"operator": ("Operator", [
|
||||
"reservation.view", "weighing.view", "weighing.create",
|
||||
"processing.view", "processing.start", "processing.pause", "processing.complete",
|
||||
]),
|
||||
"customer": ("Customer", ["reservation.view", "reservation.create", "processing.view", "report.view"]),
|
||||
"accountant": ("Accountant", ["invoice.view", "invoice.create", "invoice.cancel", "report.view"]),
|
||||
"quality_controller": ("Quality Controller", ["processing.view", "processing.complete", "report.create"]),
|
||||
}
|
||||
|
||||
# (key, name, flags)
|
||||
STEP_DEFS = [
|
||||
("reservation", "Reservation", {"is_start": True}),
|
||||
("approval", "Approval", {}),
|
||||
("weighing", "Weighing", {"required": True}),
|
||||
("processing_selection", "Processing Selection", {"required": True}),
|
||||
("customer_confirmation", "Customer Confirmation", {"requires_customer_confirmation": True}),
|
||||
("payment", "Payment", {"requires_payment": True}),
|
||||
("sorting", "Sorting", {}),
|
||||
("washing", "Washing", {}),
|
||||
("drying", "Drying", {}),
|
||||
("quality_control", "Quality Control", {}),
|
||||
("packaging", "Packaging", {}),
|
||||
("final_weighing", "Final Weighing", {}),
|
||||
("final_report", "Final Report", {}),
|
||||
("invoice", "Invoice", {"triggers_invoice": True, "triggers_odoo_sync": True}),
|
||||
("completed", "Completed", {"is_terminal": True}),
|
||||
]
|
||||
|
||||
REPORT_FIELDS = [
|
||||
("final_moisture", "Final Moisture", "decimal"),
|
||||
("quality_grade", "Quality Grade", "choice"),
|
||||
("broken_percentage", "Broken Percentage", "percentage"),
|
||||
("category_a_weight", "Category A Weight", "weight"),
|
||||
("category_b_weight", "Category B Weight", "weight"),
|
||||
("category_c_weight", "Category C Weight", "weight"),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed demo roles, users, facilities, a default workflow, pricing, templates and reports."
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
perms = {}
|
||||
for code in PERMISSIONS:
|
||||
perms[code], _ = Permission.objects.get_or_create(code=code, defaults={"description": code})
|
||||
|
||||
roles = {}
|
||||
for slug, (name, codes) in ROLE_SPECS.items():
|
||||
role, _ = Role.objects.get_or_create(slug=slug, defaults={"name": name})
|
||||
role.permissions.set([perms[c] for c in codes])
|
||||
roles[slug] = role
|
||||
|
||||
def make_user(username, role_slug, **kw):
|
||||
user, _ = User.objects.get_or_create(username=username, defaults=kw)
|
||||
user.set_password("demo1234")
|
||||
user.save()
|
||||
if not user.user_roles.filter(role=roles[role_slug]).exists():
|
||||
user.user_roles.create(role=roles[role_slug])
|
||||
return user
|
||||
|
||||
make_user("admin", "administrator", is_staff=True, is_superuser=True, email="admin@example.com")
|
||||
make_user("manager", "facility_manager")
|
||||
make_user("operator", "operator")
|
||||
make_user("accountant", "accountant")
|
||||
make_user("qc", "quality_controller")
|
||||
cust_user = make_user("customer", "customer", email="customer@example.com", phone="09120000000")
|
||||
Customer.objects.get_or_create(user=cust_user, defaults={"company_name": "Demo Pistachio Co."})
|
||||
|
||||
# Facility + schedule + slots
|
||||
facility, _ = Facility.objects.get_or_create(name="Main Facility")
|
||||
tomorrow = timezone.localdate() + timezone.timedelta(days=1)
|
||||
FacilitySchedule.objects.update_or_create(
|
||||
facility=facility, date=tomorrow, defaults={"is_open": True, "capacity": 3}
|
||||
)
|
||||
SlotService().generate_slots(facility, tomorrow)
|
||||
|
||||
# Default workflow
|
||||
workflow, _ = Workflow.objects.get_or_create(
|
||||
slug="pistachio-processing",
|
||||
defaults={"name": "Pistachio Processing", "description": "Default configurable workflow"},
|
||||
)
|
||||
version = WorkflowVersion.objects.create(workflow=workflow, version=workflow.versions.count() + 1)
|
||||
steps = {}
|
||||
for order, (key, name, flags) in enumerate(STEP_DEFS):
|
||||
steps[key] = WorkflowStep.objects.create(
|
||||
version=version, key=key, name=name, order=order,
|
||||
position_x=80 + (order % 5) * 200, position_y=40 + (order // 5) * 160,
|
||||
**flags,
|
||||
)
|
||||
chain = [d[0] for d in STEP_DEFS]
|
||||
for a, b in zip(chain, chain[1:]):
|
||||
WorkflowTransition.objects.create(version=version, from_step=steps[a], to_step=steps[b])
|
||||
|
||||
# Pricing
|
||||
PricingRule.objects.get_or_create(step=steps["washing"], name="Washing per kg", rule_type="per_kg", amount=Decimal("5000"))
|
||||
PricingRule.objects.get_or_create(step=steps["drying"], name="Drying per kg", rule_type="per_kg", amount=Decimal("8000"))
|
||||
PricingRule.objects.get_or_create(step=steps["sorting"], name="Sorting per kg", rule_type="per_kg", amount=Decimal("12000"))
|
||||
|
||||
# Report definition
|
||||
definition, _ = ReportDefinition.objects.get_or_create(
|
||||
workflow=workflow, name="Default Processing Report", defaults={"active": True}
|
||||
)
|
||||
for order, (key, label, field_type) in enumerate(REPORT_FIELDS):
|
||||
ReportField.objects.get_or_create(
|
||||
definition=definition, key=key,
|
||||
defaults={"label": label, "field_type": field_type, "order": order},
|
||||
)
|
||||
|
||||
# Notification templates
|
||||
NotificationTemplate.objects.get_or_create(
|
||||
event="reservation.approved", channel="sms",
|
||||
defaults={"body": "Cetana: reservation {{reservation_title}} on {{slot_date}} at {{slot_time}} is confirmed."},
|
||||
)
|
||||
NotificationTemplate.objects.get_or_create(
|
||||
event="order.completed", channel="sms",
|
||||
defaults={"body": "Cetana: order {{order_number}} is complete. Final report is ready."},
|
||||
)
|
||||
|
||||
try:
|
||||
WorkflowEngine().publish(workflow, version)
|
||||
except Exception as exc: # pragma: no cover
|
||||
self.stdout.write(self.style.WARNING(f"Workflow publish skipped: {exc}"))
|
||||
|
||||
# Pre-fill the Odoo server URL (credentials are entered in Settings).
|
||||
from apps.core.models import SystemSetting
|
||||
|
||||
SystemSetting.objects.get_or_create(key="odoo.url", defaults={"value": "https://rayayazd.ir"})
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seed data created. Logins use password 'demo1234'."))
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 10:31
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemSetting',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('key', models.CharField(max_length=200, unique=True)),
|
||||
('value', models.JSONField(blank=True, default=dict)),
|
||||
('category', models.CharField(blank=True, db_index=True, max_length=100)),
|
||||
('is_secret', models.BooleanField(default=False)),
|
||||
('description', models.CharField(blank=True, max_length=255)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['key'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class UUIDModel(models.Model):
|
||||
"""Abstract model with a UUID primary key."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
"""Abstract model with created/updated timestamps."""
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class SystemSetting(UUIDModel, TimeStampedModel):
|
||||
"""Generic, dynamic runtime configuration (Odoo, SMS, payment, ...).
|
||||
|
||||
Settings are keyed by dotted path (e.g. ``odoo.url``) and stored as JSON
|
||||
values. The schema that drives the settings panel lives in
|
||||
``apps.core.settings_registry`` — adding a provider only requires adding
|
||||
entries there, not schema changes.
|
||||
"""
|
||||
|
||||
key = models.CharField(max_length=200, unique=True)
|
||||
value = models.JSONField(default=dict, blank=True)
|
||||
category = models.CharField(max_length=100, db_index=True, blank=True)
|
||||
is_secret = models.BooleanField(default=False)
|
||||
description = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["key"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.key
|
||||
@@ -0,0 +1,7 @@
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class StandardPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 200
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Settings schema registry.
|
||||
|
||||
The single source of truth for which runtime settings exist. The settings panel
|
||||
is rendered from this list, so adding a new integration/provider only requires
|
||||
adding entries here — no frontend or model changes.
|
||||
|
||||
Field types: ``string``, ``secret``, ``boolean``, ``choice``, ``number``.
|
||||
"""
|
||||
SETTINGS_SCHEMA = [
|
||||
# --- Odoo integration ---
|
||||
{"key": "odoo.enabled", "label": "Enable Odoo integration", "type": "boolean", "category": "Odoo", "default": False},
|
||||
{"key": "odoo.url", "label": "Odoo URL", "type": "string", "category": "Odoo", "default": "", "placeholder": "https://odoo.example.com"},
|
||||
{"key": "odoo.database", "label": "Database", "type": "string", "category": "Odoo", "default": ""},
|
||||
{"key": "odoo.username", "label": "Username", "type": "string", "category": "Odoo", "default": ""},
|
||||
{"key": "odoo.auth_method", "label": "Auth method", "type": "choice", "category": "Odoo", "options": ["password", "api_key"], "default": "password"},
|
||||
{"key": "odoo.password", "label": "Password", "type": "secret", "category": "Odoo", "default": ""},
|
||||
{"key": "odoo.api_key", "label": "API key", "type": "secret", "category": "Odoo", "default": ""},
|
||||
# --- SMS provider ---
|
||||
{"key": "sms.provider", "label": "SMS provider", "type": "choice", "category": "SMS", "options": ["mock", "http"], "default": "mock"},
|
||||
{"key": "sms.endpoint", "label": "HTTP endpoint", "type": "string", "category": "SMS", "default": "", "placeholder": "https://sms.provider.example/api/send"},
|
||||
{"key": "sms.api_key", "label": "API key", "type": "secret", "category": "SMS", "default": ""},
|
||||
{"key": "sms.sender", "label": "Sender number", "type": "string", "category": "SMS", "default": ""},
|
||||
# --- Reservation schedule (dynamic slot generation) ---
|
||||
{"key": "reservation.working_hours_start", "label": "Working hours start", "type": "string", "category": "Reservation", "default": "08:00", "placeholder": "HH:MM"},
|
||||
{"key": "reservation.working_hours_end", "label": "Working hours end", "type": "string", "category": "Reservation", "default": "17:00", "placeholder": "HH:MM"},
|
||||
{"key": "reservation.slot_duration_minutes", "label": "Slot duration (minutes)", "type": "number", "category": "Reservation", "default": 30},
|
||||
{"key": "reservation.default_capacity", "label": "Default capacity per slot", "type": "number", "category": "Reservation", "default": 2},
|
||||
]
|
||||
|
||||
SCHEMA_BY_KEY = {entry["key"]: entry for entry in SETTINGS_SCHEMA}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Runtime settings service.
|
||||
|
||||
Resolution order per key: database value → environment variable → schema
|
||||
default. Writing a value persists it to the database, so changes take effect
|
||||
immediately without a restart.
|
||||
"""
|
||||
import os
|
||||
|
||||
from apps.core.models import SystemSetting
|
||||
from apps.core.settings_registry import SCHEMA_BY_KEY, SETTINGS_SCHEMA
|
||||
|
||||
|
||||
class SettingsService:
|
||||
def schema(self):
|
||||
return SETTINGS_SCHEMA
|
||||
|
||||
def _env_key(self, key: str) -> str:
|
||||
return key.upper().replace(".", "_")
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
setting = SystemSetting.objects.filter(key=key).first()
|
||||
if setting is not None:
|
||||
return setting.value
|
||||
|
||||
env_value = os.environ.get(self._env_key(key))
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
|
||||
entry = SCHEMA_BY_KEY.get(key)
|
||||
if entry and "default" in entry:
|
||||
return entry["default"]
|
||||
return default
|
||||
|
||||
def get_bool(self, key: str, default=False) -> bool:
|
||||
value = self.get(key, default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def set(self, key: str, value) -> None:
|
||||
SystemSetting.objects.update_or_create(key=key, defaults={"value": value})
|
||||
|
||||
def all_values(self, mask_secrets: bool = True) -> dict:
|
||||
result = {}
|
||||
for entry in SETTINGS_SCHEMA:
|
||||
value = self.get(entry["key"])
|
||||
if mask_secrets and entry.get("type") == "secret" and value:
|
||||
result[entry["key"]] = "••••••"
|
||||
else:
|
||||
result[entry["key"]] = value
|
||||
return result
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.core.settings_service import SettingsService
|
||||
|
||||
|
||||
class SettingsServiceTests(TestCase):
|
||||
def test_get_returns_schema_default(self):
|
||||
service = SettingsService()
|
||||
self.assertEqual(service.get("sms.provider"), "mock")
|
||||
self.assertIs(service.get("odoo.enabled"), False)
|
||||
|
||||
def test_set_and_get(self):
|
||||
service = SettingsService()
|
||||
service.set("odoo.url", "https://odoo.example.com")
|
||||
self.assertEqual(service.get("odoo.url"), "https://odoo.example.com")
|
||||
|
||||
def test_secrets_are_masked(self):
|
||||
service = SettingsService()
|
||||
service.set("odoo.api_key", "super-secret")
|
||||
values = service.all_values()
|
||||
self.assertEqual(values["odoo.api_key"], "••••••")
|
||||
@@ -0,0 +1,62 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.core.settings_registry import SCHEMA_BY_KEY
|
||||
from apps.core.settings_service import SettingsService
|
||||
from apps.rbac.permissions import HasPermission
|
||||
|
||||
|
||||
class SettingsView(APIView):
|
||||
"""Read/write runtime integration settings.
|
||||
|
||||
GET returns the schema (drives the panel) and current values (secrets
|
||||
masked). PUT accepts ``{"values": {"key": value, ...}}`` and persists them.
|
||||
"""
|
||||
|
||||
permission_classes = [HasPermission]
|
||||
perm_code = "settings.manage"
|
||||
|
||||
def get(self, request):
|
||||
service = SettingsService()
|
||||
return Response({"schema": service.schema(), "values": service.all_values()})
|
||||
|
||||
def put(self, request):
|
||||
service = SettingsService()
|
||||
values = request.data.get("values") or {}
|
||||
for key, value in values.items():
|
||||
entry = SCHEMA_BY_KEY.get(key)
|
||||
if entry is None:
|
||||
continue
|
||||
# Never overwrite a secret with an empty value (means "unchanged").
|
||||
if entry.get("type") == "secret" and value in ("", None):
|
||||
continue
|
||||
service.set(key, value)
|
||||
return Response({"values": service.all_values()})
|
||||
|
||||
|
||||
class TestOdooView(APIView):
|
||||
"""Attempt an XML-RPC connection using the currently saved Odoo settings."""
|
||||
|
||||
permission_classes = [HasPermission]
|
||||
perm_code = "settings.manage"
|
||||
|
||||
def post(self, request):
|
||||
from apps.integrations.odoo.client import OdooXmlRpcClient
|
||||
|
||||
service = SettingsService()
|
||||
url = service.get("odoo.url", "")
|
||||
if not url:
|
||||
return Response({"ok": False, "error": "Odoo URL is not configured."})
|
||||
client = OdooXmlRpcClient(
|
||||
url=url,
|
||||
db=service.get("odoo.database", ""),
|
||||
username=service.get("odoo.username", ""),
|
||||
password=service.get("odoo.password", ""),
|
||||
api_key=service.get("odoo.api_key", ""),
|
||||
auth_method=service.get("odoo.auth_method", "password"),
|
||||
)
|
||||
try:
|
||||
result = client.test_connection()
|
||||
return Response({"ok": True, "version": result["version"], "uid": str(result["uid"])})
|
||||
except Exception as exc:
|
||||
return Response({"ok": False, "error": str(exc)})
|
||||
@@ -0,0 +1 @@
|
||||
# integrations package
|
||||
@@ -0,0 +1 @@
|
||||
# odoo integration (service layer, not a Django app)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Odoo client abstraction.
|
||||
|
||||
Cetana never spreads raw Odoo calls through the codebase. Everything goes
|
||||
through this client behind an interface. The concrete client is chosen at
|
||||
runtime from the dynamic settings store (apps.core.settings_service), so the
|
||||
integration can be configured and switched from the admin panel without a
|
||||
deploy — with a mock fallback when Odoo is not configured.
|
||||
"""
|
||||
import xmlrpc.client
|
||||
from typing import Protocol
|
||||
|
||||
from apps.core.settings_service import SettingsService
|
||||
|
||||
|
||||
class OdooClientInterface(Protocol):
|
||||
def search_partner(self, key: str, value) -> dict | None: ...
|
||||
def create_partner(self, payload: dict) -> dict: ...
|
||||
def create_invoice(self, payload: dict) -> dict: ...
|
||||
|
||||
|
||||
class MockOdooClient:
|
||||
"""Deterministic in-process stand-in used when Odoo is not configured."""
|
||||
|
||||
def search_partner(self, key: str, value) -> dict | None:
|
||||
return None
|
||||
|
||||
def create_partner(self, payload: dict) -> dict:
|
||||
return {"id": f"MOCK-PARTNER-{abs(hash(str(payload))) % 10_000_000}"}
|
||||
|
||||
def create_invoice(self, payload: dict) -> dict:
|
||||
return {"id": f"MOCK-INV-{abs(hash(str(payload))) % 10_000_000}"}
|
||||
|
||||
|
||||
class OdooXmlRpcClient:
|
||||
"""Real Odoo client over the standard XML-RPC API (stdlib only)."""
|
||||
|
||||
def __init__(self, url, db, username, password="", api_key="", auth_method="password"):
|
||||
self.url = (url or "").rstrip("/")
|
||||
self.db = db
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.api_key = api_key
|
||||
self.auth_method = auth_method
|
||||
self._uid = None
|
||||
|
||||
def _authenticate(self):
|
||||
common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
|
||||
secret = self.api_key if self.auth_method == "api_key" else self.password
|
||||
self._uid = common.authenticate(self.db, self.username, secret, {})
|
||||
return self._uid
|
||||
|
||||
def _execute(self, model, method, method_args, kwargs=None):
|
||||
if self._uid is None:
|
||||
self._authenticate()
|
||||
secret = self.api_key if self.auth_method == "api_key" else self.password
|
||||
models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object")
|
||||
return models.execute_kw(self.db, self._uid, secret, model, method, method_args, kwargs or {})
|
||||
|
||||
def search_partner(self, key: str, value) -> dict | None:
|
||||
results = self._execute(
|
||||
"res.partner", "search_read",
|
||||
[[[key, "=", value]]],
|
||||
{"limit": 1, "fields": ["id", "name"]},
|
||||
)
|
||||
return results[0] if results else None
|
||||
|
||||
def create_partner(self, payload: dict) -> dict:
|
||||
partner_id = self._execute("res.partner", "create", [payload])
|
||||
return {"id": partner_id}
|
||||
|
||||
def create_invoice(self, payload: dict) -> dict:
|
||||
ref = payload.get("partner_ref") or ""
|
||||
partner = None
|
||||
if ref:
|
||||
partner = self.search_partner("ref", ref) or self.search_partner("name", ref)
|
||||
if partner is None and ref:
|
||||
partner = {"id": self._execute("res.partner", "create", [{"name": ref}])}
|
||||
vals = {
|
||||
"move_type": "out_invoice",
|
||||
"ref": payload.get("reference"),
|
||||
"partner_id": partner["id"] if partner else False,
|
||||
"invoice_line_ids": [(0, 0, {
|
||||
"name": payload.get("reference") or "Processing",
|
||||
"quantity": 1,
|
||||
"price_unit": float(payload.get("amount") or 0),
|
||||
})],
|
||||
}
|
||||
invoice_id = self._execute("account.move", "create", [vals])
|
||||
return {"id": invoice_id}
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
"""Validate reachability + credentials. Returns server version and uid."""
|
||||
common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
|
||||
version = common.version()
|
||||
uid = self._authenticate()
|
||||
if not uid:
|
||||
raise ValueError("Authentication failed — check database, username and secret.")
|
||||
return {"version": version, "uid": uid}
|
||||
|
||||
|
||||
def get_odoo_client() -> OdooClientInterface:
|
||||
service = SettingsService()
|
||||
if not service.get_bool("odoo.enabled", False):
|
||||
return MockOdooClient()
|
||||
url = service.get("odoo.url", "")
|
||||
if not url:
|
||||
return MockOdooClient()
|
||||
return OdooXmlRpcClient(
|
||||
url=url,
|
||||
db=service.get("odoo.database", ""),
|
||||
username=service.get("odoo.username", ""),
|
||||
password=service.get("odoo.password", ""),
|
||||
api_key=service.get("odoo.api_key", ""),
|
||||
auth_method=service.get("odoo.auth_method", "password"),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
from apps.integrations.odoo.client import OdooClientInterface, get_odoo_client
|
||||
|
||||
|
||||
class OdooPartnerService:
|
||||
def __init__(self, client: OdooClientInterface | None = None):
|
||||
self.client = client or get_odoo_client()
|
||||
|
||||
def lookup(self, key: str, value) -> dict | None:
|
||||
return self.client.search_partner(key, value)
|
||||
|
||||
def create(self, payload: dict) -> dict:
|
||||
return self.client.create_partner(payload)
|
||||
|
||||
|
||||
class OdooInvoiceService:
|
||||
def __init__(self, client: OdooClientInterface | None = None):
|
||||
self.client = client or get_odoo_client()
|
||||
|
||||
def sync_invoice(self, invoice) -> dict:
|
||||
payload = {
|
||||
"reference": invoice.number,
|
||||
"amount": str(invoice.amount),
|
||||
"currency": invoice.currency,
|
||||
"partner_ref": invoice.order.customer.odoo_partner_id or "",
|
||||
}
|
||||
return self.client.create_invoice(payload)
|
||||
@@ -0,0 +1 @@
|
||||
# invoicing app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InvoicingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.invoicing"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('number', models.CharField(max_length=40, unique=True)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=18)),
|
||||
('currency', models.CharField(default='IRR', max_length=8)),
|
||||
('sync_state', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('synchronized', 'Synchronized'), ('failed', 'Failed'), ('retrying', 'Retrying'), ('cancelled', 'Cancelled')], default='pending', max_length=16)),
|
||||
('odoo_invoice_id', models.CharField(blank=True, max_length=120)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('synced_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('invoicing', '0001_initial'),
|
||||
('orders', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='orders.processingorder'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class Invoice(UUIDModel, TimeStampedModel):
|
||||
class SyncState(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
PROCESSING = "processing", "Processing"
|
||||
SYNCHRONIZED = "synchronized", "Synchronized"
|
||||
FAILED = "failed", "Failed"
|
||||
RETRYING = "retrying", "Retrying"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
order = models.ForeignKey("orders.ProcessingOrder", on_delete=models.PROTECT, related_name="invoices")
|
||||
number = models.CharField(max_length=40, unique=True)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=2)
|
||||
currency = models.CharField(max_length=8, default="IRR")
|
||||
sync_state = models.CharField(max_length=16, choices=SyncState.choices, default=SyncState.PENDING)
|
||||
odoo_invoice_id = models.CharField(max_length=120, blank=True)
|
||||
last_error = models.TextField(blank=True)
|
||||
synced_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.number
|
||||
@@ -0,0 +1,13 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.invoicing.models import Invoice
|
||||
|
||||
|
||||
class InvoiceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id", "number", "amount", "currency", "sync_state",
|
||||
"odoo_invoice_id", "last_error", "synced_at",
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.integrations.odoo.services import OdooInvoiceService
|
||||
from apps.invoicing.models import Invoice
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=5, default_retry_delay=60)
|
||||
def sync_invoice_to_odoo(self, invoice_id):
|
||||
"""Synchronize an invoice to Odoo with automatic retry (idempotent)."""
|
||||
try:
|
||||
invoice = Invoice.objects.get(pk=invoice_id)
|
||||
except Invoice.DoesNotExist:
|
||||
return
|
||||
if invoice.sync_state == Invoice.SyncState.SYNCHRONIZED:
|
||||
return # already synchronized; retries must be idempotent
|
||||
|
||||
invoice.sync_state = Invoice.SyncState.PROCESSING
|
||||
invoice.save(update_fields=["sync_state"])
|
||||
try:
|
||||
result = OdooInvoiceService().sync_invoice(invoice)
|
||||
invoice.odoo_invoice_id = result.get("id", "")
|
||||
invoice.sync_state = Invoice.SyncState.SYNCHRONIZED
|
||||
invoice.synced_at = timezone.now()
|
||||
invoice.save(update_fields=["odoo_invoice_id", "sync_state", "synced_at"])
|
||||
except Exception as exc:
|
||||
invoice.sync_state = Invoice.SyncState.RETRYING
|
||||
invoice.last_error = str(exc)
|
||||
invoice.save(update_fields=["sync_state", "last_error"])
|
||||
raise self.retry(exc=exc)
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.invoicing.views import InvoiceViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", InvoiceViewSet, basename="invoice")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,52 @@
|
||||
from django.utils import timezone
|
||||
from rest_framework import decorators, mixins, response, viewsets
|
||||
|
||||
from apps.core.errors import ApiError
|
||||
from apps.integrations.odoo.services import OdooInvoiceService
|
||||
from apps.invoicing.models import Invoice
|
||||
from apps.invoicing.serializers import InvoiceSerializer
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class InvoiceViewSet(
|
||||
mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
serializer_class = InvoiceSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "invoice.view",
|
||||
"retrieve": "invoice.view",
|
||||
"create": "invoice.create",
|
||||
"sync": "invoice.create",
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = Invoice.objects.select_related("order").all()
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(order__customer__user=user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
order = serializer.validated_data["order"]
|
||||
total = (order.price_snapshot or {}).get("total")
|
||||
if not total:
|
||||
raise ApiError("NO_PRICE_SNAPSHOT", "Order has no price snapshot.")
|
||||
serializer.save(number=f"INV-{order.order_number}", amount=total, currency=order.currency)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def sync(self, request, pk=None):
|
||||
invoice = self.get_object()
|
||||
invoice.sync_state = Invoice.SyncState.PROCESSING
|
||||
invoice.save(update_fields=["sync_state"])
|
||||
try:
|
||||
result = OdooInvoiceService().sync_invoice(invoice)
|
||||
invoice.odoo_invoice_id = result.get("id", "")
|
||||
invoice.sync_state = Invoice.SyncState.SYNCHRONIZED
|
||||
invoice.synced_at = timezone.now()
|
||||
invoice.save(update_fields=["odoo_invoice_id", "sync_state", "synced_at"])
|
||||
except Exception as exc:
|
||||
invoice.sync_state = Invoice.SyncState.FAILED
|
||||
invoice.last_error = str(exc)
|
||||
invoice.save(update_fields=["sync_state", "last_error"])
|
||||
return response.Response(InvoiceSerializer(invoice).data)
|
||||
@@ -0,0 +1 @@
|
||||
# live_monitoring app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LiveMonitoringConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.live_monitoring"
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('workflows', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Camera',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('facility', models.CharField(blank=True, max_length=200)),
|
||||
('processing_line', models.CharField(blank=True, max_length=200)),
|
||||
('stream_url', models.URLField()),
|
||||
('auth_config', models.JSONField(blank=True, default=dict)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LiveStream',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(blank=True, max_length=200)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('camera', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='streams', to='live_monitoring.camera')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkflowStepCamera',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('camera', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflow_steps', to='live_monitoring.camera')),
|
||||
('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cameras', to='workflows.workflowstep')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class Camera(UUIDModel, TimeStampedModel):
|
||||
name = models.CharField(max_length=200)
|
||||
facility = models.CharField(max_length=200, blank=True)
|
||||
processing_line = models.CharField(max_length=200, blank=True)
|
||||
stream_url = models.URLField()
|
||||
# Never expose credentials to the frontend; keep auth config server-side.
|
||||
auth_config = models.JSONField(default=dict, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class LiveStream(UUIDModel, TimeStampedModel):
|
||||
camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="streams")
|
||||
name = models.CharField(max_length=200, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.camera.name
|
||||
|
||||
|
||||
class WorkflowStepCamera(UUIDModel):
|
||||
step = models.ForeignKey("workflows.WorkflowStep", on_delete=models.CASCADE, related_name="cameras")
|
||||
camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="workflow_steps")
|
||||
@@ -0,0 +1,10 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.live_monitoring.models import Camera
|
||||
|
||||
|
||||
class CameraSerializer(serializers.ModelSerializer):
|
||||
# auth_config is never exposed to the frontend.
|
||||
class Meta:
|
||||
model = Camera
|
||||
fields = ["id", "name", "facility", "processing_line", "stream_url", "active"]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Secure live-stream access.
|
||||
|
||||
Camera credentials are never exposed. A customer can request short-lived,
|
||||
HMAC-signed access tokens for streams authorized for their own orders; the
|
||||
`verify` endpoint validates the token and returns the stream URL.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.live_monitoring.models import Camera, WorkflowStepCamera
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.workflows.models import WorkflowStep
|
||||
|
||||
|
||||
class StreamAccessService:
|
||||
def can_access(self, user, camera: Camera) -> bool:
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
version_ids = ProcessingOrder.objects.filter(customer__user=user).values_list(
|
||||
"workflow_version_id", flat=True
|
||||
)
|
||||
step_ids = WorkflowStep.objects.filter(
|
||||
version_id__in=version_ids, live_monitoring_enabled=True
|
||||
).values_list("id", flat=True)
|
||||
return WorkflowStepCamera.objects.filter(camera=camera, step_id__in=step_ids).exists()
|
||||
|
||||
def issue_token(self, camera: Camera, ttl: int = 3600) -> tuple:
|
||||
payload = {"camera": str(camera.id), "exp": int(time.time()) + ttl}
|
||||
raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
||||
sig = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{raw}.{sig}", payload["exp"]
|
||||
|
||||
def verify_token(self, token: str):
|
||||
try:
|
||||
raw, sig = token.split(".", 1)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
expected = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, sig):
|
||||
return None
|
||||
try:
|
||||
pad = raw + "=" * (-len(raw) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(pad).decode())
|
||||
except Exception:
|
||||
return None
|
||||
if payload.get("exp", 0) < int(time.time()):
|
||||
return None
|
||||
return payload
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.live_monitoring.models import Camera
|
||||
from apps.live_monitoring.services import StreamAccessService
|
||||
|
||||
|
||||
class StreamAccessTests(TestCase):
|
||||
def setUp(self):
|
||||
self.camera = Camera.objects.create(name="Cam1", stream_url="https://example.com/live.m3u8")
|
||||
self.service = StreamAccessService()
|
||||
|
||||
def test_issue_and_verify_token(self):
|
||||
token, exp = self.service.issue_token(self.camera)
|
||||
payload = self.service.verify_token(token)
|
||||
self.assertIsNotNone(payload)
|
||||
self.assertEqual(payload["camera"], str(self.camera.id))
|
||||
self.assertGreater(payload["exp"], exp - 1)
|
||||
|
||||
def test_tampered_token_rejected(self):
|
||||
token, _ = self.service.issue_token(self.camera)
|
||||
raw, _sig = token.split(".", 1)
|
||||
self.assertIsNone(self.service.verify_token(raw + ".deadbeef"))
|
||||
|
||||
def test_expired_token_rejected(self):
|
||||
token, _ = self.service.issue_token(self.camera, ttl=-1)
|
||||
self.assertIsNone(self.service.verify_token(token))
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.live_monitoring.views import CameraViewSet, VerifyStreamView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("cameras", CameraViewSet, basename="camera")
|
||||
|
||||
urlpatterns = [
|
||||
path("streams/verify/", VerifyStreamView.as_view(), name="verify-stream"),
|
||||
*router.urls,
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
from rest_framework import decorators, mixins, permissions, response, status, views, viewsets
|
||||
|
||||
from apps.core.errors import error_response
|
||||
from apps.live_monitoring.models import Camera
|
||||
from apps.live_monitoring.serializers import CameraSerializer
|
||||
from apps.live_monitoring.services import StreamAccessService
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class CameraViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
queryset = Camera.objects.filter(active=True)
|
||||
serializer_class = CameraSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {"list": "processing.view", "retrieve": "processing.view", "access": "processing.view"}
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def access(self, request, pk=None):
|
||||
camera = self.get_object()
|
||||
service = StreamAccessService()
|
||||
if not service.can_access(request.user, camera):
|
||||
return response.Response(
|
||||
error_response("STREAM_FORBIDDEN", "You do not have access to this stream."),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
token, expires_at = service.issue_token(camera)
|
||||
access_url = request.build_absolute_uri(f"/api/v1/live-monitoring/streams/verify/?token={token}")
|
||||
return response.Response({"token": token, "expires_at": expires_at, "access_url": access_url})
|
||||
|
||||
|
||||
class VerifyStreamView(views.APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
token = request.query_params.get("token")
|
||||
payload = StreamAccessService().verify_token(token)
|
||||
if not payload:
|
||||
return response.Response(
|
||||
error_response("INVALID_TOKEN", "Invalid or expired stream token."),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
try:
|
||||
camera = Camera.objects.get(pk=payload["camera"], active=True)
|
||||
except Camera.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("CAMERA_NOT_FOUND", "Camera not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return response.Response({"stream_url": camera.stream_url})
|
||||
@@ -0,0 +1 @@
|
||||
# notifications app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notifications"
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationTemplate',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('event', models.CharField(db_index=True, max_length=120)),
|
||||
('channel', models.CharField(choices=[('sms', 'SMS'), ('email', 'Email'), ('push', 'Push'), ('inapp', 'In-app')], default='sms', max_length=12)),
|
||||
('subject', models.CharField(blank=True, max_length=255)),
|
||||
('body', models.TextField(help_text='Supports {{variable}} placeholders.')),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationRule',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('event', models.CharField(db_index=True, max_length=120)),
|
||||
('recipients', models.JSONField(blank=True, default=list)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='notifications.notificationtemplate')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationLog',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('recipient', models.CharField(blank=True, max_length=200)),
|
||||
('channel', models.CharField(max_length=12)),
|
||||
('status', models.CharField(default='pending', max_length=20)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='notifications.notificationtemplate')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class NotificationTemplate(UUIDModel, TimeStampedModel):
|
||||
class Channel(models.TextChoices):
|
||||
SMS = "sms", "SMS"
|
||||
EMAIL = "email", "Email"
|
||||
PUSH = "push", "Push"
|
||||
INAPP = "inapp", "In-app"
|
||||
|
||||
event = models.CharField(max_length=120, db_index=True)
|
||||
channel = models.CharField(max_length=12, choices=Channel.choices, default=Channel.SMS)
|
||||
subject = models.CharField(max_length=255, blank=True)
|
||||
body = models.TextField(help_text="Supports {{variable}} placeholders.")
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.event} ({self.channel})"
|
||||
|
||||
|
||||
class NotificationRule(UUIDModel, TimeStampedModel):
|
||||
event = models.CharField(max_length=120, db_index=True)
|
||||
template = models.ForeignKey(NotificationTemplate, on_delete=models.CASCADE, related_name="rules")
|
||||
recipients = models.JSONField(default=list, blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
|
||||
class NotificationLog(UUIDModel, TimeStampedModel):
|
||||
template = models.ForeignKey(NotificationTemplate, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
recipient = models.CharField(max_length=200, blank=True)
|
||||
channel = models.CharField(max_length=12)
|
||||
status = models.CharField(max_length=20, default="pending")
|
||||
error = models.TextField(blank=True)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pluggable SMS / notification providers.
|
||||
|
||||
Providers are selected at runtime from the dynamic settings store, so an SMS
|
||||
gateway can be configured from the admin panel without touching code. Business
|
||||
logic only ever talks to the provider interface.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from typing import Protocol
|
||||
|
||||
from apps.core.settings_service import SettingsService
|
||||
|
||||
|
||||
class SmsProviderInterface(Protocol):
|
||||
def send(self, recipient: str, message: str) -> str:
|
||||
"""Return the provider message id."""
|
||||
...
|
||||
|
||||
|
||||
class MockSmsProvider:
|
||||
def send(self, recipient: str, message: str) -> str:
|
||||
return f"MOCK-{recipient}-{abs(hash(message)) % 10_000_000}"
|
||||
|
||||
|
||||
class HttpSmsProvider:
|
||||
"""Generic HTTP gateway: POSTs a JSON payload to a configurable endpoint."""
|
||||
|
||||
def __init__(self, endpoint: str, api_key: str = "", sender: str = ""):
|
||||
self.endpoint = endpoint
|
||||
self.api_key = api_key
|
||||
self.sender = sender
|
||||
|
||||
def send(self, recipient: str, message: str) -> str:
|
||||
payload = {"to": recipient, "text": message, "from": self.sender, "api_key": self.api_key}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return response.read().decode("utf-8", "replace")[:120]
|
||||
|
||||
|
||||
def get_sms_provider() -> SmsProviderInterface:
|
||||
service = SettingsService()
|
||||
provider = service.get("sms.provider", "mock")
|
||||
if provider == "http":
|
||||
return HttpSmsProvider(
|
||||
endpoint=service.get("sms.endpoint", ""),
|
||||
api_key=service.get("sms.api_key", ""),
|
||||
sender=service.get("sms.sender", ""),
|
||||
)
|
||||
return MockSmsProvider()
|
||||
|
||||
|
||||
def render_template(template_text: str, context: dict) -> str:
|
||||
def _replace(match):
|
||||
key = match.group(1)
|
||||
return str(context.get(key, ""))
|
||||
|
||||
return re.sub(r"\{\{\s*(\w+)\s*\}\}", _replace, template_text)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Notification engine.
|
||||
|
||||
Resolves templates for a domain event from the database, renders them and
|
||||
dispatches through the configured provider. Business logic only emits events.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.notifications.models import NotificationLog, NotificationTemplate
|
||||
from apps.notifications.providers import get_sms_provider, render_template
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def send_event(self, event: str, context: dict, recipient: str = "") -> list:
|
||||
results = []
|
||||
templates = NotificationTemplate.objects.filter(event=event, enabled=True)
|
||||
for template in templates:
|
||||
body = render_template(template.body, context)
|
||||
if template.channel == NotificationTemplate.Channel.SMS:
|
||||
results.append(self._send_sms(template, recipient, body))
|
||||
return results
|
||||
|
||||
def _send_sms(self, template, recipient: str, body: str) -> dict:
|
||||
status = "sent"
|
||||
error = ""
|
||||
reference = ""
|
||||
try:
|
||||
reference = get_sms_provider().send(recipient, body)
|
||||
except Exception as exc: # pragma: no cover - provider failures
|
||||
status = "failed"
|
||||
error = str(exc)
|
||||
NotificationLog.objects.create(
|
||||
template=template,
|
||||
recipient=recipient,
|
||||
channel="sms",
|
||||
status=status,
|
||||
error=error,
|
||||
sent_at=timezone.now() if status == "sent" else None,
|
||||
)
|
||||
return {"channel": "sms", "status": status, "reference": reference}
|
||||
@@ -0,0 +1 @@
|
||||
# orders app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OrdersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.orders"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
('reservations', '0001_initial'),
|
||||
('workflows', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProcessingOrder',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('order_number', models.CharField(blank=True, max_length=32, unique=True)),
|
||||
('gross_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('tare_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('net_weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||
('selected_step_keys', models.JSONField(blank=True, default=list)),
|
||||
('price_snapshot', models.JSONField(blank=True, default=dict)),
|
||||
('currency', models.CharField(default='IRR', max_length=8)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('awaiting_confirmation', 'Awaiting confirmation'), ('confirmed', 'Confirmed'), ('processing', 'Processing'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='draft', max_length=24)),
|
||||
('confirmed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('terms_accepted', models.BooleanField(default=False)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='accounts.customer')),
|
||||
('reservation', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order', to='reservations.reservation')),
|
||||
('workflow_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='workflows.workflowversion')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
def generate_order_number() -> str:
|
||||
year = timezone.now().year
|
||||
prefix = f"STN-{year}-"
|
||||
last = (
|
||||
ProcessingOrder.objects.filter(order_number__startswith=prefix)
|
||||
.order_by("-order_number")
|
||||
.first()
|
||||
)
|
||||
if last:
|
||||
try:
|
||||
seq = int(last.order_number.rsplit("-", 1)[1]) + 1
|
||||
except (IndexError, ValueError):
|
||||
seq = 1
|
||||
else:
|
||||
seq = 1
|
||||
return f"{prefix}{seq:06d}"
|
||||
|
||||
|
||||
class ProcessingOrder(UUIDModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
AWAITING_CONFIRMATION = "awaiting_confirmation", "Awaiting confirmation"
|
||||
CONFIRMED = "confirmed", "Confirmed"
|
||||
PROCESSING = "processing", "Processing"
|
||||
COMPLETED = "completed", "Completed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
order_number = models.CharField(max_length=32, unique=True, blank=True)
|
||||
customer = models.ForeignKey("accounts.Customer", on_delete=models.PROTECT, related_name="orders")
|
||||
reservation = models.OneToOneField(
|
||||
"reservations.Reservation", null=True, blank=True, on_delete=models.SET_NULL, related_name="order"
|
||||
)
|
||||
# Workflow version is immutable per order — historical orders never change.
|
||||
workflow_version = models.ForeignKey(
|
||||
"workflows.WorkflowVersion", on_delete=models.PROTECT, related_name="orders"
|
||||
)
|
||||
gross_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
tare_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
net_weight_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
selected_step_keys = models.JSONField(default=list, blank=True)
|
||||
price_snapshot = models.JSONField(default=dict, blank=True)
|
||||
currency = models.CharField(max_length=8, default="IRR")
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.DRAFT)
|
||||
confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||
terms_accepted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.order_number:
|
||||
self.order_number = generate_order_number()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.order_number or str(self.id)
|
||||
@@ -0,0 +1,31 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.orders.models import ProcessingOrder
|
||||
|
||||
|
||||
class ProcessingOrderSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProcessingOrder
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id", "order_number", "workflow_version", "price_snapshot",
|
||||
"status", "confirmed_at", "net_weight_kg", "gross_weight_kg", "tare_weight_kg",
|
||||
]
|
||||
|
||||
|
||||
class OrderCreateSerializer(serializers.Serializer):
|
||||
reservation = serializers.UUIDField()
|
||||
workflow = serializers.UUIDField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class WeighingSerializer(serializers.Serializer):
|
||||
gross = serializers.DecimalField(max_digits=12, decimal_places=2)
|
||||
tare = serializers.DecimalField(max_digits=12, decimal_places=2)
|
||||
|
||||
|
||||
class StepSelectionSerializer(serializers.Serializer):
|
||||
step_keys = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
|
||||
class ConfirmSerializer(serializers.Serializer):
|
||||
terms_accepted = serializers.BooleanField(default=True)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Order lifecycle service — ties reservations, weighing, dynamic step
|
||||
selection, pricing and confirmation together. All financial values are
|
||||
computed by the backend and stored as immutable snapshots."""
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.errors import ApiError
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.pricing.engine import PricingEngine
|
||||
from apps.workflows.models import Workflow
|
||||
|
||||
|
||||
class OrderService:
|
||||
def _active_workflow(self) -> Workflow:
|
||||
workflow = Workflow.objects.filter(status="active", active_version__isnull=False).first()
|
||||
if workflow is None:
|
||||
raise ApiError("NO_ACTIVE_WORKFLOW", "No active workflow is configured.")
|
||||
return workflow
|
||||
|
||||
@transaction.atomic
|
||||
def create_from_reservation(self, reservation, workflow=None) -> ProcessingOrder:
|
||||
if ProcessingOrder.objects.filter(reservation=reservation).exists():
|
||||
raise ApiError("ORDER_EXISTS", "An order already exists for this reservation.")
|
||||
if workflow is None:
|
||||
workflow = self._active_workflow()
|
||||
return ProcessingOrder.objects.create(
|
||||
customer=reservation.customer,
|
||||
reservation=reservation,
|
||||
workflow_version=workflow.active_version,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def record_weighing(self, order, gross, tare) -> ProcessingOrder:
|
||||
order = ProcessingOrder.objects.select_for_update().get(pk=order.pk)
|
||||
if order.net_weight_kg is not None:
|
||||
raise ApiError("WEIGHING_IMMUTABLE", "Net weight is already recorded and immutable.")
|
||||
gross = Decimal(str(gross))
|
||||
tare = Decimal(str(tare))
|
||||
if gross < tare:
|
||||
raise ApiError("INVALID_WEIGHT", "Gross weight cannot be less than tare weight.")
|
||||
order.gross_weight_kg = gross
|
||||
order.tare_weight_kg = tare
|
||||
order.net_weight_kg = gross - tare
|
||||
order.save(update_fields=["gross_weight_kg", "tare_weight_kg", "net_weight_kg"])
|
||||
return order
|
||||
|
||||
@transaction.atomic
|
||||
def select_steps(self, order, step_keys) -> ProcessingOrder:
|
||||
order = ProcessingOrder.objects.select_for_update().get(pk=order.pk)
|
||||
if order.net_weight_kg is None:
|
||||
raise ApiError("WEIGHING_REQUIRED", "Record weighing before selecting processing.")
|
||||
version = order.workflow_version
|
||||
steps = list(version.steps.filter(key__in=step_keys, enabled=True))
|
||||
keys = {s.key for s in steps}
|
||||
for required in version.steps.filter(required=True):
|
||||
if required.key not in keys:
|
||||
raise ApiError(
|
||||
"REQUIRED_STEP_MISSING",
|
||||
f"Required step '{required.key}' must be selected.",
|
||||
{"step": required.key},
|
||||
)
|
||||
order.selected_step_keys = sorted(keys)
|
||||
order.price_snapshot = PricingEngine().calculate(order.net_weight_kg, steps, order.currency)
|
||||
order.status = ProcessingOrder.Status.AWAITING_CONFIRMATION
|
||||
order.save(update_fields=["selected_step_keys", "price_snapshot", "status"])
|
||||
return order
|
||||
|
||||
@transaction.atomic
|
||||
def confirm(self, order, terms_accepted=True) -> ProcessingOrder:
|
||||
order = ProcessingOrder.objects.select_for_update().get(pk=order.pk)
|
||||
if order.status != ProcessingOrder.Status.AWAITING_CONFIRMATION:
|
||||
raise ApiError("ORDER_NOT_CONFIRMABLE", "Order is not awaiting confirmation.")
|
||||
if not terms_accepted:
|
||||
raise ApiError("TERMS_REQUIRED", "Terms must be accepted.")
|
||||
order.status = ProcessingOrder.Status.CONFIRMED
|
||||
order.confirmed_at = timezone.now()
|
||||
order.terms_accepted = True
|
||||
order.save(update_fields=["status", "confirmed_at", "terms_accepted"])
|
||||
return order
|
||||
@@ -0,0 +1,72 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import Customer
|
||||
from apps.core.errors import ApiError
|
||||
from apps.orders.services import OrderService
|
||||
from apps.pricing.models import PricingRule
|
||||
from apps.reservations.models import Facility, Reservation, Slot
|
||||
from apps.workflows.engine import WorkflowEngine
|
||||
from apps.workflows.models import Workflow, WorkflowStep, WorkflowTransition, WorkflowVersion
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def make_active_workflow():
|
||||
wf = Workflow.objects.create(name="W", slug="w-order")
|
||||
v = WorkflowVersion.objects.create(workflow=wf, version=1)
|
||||
s1 = WorkflowStep.objects.create(version=v, key="start", name="Start", order=1, is_start=True)
|
||||
wash = WorkflowStep.objects.create(version=v, key="washing", name="Washing", order=2, required=True)
|
||||
end = WorkflowStep.objects.create(version=v, key="end", name="End", order=3, is_terminal=True)
|
||||
WorkflowTransition.objects.create(version=v, from_step=s1, to_step=wash)
|
||||
WorkflowTransition.objects.create(version=v, from_step=wash, to_step=end)
|
||||
WorkflowEngine().publish(wf, v)
|
||||
PricingRule.objects.create(step=wash, name="Wash", rule_type="per_kg", amount=Decimal("5000"))
|
||||
return wf, v
|
||||
|
||||
|
||||
class OrderServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="c", password="***")
|
||||
self.customer = Customer.objects.create(user=self.user)
|
||||
self.facility = Facility.objects.create(name="F")
|
||||
self.slot = Slot.objects.create(
|
||||
facility=self.facility, date=timezone.localdate(), start_time="08:00", end_time="08:30", capacity=5
|
||||
)
|
||||
self.reservation = Reservation.objects.create(customer=self.customer, slot=self.slot, status="confirmed")
|
||||
make_active_workflow()
|
||||
self.service = OrderService()
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
order = self.service.create_from_reservation(self.reservation)
|
||||
order = self.service.record_weighing(order, "1000", "80")
|
||||
self.assertEqual(order.net_weight_kg, Decimal("920"))
|
||||
order = self.service.select_steps(order, ["washing"])
|
||||
self.assertEqual(order.status, "awaiting_confirmation")
|
||||
self.assertEqual(order.price_snapshot["total"], "4600000.00")
|
||||
order = self.service.confirm(order)
|
||||
self.assertEqual(order.status, "confirmed")
|
||||
self.assertIsNotNone(order.confirmed_at)
|
||||
|
||||
def test_required_step_missing(self):
|
||||
order = self.service.create_from_reservation(self.reservation)
|
||||
order = self.service.record_weighing(order, "1000", "0")
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
self.service.select_steps(order, ["end"])
|
||||
self.assertEqual(ctx.exception.code, "REQUIRED_STEP_MISSING")
|
||||
|
||||
def test_weighing_immutable(self):
|
||||
order = self.service.create_from_reservation(self.reservation)
|
||||
order = self.service.record_weighing(order, "1000", "80")
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
self.service.record_weighing(order, "900", "0")
|
||||
self.assertEqual(ctx.exception.code, "WEIGHING_IMMUTABLE")
|
||||
|
||||
def test_duplicate_order_for_reservation(self):
|
||||
self.service.create_from_reservation(self.reservation)
|
||||
with self.assertRaises(ApiError) as ctx:
|
||||
self.service.create_from_reservation(self.reservation)
|
||||
self.assertEqual(ctx.exception.code, "ORDER_EXISTS")
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.orders.views import ProcessingOrderViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", ProcessingOrderViewSet, basename="order")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,101 @@
|
||||
from rest_framework import decorators, mixins, response, status, viewsets
|
||||
|
||||
from apps.core.errors import ApiError, error_response
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.orders.serializers import (
|
||||
ConfirmSerializer,
|
||||
OrderCreateSerializer,
|
||||
ProcessingOrderSerializer,
|
||||
StepSelectionSerializer,
|
||||
WeighingSerializer,
|
||||
)
|
||||
from apps.orders.services import OrderService
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
from apps.reservations.models import Reservation
|
||||
from apps.workflows.models import Workflow
|
||||
|
||||
|
||||
class ProcessingOrderViewSet(
|
||||
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
serializer_class = ProcessingOrderSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "processing.view",
|
||||
"retrieve": "processing.view",
|
||||
"create": "reservation.create",
|
||||
"weigh": "weighing.create",
|
||||
"select_steps": "reservation.update",
|
||||
"confirm": "reservation.update",
|
||||
}
|
||||
service = OrderService()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = ProcessingOrder.objects.select_related("customer", "workflow_version").all()
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(customer__user=user)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
input_serializer = OrderCreateSerializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
reservation = Reservation.objects.get(pk=input_serializer.validated_data["reservation"])
|
||||
except Reservation.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("RESERVATION_NOT_FOUND", "Reservation not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
workflow_id = input_serializer.validated_data.get("workflow")
|
||||
workflow = None
|
||||
if workflow_id:
|
||||
try:
|
||||
workflow = Workflow.objects.get(pk=workflow_id)
|
||||
except Workflow.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("WORKFLOW_NOT_FOUND", "Workflow not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
try:
|
||||
order = self.service.create_from_reservation(reservation, workflow)
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ProcessingOrderSerializer(order).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def weigh(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
input_serializer = WeighingSerializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
order = self.service.record_weighing(
|
||||
order,
|
||||
input_serializer.validated_data["gross"],
|
||||
input_serializer.validated_data["tare"],
|
||||
)
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ProcessingOrderSerializer(order).data)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def select_steps(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
input_serializer = StepSelectionSerializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
order = self.service.select_steps(order, input_serializer.validated_data["step_keys"])
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ProcessingOrderSerializer(order).data)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def confirm(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
input_serializer = ConfirmSerializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
order = self.service.confirm(order, input_serializer.validated_data["terms_accepted"])
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(ProcessingOrderSerializer(order).data)
|
||||
@@ -0,0 +1 @@
|
||||
# payments app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.payments"
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Payment gateway abstraction with idempotent callbacks.
|
||||
|
||||
Payments never talk to a concrete gateway directly; the gateway is selected
|
||||
from configuration and isolated behind an interface.
|
||||
"""
|
||||
from typing import Protocol
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class PaymentGatewayInterface(Protocol):
|
||||
def request_payment(self, amount, currency, order_number: str, callback_url: str) -> dict:
|
||||
"""Return {'authority': str, 'redirect_url': str}."""
|
||||
...
|
||||
|
||||
def verify(self, authority: str, amount) -> dict:
|
||||
"""Return {'transaction_id': str, 'status': 'success'|'failed'|'cancelled'}."""
|
||||
...
|
||||
|
||||
|
||||
class MockGateway:
|
||||
def request_payment(self, amount, currency, order_number: str, callback_url: str) -> dict:
|
||||
return {
|
||||
"authority": f"MOCK-AUTH-{order_number}",
|
||||
"redirect_url": f"{callback_url}?authority=MOCK-AUTH-{order_number}",
|
||||
}
|
||||
|
||||
def verify(self, authority: str, amount) -> dict:
|
||||
return {"transaction_id": f"MOCK-TXN-{authority}", "status": "success"}
|
||||
|
||||
|
||||
def get_gateway() -> PaymentGatewayInterface:
|
||||
if settings.PAYMENT_GATEWAY_PROVIDER == "mock":
|
||||
return MockGateway()
|
||||
# Real gateways (GatewayA, GatewayB, ...) are selected here.
|
||||
return MockGateway()
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('orders', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Payment',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=18)),
|
||||
('currency', models.CharField(default='IRR', max_length=8)),
|
||||
('gateway', models.CharField(max_length=40)),
|
||||
('transaction_id', models.CharField(max_length=120)),
|
||||
('authority', models.CharField(blank=True, max_length=120)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('success', 'Success'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=12)),
|
||||
('request_time', models.DateTimeField(blank=True, null=True)),
|
||||
('callback_time', models.DateTimeField(blank=True, null=True)),
|
||||
('verification_time', models.DateTimeField(blank=True, null=True)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.processingorder')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('gateway', 'transaction_id'), name='uniq_gateway_transaction')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 11:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('orders', '0001_initial'),
|
||||
('payments', '0001_initial'),
|
||||
('reservations', '0002_reservation_approved_at_reservation_approved_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='reservation',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='reservations.reservation'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='order',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.processingorder'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='payment',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('order__isnull', False), ('reservation__isnull', False), _connector='OR'), name='payment_has_target'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class Payment(UUIDModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
SUCCESS = "success", "Success"
|
||||
FAILED = "failed", "Failed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
# A payment targets either a reservation (upfront) or a processing order.
|
||||
reservation = models.ForeignKey(
|
||||
"reservations.Reservation", null=True, blank=True, on_delete=models.PROTECT, related_name="payments"
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
"orders.ProcessingOrder", null=True, blank=True, on_delete=models.PROTECT, related_name="payments"
|
||||
)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=2)
|
||||
currency = models.CharField(max_length=8, default="IRR")
|
||||
gateway = models.CharField(max_length=40)
|
||||
transaction_id = models.CharField(max_length=120)
|
||||
authority = models.CharField(max_length=120, blank=True)
|
||||
status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING)
|
||||
request_time = models.DateTimeField(null=True, blank=True)
|
||||
callback_time = models.DateTimeField(null=True, blank=True)
|
||||
verification_time = models.DateTimeField(null=True, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["gateway", "transaction_id"], name="uniq_gateway_transaction"),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(order__isnull=False) | models.Q(reservation__isnull=False),
|
||||
name="payment_has_target",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.gateway}:{self.transaction_id}"
|
||||
@@ -0,0 +1,13 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.payments.models import Payment
|
||||
|
||||
|
||||
class PaymentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Payment
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id", "gateway", "transaction_id", "authority", "status",
|
||||
"request_time", "callback_time", "verification_time", "error",
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Customer
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.payments.gateways import get_gateway
|
||||
from apps.payments.models import Payment
|
||||
from apps.workflows.models import Workflow, WorkflowVersion
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="c", password="***")
|
||||
self.customer = Customer.objects.create(user=self.user)
|
||||
wf = Workflow.objects.create(name="W", slug="w-pay")
|
||||
version = WorkflowVersion.objects.create(workflow=wf, version=1)
|
||||
self.order = ProcessingOrder.objects.create(customer=self.customer, workflow_version=version)
|
||||
|
||||
def test_mock_gateway_verify_returns_success(self):
|
||||
result = get_gateway().verify("AUTH-1", "1000")
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertTrue(result["transaction_id"])
|
||||
|
||||
def test_duplicate_gateway_transaction_rejected(self):
|
||||
Payment.objects.create(
|
||||
order=self.order, amount=Decimal("1000"), currency="IRR",
|
||||
gateway="mock", transaction_id="TXN-1", status="success",
|
||||
)
|
||||
with self.assertRaises(IntegrityError):
|
||||
with transaction.atomic():
|
||||
Payment.objects.create(
|
||||
order=self.order, amount=Decimal("1000"), currency="IRR",
|
||||
gateway="mock", transaction_id="TXN-1", status="success",
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.payments.views import PaymentViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", PaymentViewSet, basename="payment")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,82 @@
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from rest_framework import decorators, mixins, response, status, viewsets
|
||||
|
||||
from apps.core.errors import error_response
|
||||
from apps.payments.gateways import get_gateway
|
||||
from apps.payments.models import Payment
|
||||
from apps.payments.serializers import PaymentSerializer
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class PaymentViewSet(
|
||||
mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
serializer_class = PaymentSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "invoice.view",
|
||||
"retrieve": "invoice.view",
|
||||
"create": "reservation.create",
|
||||
"verify": "invoice.create",
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = Payment.objects.select_related("order", "reservation").all()
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(Q(order__customer__user=user) | Q(reservation__customer__user=user))
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
order = serializer.validated_data.get("order")
|
||||
reservation = serializer.validated_data.get("reservation")
|
||||
if not order and not reservation:
|
||||
return response.Response(
|
||||
error_response("PAYMENT_TARGET_REQUIRED", "An order or reservation is required."),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
amount = serializer.validated_data["amount"]
|
||||
currency = order.currency if order else getattr(settings, "BASE_CURRENCY", "IRR")
|
||||
reference = order.order_number if order else f"RES-{str(reservation.id)[:8]}"
|
||||
|
||||
gateway = get_gateway()
|
||||
callback_url = request.build_absolute_uri("/api/v1/payments/callback/")
|
||||
result = gateway.request_payment(str(amount), currency, reference, callback_url)
|
||||
payment = Payment.objects.create(
|
||||
order=order,
|
||||
reservation=reservation,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
gateway=settings.PAYMENT_GATEWAY_PROVIDER or "mock",
|
||||
authority=result["authority"],
|
||||
status=Payment.Status.PENDING,
|
||||
request_time=timezone.now(),
|
||||
)
|
||||
return response.Response(
|
||||
{"id": str(payment.id), "authority": result["authority"], "redirect_url": result["redirect_url"]},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
@transaction.atomic
|
||||
def verify(self, request, pk=None):
|
||||
payment = Payment.objects.select_for_update().get(pk=pk)
|
||||
# Idempotent: a duplicate callback must not create a duplicate success.
|
||||
if payment.status == Payment.Status.SUCCESS:
|
||||
return response.Response(PaymentSerializer(payment).data)
|
||||
gateway = get_gateway()
|
||||
result = gateway.verify(payment.authority, str(payment.amount))
|
||||
payment.verification_time = timezone.now()
|
||||
if result.get("status") == "success":
|
||||
payment.status = Payment.Status.SUCCESS
|
||||
payment.transaction_id = result["transaction_id"]
|
||||
else:
|
||||
payment.status = Payment.Status.FAILED
|
||||
payment.error = result.get("error", "")
|
||||
payment.save()
|
||||
return response.Response(PaymentSerializer(payment).data)
|
||||
@@ -0,0 +1 @@
|
||||
# pricing app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PricingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.pricing"
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Backend-authoritative pricing engine.
|
||||
|
||||
Produces a full, explainable breakdown and is always the source of truth —
|
||||
the frontend may display estimates but must never be trusted for financial
|
||||
values.
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
CHARGE_TYPES = {"fixed", "per_kg", "per_unit", "percentage", "tiered", "fee"}
|
||||
|
||||
|
||||
class PricingEngine:
|
||||
def calculate(self, weight_kg, selected_steps, currency: str = "IRR") -> dict:
|
||||
weight = Decimal(str(weight_kg))
|
||||
lines = []
|
||||
subtotal = Decimal("0")
|
||||
|
||||
# Pass 1 — charge rules.
|
||||
for step in selected_steps:
|
||||
for rule in step.pricing_rules.filter(active=True, rule_type__in=CHARGE_TYPES).order_by("priority"):
|
||||
line = self._apply_charge_rule(rule, weight)
|
||||
if line is None:
|
||||
continue
|
||||
lines.append(line)
|
||||
subtotal += Decimal(line["amount"])
|
||||
|
||||
# Pass 2 — discounts (fixed or percentage of subtotal).
|
||||
discounts = Decimal("0")
|
||||
for step in selected_steps:
|
||||
for rule in step.pricing_rules.filter(active=True, rule_type="discount"):
|
||||
amt = Decimal(str(rule.amount))
|
||||
if (rule.config or {}).get("percentage"):
|
||||
amt = subtotal * amt / Decimal("100")
|
||||
discounts += amt
|
||||
lines.append(
|
||||
self._line(rule, weight, "discount", Decimal("0"), amt)
|
||||
)
|
||||
|
||||
# Pass 3 — taxes on (subtotal - discounts).
|
||||
taxes = Decimal("0")
|
||||
taxable = subtotal - discounts
|
||||
for step in selected_steps:
|
||||
for rule in step.pricing_rules.filter(active=True, rule_type="tax"):
|
||||
amt = taxable * Decimal(str(rule.amount)) / Decimal("100")
|
||||
taxes += amt
|
||||
lines.append(self._line(rule, weight, "tax", Decimal("0"), amt))
|
||||
|
||||
total = subtotal - discounts + taxes
|
||||
if total < 0:
|
||||
total = Decimal("0")
|
||||
|
||||
return {
|
||||
"currency": currency,
|
||||
"lines": lines,
|
||||
"subtotal": str(self._q(subtotal)),
|
||||
"discounts": str(self._q(discounts)),
|
||||
"taxes": str(self._q(taxes)),
|
||||
"total": str(self._q(total)),
|
||||
}
|
||||
|
||||
def _q(self, v: Decimal) -> Decimal:
|
||||
return v.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
def _line(self, rule, weight, kind, unit_price, amount, quantity=None) -> dict:
|
||||
return {
|
||||
"step_key": rule.step.key,
|
||||
"rule": rule.name,
|
||||
"kind": kind,
|
||||
"quantity": str(quantity if quantity is not None else weight),
|
||||
"unit_price": str(self._q(unit_price)),
|
||||
"amount": str(self._q(amount)),
|
||||
}
|
||||
|
||||
def _apply_charge_rule(self, rule, weight):
|
||||
kind = rule.rule_type
|
||||
cfg = rule.config or {}
|
||||
amount = Decimal(str(rule.amount))
|
||||
unit_price = Decimal("0")
|
||||
quantity = weight
|
||||
if kind == "fixed":
|
||||
unit_price = amount
|
||||
elif kind == "per_kg":
|
||||
unit_price = amount
|
||||
amount = amount * weight
|
||||
elif kind == "per_unit":
|
||||
units = Decimal(str(cfg.get("units", 1)))
|
||||
unit_price = amount
|
||||
amount = amount * units
|
||||
quantity = units
|
||||
elif kind == "percentage":
|
||||
base = Decimal(str(cfg.get("base", 0)))
|
||||
unit_price = amount
|
||||
amount = base * amount / Decimal("100")
|
||||
elif kind == "tiered":
|
||||
unit_price = self._tier_unit_price(cfg.get("tiers", []), weight)
|
||||
amount = unit_price * weight
|
||||
elif kind == "fee":
|
||||
unit_price = Decimal("0")
|
||||
else:
|
||||
return None
|
||||
|
||||
min_c = cfg.get("min_charge")
|
||||
max_c = cfg.get("max_charge")
|
||||
if min_c is not None and amount < Decimal(str(min_c)):
|
||||
amount = Decimal(str(min_c))
|
||||
if max_c is not None and amount > Decimal(str(max_c)):
|
||||
amount = Decimal(str(max_c))
|
||||
|
||||
return self._line(rule, weight, kind, unit_price, amount, quantity)
|
||||
|
||||
def _tier_unit_price(self, tiers, weight) -> Decimal:
|
||||
best = Decimal("0")
|
||||
for tier in tiers or []:
|
||||
low = Decimal(str(tier.get("from", 0)))
|
||||
if weight >= low:
|
||||
best = Decimal(str(tier.get("price", 0)))
|
||||
return best
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('workflows', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PricingRule',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('rule_type', models.CharField(choices=[('fixed', 'Fixed'), ('per_kg', 'Per kilogram'), ('per_unit', 'Per unit'), ('percentage', 'Percentage'), ('tiered', 'Tiered'), ('discount', 'Discount'), ('tax', 'Tax'), ('fee', 'Additional fee')], max_length=16)),
|
||||
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=18)),
|
||||
('config', models.JSONField(blank=True, default=dict)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('priority', models.IntegerField(default=0)),
|
||||
('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricing_rules', to='workflows.workflowstep')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['priority'],
|
||||
},
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user