Compare commits

...
28 Commits
Author SHA1 Message Date
javad a28ac2167c fix refresh token 2026-10-09 11:33:00 +03:30
javad 4dc6d98abf fix order 2026-10-09 11:05:10 +03:30
javad ae4dbab3cc fix bug sms 2026-10-09 10:32:27 +03:30
javad 48d0ecba79 fix login and jwt 2026-10-09 10:07:31 +03:30
javad 56d8760613 migration repaire 2026-10-08 18:09:12 +03:30
javad c8d45dd07c add product 2026-10-08 16:37:45 +03:30
javad 423d4cf40e edit production media root and url 2026-06-21 08:36:26 +03:30
javad 2dde1c8a0f edit storage in settings 2026-06-21 08:18:08 +03:30
javad 5e29e9d902 base settings edit url 2026-06-21 07:52:55 +03:30
javad c4e1444452 fix production.py 2026-06-20 19:47:13 +03:30
javad 05037793e1 fix production.py 2026-06-20 19:40:45 +03:30
javad 9c2c2d91de fix production.py 2026-06-20 19:33:55 +03:30
javad b1023a161f fix production.py 2026-06-20 19:26:04 +03:30
javad 46971f67e9 fix entrypoint 2026-06-20 19:19:25 +03:30
javad c7577185a0 fix production.py 2026-06-20 19:15:46 +03:30
javad 2db88970ed config fix for staticfiles 2026-06-20 19:03:31 +03:30
javad 550c113f7f dockerfile 2026-06-20 18:38:11 +03:30
javad 708d24c613 dockerfile 2026-06-20 18:26:05 +03:30
javad 8941fded53 dockerfile| 2026-06-20 18:17:43 +03:30
javad 0d0c2452c8 order to excel for all 2025-06-29 12:02:45 +03:30
javad 813e5055b2 order view for all 2025-06-28 15:12:07 +03:30
javad 076ba42ab2 orders for all 2025-06-28 15:04:27 +03:30
javad fc914d6f6d accsess all to list customer 2025-06-24 14:10:08 +03:30
javad db708a662e lead accsess for all operator 2024-09-30 09:04:15 +03:30
javad 5e396c0e32 Add Lead Event and Send SMS 2024-08-27 12:04:27 +03:30
mohammad javad fesahat 43f5dd4594 before tehran 2024-08-24 16:00:11 +03:30
mohammad javad fesahat ef18434b86 before change Ticket Desc to TextArea 2024-07-25 09:06:30 +03:30
javad f7dbd02a6c small changes 2024-02-07 10:40:45 +03:30
165 changed files with 6195 additions and 610 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.git
.gitignore
__pycache__/
*.pyc
venv/
.venv/
+2
View File
@@ -7,6 +7,8 @@ __pycache__/
# C extensions
*.so
*.xlsx
# Distribution / packaging
.Python
build/
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# GitHub Copilot persisted chat sessions
/copilot/chatSessions
Generated
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="accountSettings">
<option name="activeProfile" value="profile:default" />
<option name="activeRegion" value="us-east-1" />
<option name="recentlyUsedProfiles">
<list>
<option value="profile:default" />
</list>
</option>
<option name="recentlyUsedRegions">
<list>
<option value="us-east-1" />
</list>
</option>
</component>
<component name="connectionManager">
<option name="activeConnectionId" value="sso;us-east-1;https://view.awsapps.com/start" />
</component>
</project>
+5 -2
View File
@@ -13,8 +13,11 @@
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.10" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
+4 -1
View File
@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="crmApiEnv" project-jdk-type="Python SDK" />
<component name="Black">
<option name="sdkName" value="Python 3.12 (venv)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10" project-jdk-type="Python SDK" />
</project>
+166 -18
View File
@@ -1,14 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="87c5db51-3e89-4a98-9bbb-121c9c52ab3c" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/cuser/cuser_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/apps/cuser/cuser_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/cuser/models.py" beforeDir="false" afterPath="$PROJECT_DIR$/apps/cuser/models.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/customer/services.py" beforeDir="false" afterPath="$PROJECT_DIR$/apps/customer/services.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/customer/urls.py" beforeDir="false" afterPath="$PROJECT_DIR$/apps/customer/urls.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/customer/views/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/apps/customer/views/__init__.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/customer/views/customer.py" beforeDir="false" afterPath="$PROJECT_DIR$/apps/customer/views/customer.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -31,23 +28,35 @@
<component name="MarkdownSettingsMigration">
<option name="stateVersion" value="1" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="2SvKNIPBcTXVjDKPSdS8NHW9I0w" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;WebServerToolWindowFactoryState&quot;: &quot;false&quot;,
&quot;last_opened_file_path&quot;: &quot;E:/Projects/crmProject/Project/api-Project/crmApi&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Django Server.crmApi.executor": "Run",
"RunOnceActivity.OpenDjangoStructureViewOnStart": "true",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
"WebServerToolWindowFactoryState": "false",
"git-widget-placeholder": "main",
"ignore.virus.scanning.warn.message": "true",
"last_opened_file_path": "C:/Users/mjavad/Projects/crmProject/Project/api-Project/crmApi",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.pluginManager",
"vue.rearranger.settings.migration": "true"
}
}</component>
}]]></component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="E:\Projects\crmProject\Project\api-Project\crmApi\utils" />
@@ -57,13 +66,14 @@
<component name="RunManager">
<configuration name="crmApi" type="Python.DjangoServer" factoryName="Django server">
<module name="crmApi" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="WORKING_DIRECTORY" value="C:\Users\mjavad\Projects\crmProject\Project\api-Project\crmApi" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
@@ -79,6 +89,14 @@
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-1d06a55b98c1-74d2a5396914-JavaScript-PY-241.14494.241" />
<option value="bundled-python-sdk-0509580d9d50-28c9f5db9ffe-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-241.14494.241" />
</set>
</attachedChunks>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
@@ -96,7 +114,49 @@
<workItem from="1697269439761" duration="66295000" />
<workItem from="1702900258020" duration="63755000" />
<workItem from="1704088148329" duration="105568000" />
<workItem from="1704951046597" duration="99812000" />
<workItem from="1704951046597" duration="135372000" />
<workItem from="1706950546279" duration="21524000" />
<workItem from="1707974605580" duration="43207000" />
<workItem from="1710141658112" duration="240000" />
<workItem from="1710142130443" duration="1078000" />
<workItem from="1710150399673" duration="961000" />
<workItem from="1710419485683" duration="68525000" />
<workItem from="1710488609275" duration="177000" />
<workItem from="1710488798818" duration="2376000" />
<workItem from="1710491283739" duration="274000" />
<workItem from="1710491571470" duration="2407000" />
<workItem from="1710859420770" duration="5021000" />
<workItem from="1711518197148" duration="371000" />
<workItem from="1711518633367" duration="2351000" />
<workItem from="1711525074800" duration="90000" />
<workItem from="1711525175741" duration="553000" />
<workItem from="1711525746440" duration="138000" />
<workItem from="1711525904502" duration="187000" />
<workItem from="1711526396806" duration="5937000" />
<workItem from="1711602888442" duration="717000" />
<workItem from="1711606336704" duration="5357000" />
<workItem from="1711869672173" duration="628000" />
<workItem from="1711878111438" duration="700000" />
<workItem from="1711880635851" duration="981000" />
<workItem from="1712055178474" duration="84000" />
<workItem from="1713166182071" duration="22113000" />
<workItem from="1714581267648" duration="6000" />
<workItem from="1714884894390" duration="1477000" />
<workItem from="1715065792878" duration="1238000" />
<workItem from="1715426896536" duration="10640000" />
<workItem from="1719038043515" duration="2436000" />
<workItem from="1720441183640" duration="1046000" />
<workItem from="1720442591368" duration="302000" />
<workItem from="1721885637579" duration="1931000" />
<workItem from="1722924760522" duration="1012000" />
<workItem from="1724485712064" duration="3533000" />
<workItem from="1724504428300" duration="205000" />
<workItem from="1724504644170" duration="64000" />
<workItem from="1724504724350" duration="43000" />
<workItem from="1724504786406" duration="1429000" />
<workItem from="1724678669565" duration="55000" />
<workItem from="1724678749300" duration="2110000" />
<workItem from="1727674001765" duration="421000" />
</task>
<servers />
</component>
@@ -114,4 +174,92 @@
</map>
</option>
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/shop/views/excel_import.py</url>
<line>16</line>
<option name="timeStamp" value="2" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/shop/views/excel_import.py</url>
<line>19</line>
<option name="timeStamp" value="3" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/shop/views/excel_import.py</url>
<line>21</line>
<option name="timeStamp" value="4" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/shop/views/excel_import.py</url>
<line>23</line>
<option name="timeStamp" value="5" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/shop/views/excel_import.py</url>
<line>29</line>
<option name="timeStamp" value="6" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/shop/views/excel_import.py</url>
<line>30</line>
<option name="timeStamp" value="7" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/shop/views/excel_import.py</url>
<line>39</line>
<option name="timeStamp" value="8" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/jwt_auth/services.py</url>
<line>14</line>
<option name="timeStamp" value="13" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/jwt_auth/services.py</url>
<line>17</line>
<option name="timeStamp" value="14" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/jwt_auth/services.py</url>
<line>19</line>
<option name="timeStamp" value="15" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/jwt_auth/services.py</url>
<line>20</line>
<option name="timeStamp" value="16" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/jwt_auth/services.py</url>
<line>37</line>
<option name="timeStamp" value="17" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/jwt_auth/services.py</url>
<line>38</line>
<option name="timeStamp" value="18" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/jwt_auth/views.py</url>
<line>116</line>
<option name="timeStamp" value="19" />
</line-breakpoint>
<line-breakpoint enabled="true" suspend="THREAD" type="python-line">
<url>file://$PROJECT_DIR$/apps/jwt_auth/views.py</url>
<line>118</line>
<option name="timeStamp" value="20" />
</line-breakpoint>
</breakpoints>
<default-breakpoints>
<breakpoint type="python-exception">
<properties notifyOnTerminate="true" exception="BaseException">
<option name="notifyOnTerminate" value="true" />
</properties>
</breakpoint>
</default-breakpoints>
</breakpoint-manager>
</component>
</project>
+39
View File
@@ -0,0 +1,39 @@
# ripwire quality acks v1 — written by --quality-ack; a finding stays suppressed until it worsens past its acked magnitude
# format: ack <kind> <16-hex-key> <ackNow> [cid=<16-hex-content-id>] [by=<scope that acked it>] <reason to end of line> — one per line, kept SORTED by (kind,key) on every write (merge-friendly)
ack duplication 07b21eccba4b853f 90 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 0808552a2cb156e2 64 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 1c54979316d47755 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 1ebf7141892dce85 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 36c53d31427d737f 77 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 36dd407bf7995740 61 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 43ffa97050be9c59 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 46611f8550817a04 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 58bb2dbc45626a89 77 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 5ce836b9523750f3 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 60368ec2cee5e29e 21 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 62c88879e07b9e3b 73 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 677faf5f36583e91 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 68c7d3f08353288f 207 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 6feffa5e3876b705 53 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 72f5e86186615047 59 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 760e5582f9cac278 38 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 764331dacd10a89e 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 816088d74742a3bd 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication 85a85b24c763964c 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 8c9a9e87785f4880 55 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 95149d1a0d230890 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication 976339e40ee6a2cb 44 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication ae5d16bb65bfd38b 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication b4969500bb084d10 77 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication bc3635ebb8739295 84 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication bdfe7d60141f2ce6 73 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication be4e5df94b1b82e5 100 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication c1c4b99cde75e374 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication c8109dee754a200e 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication c9f7a6966acae775 48 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication cbb16dac41720d6a 88 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication cfb7f7a3a970d0b3 63 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication d0993f4aa7ad017b 81 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack duplication e4d6ebd79eb27cc3 74 Retained the project DRF serializer/response scaffolding for distinct endpoint contracts; covered by integration regressions. No shared generic dispatcher introduced.
ack duplication efc7a9030246fb5d 71 Intentional DRF request/serializer/response boundaries and 21-token compatibility delete wrappers; shared domain validation, save/delete and import lifecycle are extracted and integration-tested. Distinct endpoints remain separate.
ack short-horizon-churn 89776c50b7500989 4 cid=4c88db074226651a Intentional authentication backend replacement to enforce recorded session validity; recent-edit churn is expected for this setting.
+67
View File
@@ -0,0 +1,67 @@
# ==========================================
# Stage 1: Build dependencies
# ==========================================
FROM python:3.11-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# بهینه‌سازی ۱: استفاده از کش سیستم‌عامل برای apt
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
# بهینه‌سازی ۲: استفاده از مکانیزم کش BuildKit برای pip
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --default-timeout=1000 -i https://package-mirror.liara.ir/repository/pypi/simple -r requirements.txt
# ==========================================
# Stage 2: Final Production Image
# ==========================================
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
# بهینه‌سازی ۳: ساخت کاربر سیستم غیر ریشه در بالاترین بخش برای حفظ کِش داکر
RUN useradd -m -u 1000 django
# بهینه‌سازی ۴: استفاده از کش apt و ساخت دایرکتوری‌ها به صورت یکپارچه
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
supervisor && \
mkdir -p /app/staticfiles /app/mediafiles /etc/supervisor /var/log/supervisor && \
chown -R django:django /app /etc/supervisor /var/log/supervisor
# کپی کردن و واگذاری مالکیت محیط مجازی به کاربر django
COPY --from=builder --chown=django:django /opt/venv /opt/venv
# کپی کردن تمام کدهای پروژه با مالکیت کاربر محدود شده
COPY --chown=django:django . .
# حل مشکل خطای Permission Denied اسکریپت ورود
RUN chmod +x /app/entrypoint.sh
# سوئیچ به کاربر غیر ریشه برای امنیت پروداکشن
USER django
# اکسپوز پورت وب‌سرور برای وب‌پروکسی Coolify
EXPOSE 8000
# نقطه ورود اصلی کانتینر
ENTRYPOINT ["/app/entrypoint.sh"]
+41 -8
View File
@@ -1,4 +1,7 @@
from utils.admin_base import CRMModelAdmin
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from apps.cuser.forms import CUserCreationForm, CUserChangeForm
from apps.cuser.models import (
CUser as CUserModel,
CuserRole as CuserRoleModel,
@@ -8,20 +11,50 @@ from apps.cuser.models import (
# Register your models here.
class CUserAdmin(admin.ModelAdmin):
pass
class CUserAdmin(UserAdmin):
list_per_page = 50
save_on_top = True
date_hierarchy = 'created_at'
form = CUserChangeForm
add_form = CUserCreationForm
list_display = ('username', 'name_family', 'mobile', 'is_active', 'is_staff')
search_fields = ('username', 'name_family', 'mobile')
ordering = ('username',)
readonly_fields = ('last_login', 'created_at', 'updated_at')
fieldsets = (
(None, {'fields': ('username', 'password')}),
('اطلاعات کاربر', {'fields': ('name_family', 'mobile')}),
('دسترسی', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
('تاریخ‌ها', {'fields': ('last_login', 'created_at', 'updated_at')}),
)
add_fieldsets = (
(None, {'classes': ('wide',), 'fields': ('username', 'name_family', 'mobile', 'password1', 'password2', 'is_active', 'is_staff')}),
)
class CUserRoleAdmin(admin.ModelAdmin):
pass
class CUserRoleAdmin(CRMModelAdmin):
list_display = ('id', 'cuser', 'name_family', 'mobile', 'role_type', 'sys_ws', 'is_active', 'customer_panel', 'is_admin')
search_fields = ('name_family', 'mobile', 'cuser__username')
readonly_fields = ('created_at', 'updated_at')
class RoleTypeAdmin(admin.ModelAdmin):
pass
class RoleTypeAdmin(CRMModelAdmin):
list_display = ('id', 'role_name', 'role_name_en', 'created_at')
class SmsOptAdmin(admin.ModelAdmin):
pass
class SmsOptAdmin(CRMModelAdmin):
list_display = ("id", "mobile", "expire", "created_at")
exclude = ("opt",)
sensitive_fields = ("opt",)
def get_readonly_fields(self, request, obj=None):
return ("mobile", "expire", "created_at", "updated_at")
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
admin.site.register(CUserModel, CUserAdmin)
+13 -2
View File
@@ -1,6 +1,8 @@
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from utils.base_model import BaseModel
from django.core.exceptions import ValidationError
from utils.validations.mobile import normalize_mobile
import uuid
@@ -8,9 +10,11 @@ class CUserManager(BaseUserManager):
def create_user(self, username, password, **extra_fields):
if not username:
raise ValueError("Please Enter username")
extra_fields['mobile'] = normalize_mobile(extra_fields.get('mobile'))
user = self.model(username=username, **extra_fields)
user.set_password(password)
user.save()
user.full_clean()
user.save(using=self._db)
return user
def create_superuser(self, username, password, **extra_fields):
@@ -24,13 +28,14 @@ class CUserManager(BaseUserManager):
class CUser(BaseModel, AbstractBaseUser, PermissionsMixin):
username = models.CharField(max_length=50, null=False, blank=False, unique=True)
mobile = models.PositiveBigIntegerField(null=False, blank=False, unique=True)
name_family = models.CharField(max_length=50, null=True, blank=True)
name_family = models.CharField(max_length=100, null=True, blank=True)
is_active = models.BooleanField(default=False)
is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['mobile']
objects = CUserManager()
@@ -44,3 +49,9 @@ class CUser(BaseModel, AbstractBaseUser, PermissionsMixin):
def __str__(self):
return f'{self.name_family} - {self.username}'
def clean(self):
super().clean()
self.mobile = normalize_mobile(self.mobile)
if type(self).objects.filter(mobile=self.mobile).exclude(pk=self.pk).exists():
raise ValidationError({"mobile": "کاربری با این شماره موبایل قبلاً ثبت شده است."})
+30
View File
@@ -0,0 +1,30 @@
from django import forms
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.core.exceptions import ValidationError
from apps.cuser.cuser_model import CUser
from utils.validations.mobile import normalize_mobile
class MobileFormMixin:
def clean_mobile(self):
try:
return normalize_mobile(self.cleaned_data['mobile'])
except ValidationError as error:
raise forms.ValidationError(error.messages)
class CUserCreationForm(MobileFormMixin, UserCreationForm):
mobile = forms.CharField(label='شماره موبایل')
class Meta(UserCreationForm.Meta):
model = CUser
fields = ('username', 'name_family', 'mobile', 'is_active', 'is_staff')
class CUserChangeForm(MobileFormMixin, UserChangeForm):
mobile = forms.CharField(label='شماره موبایل')
class Meta(UserChangeForm.Meta):
model = CUser
fields = '__all__'
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-04-16 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cuser', '0011_cuserrole_customer_panel'),
]
operations = [
migrations.AddField(
model_name='cuserrole',
name='is_admin',
field=models.BooleanField(blank=True, default=None, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-07-25 05:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cuser', '0012_cuserrole_is_admin'),
]
operations = [
migrations.AlterField(
model_name='cuser',
name='name_family',
field=models.CharField(blank=True, max_length=100, null=True),
),
]
+1
View File
@@ -26,6 +26,7 @@ class CuserRole(BaseModel):
mobile = models.PositiveBigIntegerField(null=False, blank=False)
mobile_verify = models.BooleanField(default=False)
customer_panel = models.BooleanField(default=None, blank=True, null=True)
is_admin = models.BooleanField(default=None, blank=True, null=True)
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=True, blank=True, default=None)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
+7 -1
View File
@@ -3,15 +3,21 @@ from apps.cuser.models import CuserRole as CuserRoleModel
from utils.get_cuser_role import get_cuser_role_uuid
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
# Create your views here.
class CUserGetInfo(views.APIView):
permission_classes = (IsAuthenticated,)
def get(self, request):
cuser_role_uuid = get_cuser_role_uuid(request)
cuser_obj = CuserRoleModel.objects.get(uuid=cuser_role_uuid)
return Response(data={"name_family": cuser_obj.name_family}, status=status.HTTP_200_OK)
return Response(data={"name_family": cuser_obj.name_family,
"role": cuser_obj.role_type.role_name_en,
"workspace": str(cuser_obj.sys_ws.uuid),
"permissions": {"manage_products": cuser_obj.role_type.role_name_en in ('workspace_admin', 'workspace_owner')}},
status=status.HTTP_200_OK)
+38 -7
View File
@@ -1,5 +1,7 @@
from utils.admin_base import CRMModelAdmin
from django.contrib import admin
from apps.customer.models import (
MobileConflict,
Customer as CustomerModel,
CustomerNote as CustomerNoteModel,
ShiveAshenaei as ShiveAshenaeiModel,
@@ -11,36 +13,65 @@ from apps.customer.models import (
# Register your models here.
@admin.register(MobileConflict)
class MobileConflictAdmin(CRMModelAdmin):
list_display = ('source_model', 'record_id', 'workspace_id', 'original_mobile', 'canonical_mobile', 'created_at')
list_filter = ('source_model', 'workspace_id')
search_fields = ('=record_id', '=original_mobile', '=canonical_mobile')
readonly_fields = ('source_model', 'record_id', 'workspace_id', 'original_mobile', 'canonical_mobile', 'snapshot', 'created_at')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
class CustomerNoteInline(admin.TabularInline):
model = CustomerNoteModel
extra = 0
fields = ["desc","cuser_role","created_at"]
readonly_fields = fields
can_delete = False
show_change_link = True
def has_add_permission(self, request, obj=None):
return False
@admin.register(CustomerModel)
class CustomerAdmin(admin.ModelAdmin):
class CustomerAdmin(CRMModelAdmin):
pass
@admin.register(CustomerNoteModel)
class CustomerNoteAdmin(admin.ModelAdmin):
class CustomerNoteAdmin(CRMModelAdmin):
pass
@admin.register(ShiveAshenaeiModel)
class ShiveAshenaeiAdmin(admin.ModelAdmin):
class ShiveAshenaeiAdmin(CRMModelAdmin):
pass
@admin.register(ResponsibleGroupModel)
class ResponsibleGroupAdmin(admin.ModelAdmin):
class ResponsibleGroupAdmin(CRMModelAdmin):
pass
@admin.register(CustomerResponsibleModel)
class CustomerResponsibleAdmin(admin.ModelAdmin):
class CustomerResponsibleAdmin(CRMModelAdmin):
pass
@admin.register(CustomerGroupTypeModel)
class CustomerGroupTypeAdmin(admin.ModelAdmin):
class CustomerGroupTypeAdmin(CRMModelAdmin):
pass
@admin.register(CustomerGroupModel)
class CustomerGroupAdmin(admin.ModelAdmin):
class CustomerGroupAdmin(CRMModelAdmin):
pass
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,100 @@
"""Read-only diagnostics for the legacy mobile uniqueness migration."""
import json
from collections import defaultdict
from importlib import import_module
from django.core.management.base import BaseCommand, CommandError
from django.db import connections
from apps.cuser.cuser_model import CUser
from apps.customer.models import Customer
canonical_mobile = import_module(
'apps.customer.migrations.0008_customer_mobile_unique'
).canonical_mobile
def _duplicate_groups(model, alias, per_workspace=False):
"""Use the migration's exact comparison, including country-prefix collisions."""
groups = defaultdict(list)
fields = ['pk', 'mobile'] + (['sys_ws_id'] if per_workspace else [])
for row in model.objects.using(alias).order_by('pk').values(*fields).iterator():
mobile = canonical_mobile(row['mobile'])
if mobile is None:
continue
key = (row['sys_ws_id'], mobile) if per_workspace else (None, mobile)
groups[key].append(row['pk'])
return [
{'workspace_id': workspace, 'canonical_mobile': mobile, 'record_ids': ids}
for (workspace, mobile), ids in groups.items() if len(ids) > 1
]
def _related_counts(record, alias):
"""Count direct foreign-key dependents without reading their private contents."""
counts = {}
for relation in record._meta.related_objects:
if relation.one_to_many or relation.one_to_one:
label = f'{relation.related_model._meta.label}.{relation.field.name}'
counts[label] = relation.related_model._default_manager.using(alias).filter(
**{relation.field.attname: record.pk}
).count()
return counts
def _customer_details(ids, alias):
rows = Customer.objects.using(alias).filter(pk__in=ids).select_related(
'sys_ws', 'cuser_role__cuser', 'cuser_role__role_type'
).order_by('pk')
details = []
for row in rows:
role = row.cuser_role
details.append({
'id': row.pk, 'name_family': row.name_family,
'mobile': row.mobile, 'canonical_mobile': canonical_mobile(row.mobile),
'workspace_id': row.sys_ws_id, 'workspace_name': row.sys_ws.name,
'panel_role': None if role is None else {
'id': role.pk, 'user_id': role.cuser_id, 'workspace_id': role.sys_ws_id,
'role': role.role_type.role_name_en, 'mobile': role.mobile,
'user_mobile': role.cuser.mobile if role.cuser_id else None,
'is_active': role.is_active, 'customer_panel': role.customer_panel,
},
'related_counts': _related_counts(row, alias),
})
return details
class Command(BaseCommand):
help = 'Report legacy Customer/CUser mobile conflicts; never modify or delete records.'
# Must work before migrations, without checking unrelated model declarations.
requires_system_checks = []
def add_arguments(self, parser):
parser.add_argument('--database', default='default')
parser.add_argument('--customer-ids', nargs='+', type=int, default=[])
def handle(self, *args, **options):
alias = options['database']
if alias not in connections:
raise CommandError(f'Unknown database alias: {alias}')
customer_groups = _duplicate_groups(Customer, alias, per_workspace=True)
user_groups = _duplicate_groups(CUser, alias)
customer_ids = set(options['customer_ids'])
customer_ids.update(pk for group in customer_groups for pk in group['record_ids'])
user_ids = [pk for group in user_groups for pk in group['record_ids']]
report = {
'read_only': True, 'database': alias,
'duplicate_group_count': len(customer_groups) + len(user_groups),
'customer_duplicate_groups': customer_groups,
'user_duplicate_groups': user_groups,
'customers': _customer_details(customer_ids, alias),
'missing_requested_customer_ids': sorted(customer_ids - set(
Customer.objects.using(alias).filter(pk__in=customer_ids).values_list('pk', flat=True)
)),
'users': [dict(row, canonical_mobile=canonical_mobile(row['mobile'])) for row in
CUser.objects.using(alias).filter(pk__in=user_ids).order_by('pk').values(
'id', 'username', 'name_family', 'mobile', 'is_active'
)],
}
self.stdout.write(json.dumps(report, ensure_ascii=False, indent=2))
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-07-25 05:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customer', '0006_alter_customernote_options'),
]
operations = [
migrations.AlterField(
model_name='customer',
name='name_family',
field=models.CharField(max_length=100),
),
]
@@ -0,0 +1,69 @@
from django.db import migrations, models
from apps.customer.mobile_migration_repair import repair_conflicts
def canonical_mobile(value):
if value is None:
return None
text = str(value)
if len(text) == 12 and text.startswith('98'):
return int(text[2:])
return value
def normalized_rows(model, alias, per_workspace=False, skip_ids=None):
seen = {}
updates = []
fields = ('pk', 'mobile', 'sys_ws_id') if per_workspace else ('pk', 'mobile')
for row in model.objects.using(alias).only(*fields).iterator():
if skip_ids and row.pk in skip_ids:
continue
mobile = canonical_mobile(row.mobile)
key = (row.sys_ws_id, mobile) if per_workspace else mobile
if mobile is not None and key in seen:
raise RuntimeError(
f'Duplicate mobiles in {model._meta.label}: record IDs {seen[key]} and {row.pk}. '
'Resolve these records before migration. No records were deleted.'
)
if mobile is not None:
seen[key] = row.pk
if mobile != row.mobile:
row.mobile = mobile
updates.append(row)
return updates
def check_and_normalize_mobiles(apps, schema_editor):
alias = schema_editor.connection.alias
customer = apps.get_model('customer', 'Customer')
user = apps.get_model('cuser', 'CUser')
role = apps.get_model('cuser', 'CuserRole')
# Quarantine legacy conflicts automatically, retaining a persistent audit.
skipped_users = repair_conflicts(apps, schema_editor, canonical_mobile)
customer_updates = normalized_rows(customer, alias, per_workspace=True)
user_updates = normalized_rows(user, alias, skip_ids=skipped_users)
role_updates = []
for row in role.objects.using(alias).only('pk', 'mobile').iterator():
normalized = canonical_mobile(row.mobile)
if normalized != row.mobile:
row.mobile = normalized
role_updates.append(row)
customer.objects.using(alias).bulk_update(customer_updates, ['mobile'], batch_size=500)
user.objects.using(alias).bulk_update(user_updates, ['mobile'], batch_size=500)
role.objects.using(alias).bulk_update(role_updates, ['mobile'], batch_size=500)
if schema_editor.connection.vendor == 'postgresql':
# Drain deferred FK checks from panel detachment before AddConstraint
# alters the same table in this atomic migration.
with schema_editor.connection.cursor() as cursor:
cursor.execute('SET CONSTRAINTS ALL IMMEDIATE')
class Migration(migrations.Migration):
dependencies = [('customer', '0007_alter_customer_name_family'), ('cuser', '0013_alter_cuser_name_family')]
operations = [
migrations.RunPython(check_and_normalize_mobiles, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='customer',
constraint=models.UniqueConstraint(fields=('sys_ws', 'mobile'), name='customer_workspace_mobile_unique'),
),
]
@@ -0,0 +1,37 @@
from django.db import migrations, models
from django.utils import timezone
class CreateAuditModel(migrations.CreateModel):
"""0008 can create this audit table before repairing legacy data.
Existing installations that already applied 0008 create it here instead.
Keep audit history on rollback; it is evidence of irreversible data repairs.
"""
def database_forwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.name)
if model._meta.db_table not in schema_editor.connection.introspection.table_names():
super().database_forwards(app_label, schema_editor, from_state, to_state)
def database_backwards(self, app_label, schema_editor, from_state, to_state):
pass
class Migration(migrations.Migration):
dependencies = [('customer', '0008_customer_mobile_unique')]
operations = [CreateAuditModel(
name='MobileConflict',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_model', models.CharField(max_length=100)),
('record_id', models.PositiveBigIntegerField()),
('workspace_id', models.PositiveBigIntegerField(null=True, blank=True)),
('original_mobile', models.PositiveBigIntegerField(null=True, blank=True)),
('canonical_mobile', models.PositiveBigIntegerField()),
('snapshot', models.JSONField()),
('created_at', models.DateTimeField(default=timezone.now, editable=False)),
],
options={'ordering': ['-created_at', '-pk'],
'unique_together': (('source_model', 'record_id'),),
'verbose_name': 'تعارض شماره قدیمی', 'verbose_name_plural': 'تعارض‌های شماره قدیمی'},
)]
+88
View File
@@ -0,0 +1,88 @@
"""Frozen, historical-model-only repair used by migration 0008."""
from collections import defaultdict
from importlib import import_module
import logging
from django.db.migrations.state import ProjectState
def _groups(model, alias, canonical, workspace=False):
groups = defaultdict(list)
for row in model.objects.using(alias).order_by('pk').iterator():
mobile = canonical(row.mobile)
if mobile is not None:
groups[(row.sys_ws_id if workspace else None, mobile)].append(row)
return {key: rows for key, rows in groups.items() if len(rows) > 1}
def _audit_model(schema_editor):
# Render the frozen 0009 schema; never depend on future live model fields.
operation = import_module('apps.customer.migrations.0009_mobileconflict').Migration.operations[0]
state = ProjectState()
operation.state_forwards('customer', state)
model = state.apps.get_model('customer', 'MobileConflict')
if model._meta.db_table not in schema_editor.connection.introspection.table_names():
schema_editor.create_model(model)
return model
def _archive(audit, alias, row, mobile, snapshot, workspace=None):
audit.objects.using(alias).get_or_create(
source_model=row._meta.label, record_id=row.pk,
defaults={'workspace_id': workspace, 'original_mobile': row.mobile,
'canonical_mobile': mobile, 'snapshot': snapshot},
)
def repair_conflicts(apps, schema_editor, canonical):
"""Quarantine ambiguous identities; never choose an owner or merge/delete rows."""
alias = schema_editor.connection.alias
customer = apps.get_model('customer', 'Customer')
user = apps.get_model('cuser', 'CUser')
role = apps.get_model('cuser', 'CuserRole')
if schema_editor.connection.vendor == 'postgresql':
# This migration is atomic. Prevent legacy writers inserting new conflicts
# between the audit and the unique constraint, including empty tables.
tables = ', '.join(schema_editor.connection.ops.quote_name(model._meta.db_table)
for model in (customer, user, role))
with schema_editor.connection.cursor() as cursor:
cursor.execute(f'LOCK TABLE {tables} IN SHARE ROW EXCLUSIVE MODE')
customer_groups = _groups(customer, alias, canonical, workspace=True)
user_groups = _groups(user, alias, canonical)
if not customer_groups and not user_groups:
return set()
audit = _audit_model(schema_editor)
for (workspace, mobile), rows in customer_groups.items():
ids = [row.pk for row in rows]
for row in rows:
roles = list(role.objects.using(alias).filter(pk=row.cuser_role_id).values(
'id', 'cuser_id', 'sys_ws_id', 'mobile', 'is_active', 'customer_panel'
))
_archive(audit, alias, row, mobile, {
'reason': 'duplicate_customer_mobile', 'conflicting_record_ids': ids,
'name_family': row.name_family, 'cuser_role_id': row.cuser_role_id,
'roles': roles, 'action': 'clear_mobile_and_detach_panel',
}, workspace)
role_ids = [row.cuser_role_id for row in rows if row.cuser_role_id is not None]
role.objects.using(alias).filter(pk__in=role_ids).update(customer_panel=False)
customer.objects.using(alias).filter(pk__in=ids).update(mobile=None, cuser_role_id=None)
skipped_users = set()
for (_, mobile), rows in user_groups.items():
ids = [row.pk for row in rows]
for row in rows:
_archive(audit, alias, row, mobile, {
'reason': 'duplicate_account_mobile', 'conflicting_record_ids': ids,
'username': row.username, 'name_family': row.name_family, 'is_active': row.is_active,
'roles': list(role.objects.using(alias).filter(cuser_id=row.pk).values(
'id', 'sys_ws_id', 'mobile', 'is_active', 'customer_panel'
)), 'action': 'disable_ambiguous_accounts_preserve_mobile',
})
user.objects.using(alias).filter(pk__in=ids).update(is_active=False)
role.objects.using(alias).filter(cuser_id__in=ids).update(is_active=False, customer_panel=False)
skipped_users.update(ids)
logging.getLogger(__name__).warning(
'Legacy mobile conflicts quarantined: %s customers, %s accounts. '
'No records deleted. Review customer.MobileConflict in Django Admin.',
sum(len(rows) for rows in customer_groups.values()), len(skipped_users),
)
return skipped_users
+33 -1
View File
@@ -3,9 +3,30 @@ from utils.base_model import BaseModel
from apps.workspace.models import City as CityModel, Workspace as WorkspaceModel
from apps.cuser.models import CuserRole as CuserRoleModel
from django.core.exceptions import ValidationError
from utils.validations.mobile import normalize_mobile
from django.utils import timezone
# Create your models here.
class MobileConflict(models.Model):
source_model = models.CharField(max_length=100)
record_id = models.PositiveBigIntegerField()
workspace_id = models.PositiveBigIntegerField(null=True, blank=True)
original_mobile = models.PositiveBigIntegerField(null=True, blank=True)
canonical_mobile = models.PositiveBigIntegerField()
snapshot = models.JSONField()
created_at = models.DateTimeField(default=timezone.now, editable=False)
class Meta:
ordering = ['-created_at', '-pk']
unique_together = (('source_model', 'record_id'),)
verbose_name = 'تعارض شماره قدیمی'
verbose_name_plural = 'تعارض‌های شماره قدیمی'
def __str__(self):
return f'{self.source_model} #{self.record_id}: {self.original_mobile}'
class ShiveAshenaei(BaseModel):
name = models.CharField(max_length=15, null=False, blank=False)
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False)
@@ -49,7 +70,7 @@ class CustomerGroup(BaseModel):
class Customer(BaseModel):
name_family = models.CharField(max_length=25, null=False, blank=False)
name_family = models.CharField(max_length=100, null=False, blank=False)
mobile = models.PositiveBigIntegerField(null=True, blank=True)
phone = models.PositiveBigIntegerField(null=True, blank=True)
address = models.CharField(max_length=100, null=True, blank=True)
@@ -61,13 +82,24 @@ class Customer(BaseModel):
class Meta:
ordering = ['-created_at']
constraints = [
models.UniqueConstraint(fields=['sys_ws', 'mobile'], name='customer_workspace_mobile_unique'),
]
def __str__(self):
return str(self.name_family)
def clean(self):
super().clean()
if self.mobile is not None:
self.mobile = normalize_mobile(self.mobile)
if type(self).objects.filter(sys_ws_id=self.sys_ws_id, mobile=self.mobile).exclude(pk=self.pk).exists():
raise ValidationError({"mobile": "مشتری با این شماره موبایل در این فضای کاری قبلاً ثبت شده است."})
if self.cuser_role and not self.cuser_role.role_type.role_name_en == 'workspace_customer':
raise ValidationError("cuser_role type must be customer")
if self.cuser_role and (self.cuser_role.sys_ws_id != self.sys_ws_id or not self.cuser_role.cuser_id
or self.cuser_role.cuser.mobile != self.mobile):
raise ValidationError({'cuser_role': 'حساب پنل باید متعلق به همین شماره موبایل و فضای کاری باشد.'})
class CustomerNote(BaseModel):
+3 -4
View File
@@ -77,10 +77,10 @@ def list_customer_v2(request):
user_workspace_uuid = get_user_workspace_uuid(request=request)
user_cuser_role_uuid = get_cuser_role_uuid(request=request)
customer_responsible = CustomerResponsibleModel.objects.filter(cuser_role__uuid=user_cuser_role_uuid)
# customer_responsible = CustomerResponsibleModel.objects.filter(cuser_role__uuid=user_cuser_role_uuid)
# customer_objs = CustomerModel.objects.filter(sys_ws__uuid=user_workspace_uuid, responsible_group__customer_responsible__in=customer_responsible)
customer_objs = CustomerModel.objects.filter(sys_ws__uuid=user_workspace_uuid,
responsible_group__customer_responsible__in=customer_responsible)
customer_objs = CustomerModel.objects.filter(sys_ws__uuid=user_workspace_uuid)
return customer_objs
except Exception as exp:
@@ -101,7 +101,6 @@ def customer_form_item(request):
'shahr': shahr,
'responsible_groups': responsible_groups
}
return data
+73 -47
View File
@@ -10,7 +10,9 @@ from apps.cuser.models import CuserRole as CuserRoleModel
from rest_framework import exceptions
from utils.get_user_workspace import get_user_workspace, get_user_workspace_uuid
from utils.get_cuser_role import get_cuser_role
from django.db import transaction
from django.db import transaction, IntegrityError
from django.core.exceptions import ValidationError as DjangoValidationError
from utils.validations.mobile import normalize_mobile
from django.contrib.auth import get_user_model
import uuid
import random
@@ -20,11 +22,20 @@ CUser = get_user_model()
@transaction.atomic
def customer_panel_access(request, mobile: int, customer_id: int, status: bool) -> CuserRoleModel:
try:
workspace = get_user_workspace(request)
customer = CustomerModel.objects.get(id=customer_id, sys_ws=workspace)
mobile = normalize_mobile(mobile)
if customer.mobile != mobile:
raise exceptions.ValidationError({'mobile': 'شماره موبایل باید با شماره مشتری یکسان باشد.'})
# Disabling access must not create an unrelated account or a new role.
if not status:
if customer.cuser_role_id:
if customer.cuser_role.sys_ws_id != workspace.pk:
raise exceptions.ValidationError('اتصال پنل متعلق به این فضای کاری نیست؛ ابتدا اتصال را اصلاح کنید.')
customer.cuser_role.customer_panel = False
customer.cuser_role.save(update_fields=['customer_panel'])
return customer.cuser_role
try:
cuser_obj = CUser.objects.get(mobile=mobile)
except CUser.DoesNotExist:
@@ -36,29 +47,29 @@ def customer_panel_access(request, mobile: int, customer_id: int, status: bool)
is_active=True,
)
try:
cuser_role_obj = CuserRoleModel.objects.get(cuser=cuser_obj, role_type_id=3)
cuser_role_obj.customer_panel = status
cuser_role_obj.save()
except CuserRoleModel.DoesNotExist:
cuser_role_obj = CuserRoleModel.objects.create(
cuser=cuser_obj,
role_type_id=3,
name_family=customer.name_family,
mobile=mobile,
mobile_verify=True,
is_active=True,
customer_panel=status,
sys_ws=workspace
from apps.cuser.models import RoleType
if not cuser_obj.is_active:
raise exceptions.PermissionDenied('حساب کاربری مربوط به این شماره غیرفعال است.')
role_type = RoleType.objects.get(role_name_en='workspace_customer')
cuser_role_obj, _ = CuserRoleModel.objects.get_or_create(
cuser=cuser_obj, role_type=role_type, sys_ws=workspace,
defaults={'name_family': customer.name_family, 'mobile': mobile,
'mobile_verify': True, 'is_active': True, 'customer_panel': True},
)
if cuser_role_obj:
if CustomerModel.objects.filter(cuser_role=cuser_role_obj, sys_ws=workspace).exclude(pk=customer.pk).exists():
raise exceptions.ValidationError({'mobile': 'این شماره قبلاً به پنل مشتری دیگری متصل شده است.'})
cuser_role_obj.customer_panel = True
cuser_role_obj.save(update_fields=['customer_panel'])
customer.cuser_role = cuser_role_obj
customer.full_clean()
customer.save()
return cuser_role_obj
else:
raise Exception
except exceptions.APIException:
raise
except DjangoValidationError as exp:
raise exceptions.ValidationError(exp.message_dict)
except IntegrityError:
raise exceptions.ValidationError({'mobile': 'این شماره موبایل قبلاً ثبت شده است.'})
except Exception as exp:
message = f'Error Create or Update Customer panel accsess: {exp}'
raise exceptions.ValidationError({'error': message})
@@ -104,6 +115,7 @@ def delete_shive_ashenaei(shive_id: int, workspace_uuid: str) -> ShiveAshenaeiMo
raise exceptions.ValidationError({'error': message})
@transaction.atomic
def create_customer(name_family: str , mobile: int, phone: int, address: str, city: int, shive_ashenaei: int,
responsible_group: int, cuser_role: int = None, workspace: WorkspaceModel = None) -> CustomerModel:
try:
@@ -122,39 +134,48 @@ def create_customer(name_family: str , mobile: int, phone: int, address: str, ci
customer_obj.full_clean()
customer_obj.save()
return customer_obj
except DjangoValidationError as exp:
raise exceptions.ValidationError(exp.message_dict)
except IntegrityError:
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
except Exception as exp:
message = f'Error Creating Customer: {exp}'
raise exceptions.ValidationError({'error': message})
def set_customer_mobile(customer, mobile):
if mobile is None:
return
normalized = normalize_mobile(mobile)
if normalized != customer.mobile and customer.cuser_role_id:
role = customer.cuser_role
if role.sys_ws_id == customer.sys_ws_id:
role.customer_panel = False
role.save(update_fields=['customer_panel'])
customer.cuser_role = None
customer.mobile = normalized
@transaction.atomic
def update_customer(customer_id: int, name_family: str = None, mobile: int = None, phone: int = None, address: str = None,
city: int = None, shive_ashenaei: int = None, responsible_group: int = None, cuser_role: int = None, workspace_uuid: str = None) -> CustomerModel:
try:
customer_obj = CustomerModel.objects.get(id=customer_id, sys_ws__uuid=workspace_uuid)
if name_family is not None:
customer_obj.name_family = name_family
if mobile is not None:
customer_obj.mobile = mobile
if phone is not None:
customer_obj.phone = phone
if address is not None:
customer_obj.address = address
if city is not None:
customer_obj.city = city
if shive_ashenaei is not None:
customer_obj.shive_ashenaei = shive_ashenaei
if responsible_group is not None:
customer_obj.responsible_group = responsible_group
if cuser_role is not None:
customer_obj.cuser_role = cuser_role
customer_obj.full_clean()
customer_obj.save()
return customer_obj
except Exception as exp:
message = f'Error Creating Customer: {exp}'
raise exceptions.ValidationError({'error': message})
customer = CustomerModel.objects.select_for_update().get(id=customer_id, sys_ws__uuid=workspace_uuid)
set_customer_mobile(customer, mobile)
fields = {'name_family': name_family, 'phone': phone, 'address': address, 'city': city,
'shive_ashenaei': shive_ashenaei, 'responsible_group': responsible_group, 'cuser_role': cuser_role}
for field, value in fields.items():
if value is not None:
setattr(customer, field, value)
customer.full_clean()
customer.save()
return customer
except DjangoValidationError as error:
raise exceptions.ValidationError(error.message_dict)
except IntegrityError:
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
except CustomerModel.DoesNotExist:
raise exceptions.NotFound('مشتری در این فضای کاری پیدا نشد.')
def delete_customer(customer_id: int, workspace_uuid: str = None) -> CustomerModel:
@@ -165,6 +186,7 @@ def delete_customer(customer_id: int, workspace_uuid: str = None) -> CustomerMod
raise exceptions.ValidationError({'error': message})
@transaction.atomic
def create_customer_v2(request, name_family: str , mobile: int, phone: int, address: str, city: int, shive_ashenaei: int,
responsible_group: int, cuser_role: int = None) -> CustomerModel:
try:
@@ -186,6 +208,10 @@ def create_customer_v2(request, name_family: str , mobile: int, phone: int, addr
customer_obj.full_clean()
customer_obj.save()
return customer_obj
except DjangoValidationError as exp:
raise exceptions.ValidationError(exp.message_dict)
except IntegrityError:
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
except Exception as exp:
message = f'Error Creating Customer: {exp}'
raise exceptions.ValidationError({'error': message})
+205
View File
@@ -0,0 +1,205 @@
"""Legacy deployment diagnostics must expose conflicts without changing data."""
import json
from importlib import import_module
from io import StringIO
from unittest.mock import MagicMock, Mock, patch
from django.apps import apps
from django.contrib import admin
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import connection, transaction
from django.db.migrations.executor import MigrationExecutor
from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext
from apps.cuser.cuser_model import CUser
from apps.customer.models import Customer, CustomerNote, MobileConflict
from apps.jwt_auth.test_regressions import RegressionFixtures
from apps.workspace.models import Workspace
from apps.shop.models import Order
def audit(*args):
output = StringIO()
call_command('audit_mobile_duplicates', *args, stdout=output)
return json.loads(output.getvalue())
def repair():
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
class MobileAuditTests(RegressionFixtures):
def test_country_prefix_conflict_reports_all_members_and_dependencies_read_only(self):
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
note = CustomerNote.objects.create(customer=other, cuser_role=self.role,
desc='Keep this note', sys_ws=self.workspace)
before = list(Customer.objects.order_by('pk').values())
with CaptureQueriesContext(connection) as queries:
report = audit()
self.assertEqual(report['customer_duplicate_groups'], [{
'workspace_id': self.workspace.pk, 'canonical_mobile': self.customer.mobile,
'record_ids': [self.customer.pk, other.pk],
}])
self.assertEqual(report['duplicate_group_count'], 1)
self.assertEqual(report['customers'][1]['related_counts']['customer.CustomerNote.customer'], 1)
self.assertEqual(report['customers'][0]['panel_role']['user_id'], self.user.pk)
self.assertEqual(before, list(Customer.objects.order_by('pk').values()))
self.assertTrue(CustomerNote.objects.filter(pk=note.pk).exists())
writes = [query['sql'] for query in queries if query['sql'].lstrip().split()[0].upper()
in {'INSERT', 'UPDATE', 'DELETE', 'ALTER', 'DROP', 'CREATE'}]
self.assertEqual(writes, [])
def test_cross_workspace_numbers_and_multiple_nulls_are_allowed(self):
Customer.objects.create(name_family='Other tenant', mobile=989123456789, sys_ws=self.other_workspace)
Customer.objects.create(name_family='No number 1', mobile=None, sys_ws=self.workspace)
Customer.objects.create(name_family='No number 2', mobile=None, sys_ws=self.workspace)
self.assertEqual(audit()['duplicate_group_count'], 0)
def test_user_country_prefix_conflict_is_global_and_excludes_secrets(self):
user = CUser.objects.create(username='legacy', name_family='Legacy', mobile=989123456789)
report = audit()
self.assertEqual(report['user_duplicate_groups'][0]['record_ids'], [self.user.pk, user.pk])
self.assertEqual(report['duplicate_group_count'], 1)
self.assertNotIn('password', report['users'][0])
user.refresh_from_db()
self.assertEqual(user.mobile, 989123456789)
def test_requested_customer_details_and_missing_ids_without_duplicates(self):
report = audit('--customer-ids', str(self.customer.pk), '999999')
self.assertEqual(report['customers'][0]['id'], self.customer.pk)
self.assertEqual(report['missing_requested_customer_ids'], [999999])
self.assertEqual(report['duplicate_group_count'], 0)
def test_invalid_database_has_clear_error(self):
with self.assertRaisesMessage(CommandError, 'Unknown database alias'):
audit('--database', 'missing')
def test_automatic_repair_preserves_notes_orders_audit_and_blocks_old_panel_session(self):
tokens = self.use_role()
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
note = CustomerNote.objects.create(customer=self.customer, cuser_role=self.role,
desc='Keep this note', sys_ws=self.workspace)
order = Order.objects.create(customer=other, total_price=100, factor_type=1, sys_ws=self.workspace)
repair()
self.assertTrue(CustomerNote.objects.filter(pk=note.pk, customer=self.customer).exists())
self.assertTrue(Order.objects.filter(pk=order.pk, customer=other).exists())
entries = MobileConflict.objects.filter(source_model='customer.Customer')
self.assertEqual(entries.count(), 2)
archived = entries.get(record_id=self.customer.pk)
self.assertEqual(archived.snapshot['cuser_role_id'], self.role.pk)
self.assertTrue(archived.snapshot['roles'][0]['customer_panel'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
repair()
self.assertEqual(entries.count(), 2)
self.assertEqual(entries.get(record_id=self.customer.pk).snapshot, archived.snapshot)
def test_account_collision_disables_all_ambiguous_accounts_without_changing_their_numbers(self):
tokens = self.use_role()
other = CUser.objects.create(username='legacy', mobile=989123456789, is_active=True)
repair()
self.user.refresh_from_db()
other.refresh_from_db()
self.role.refresh_from_db()
self.operator.refresh_from_db()
self.assertFalse(self.user.is_active)
self.assertFalse(other.is_active)
self.assertFalse(self.role.is_active)
self.assertFalse(self.operator.is_active)
self.assertEqual(self.user.mobile, 9123456789)
self.assertEqual(other.mobile, 989123456789)
self.assertEqual(MobileConflict.objects.filter(source_model='cuser.CUser').count(), 2)
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
before = list(MobileConflict.objects.order_by('pk').values())
repair()
self.assertEqual(before, list(MobileConflict.objects.order_by('pk').values()))
def test_failed_archival_rolls_back_entire_repair(self):
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
Customer.objects.create(name_family='Second group', mobile=9333333333, sys_ws=self.workspace)
Customer.objects.create(name_family='Second group legacy', mobile=989333333333, sys_ws=self.workspace)
module = import_module('apps.customer.mobile_migration_repair')
archive = module._archive
calls = 0
def failing_archive(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 3:
raise RuntimeError('Simulated audit storage failure')
return archive(*args, **kwargs)
with self.assertRaisesMessage(RuntimeError, 'Simulated audit storage failure'):
with transaction.atomic(), patch.object(module, '_archive', side_effect=failing_archive):
repair()
self.customer.refresh_from_db()
other.refresh_from_db()
self.assertEqual(self.customer.mobile, 9123456789)
self.assertEqual(other.mobile, 989123456789)
self.assertEqual(MobileConflict.objects.count(), 0)
def test_admin_audit_history_cannot_be_added_changed_or_deleted(self):
model_admin = admin.site._registry[MobileConflict]
request = Mock(user=Mock(is_superuser=True, is_active=True))
self.assertFalse(model_admin.has_add_permission(request))
self.assertFalse(model_admin.has_change_permission(request))
self.assertFalse(model_admin.has_delete_permission(request))
def test_postgres_migration_locks_legacy_writers_and_drains_deferred_checks(self):
cursor = MagicMock()
postgres = Mock(alias='default', vendor='postgresql', ops=connection.ops)
postgres.cursor.return_value.__enter__ = Mock(return_value=cursor)
postgres.cursor.return_value.__exit__ = Mock(return_value=False)
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=postgres))
statements = [call.args[0] for call in cursor.execute.call_args_list]
self.assertTrue(statements[0].startswith('LOCK TABLE '))
self.assertIn('SHARE ROW EXCLUSIVE MODE', statements[0])
self.assertEqual(statements[-1], 'SET CONSTRAINTS ALL IMMEDIATE')
class LegacyMobileAuditTests(TransactionTestCase):
def test_exact_duplicates_before_migration_are_automatically_quarantined(self):
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
executor.migrate([('customer', '0007_alter_customer_name_family')])
try:
# Real legacy databases do not have the new audit table yet.
with connection.schema_editor() as editor:
editor.delete_model(MobileConflict)
workspace = Workspace.objects.create(name='Legacy workspace', username='legacy-workspace')
first = Customer.objects.create(name_family='First', mobile=9123456789, sys_ws=workspace)
second = Customer.objects.create(name_family='Second', mobile=9123456789, sys_ws=workspace)
report = audit('--customer-ids', str(first.pk), str(second.pk))
self.assertEqual(report['customer_duplicate_groups'][0]['record_ids'], [first.pk, second.pk])
self.assertEqual(Customer.objects.filter(mobile=9123456789).count(), 2)
MigrationExecutor(connection).migrate(targets)
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk]).count(), 2)
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk], mobile=None).count(), 2)
self.assertEqual(MobileConflict.objects.filter(source_model='customer.Customer').count(), 2)
self.assertEqual(MobileConflict.objects.get(record_id=first.pk).original_mobile, 9123456789)
self.assertEqual(audit()['duplicate_group_count'], 0)
finally:
# Test data only: restore the normal schema for subsequent tests.
Customer.objects.all().delete()
MigrationExecutor(connection).migrate(targets)
def test_already_applied_0008_installation_creates_missing_audit_table(self):
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
executor.migrate([('customer', '0008_customer_mobile_unique')])
try:
# Simulate an installation that applied the original 0008 before
# this automatic repair feature existed.
with connection.schema_editor() as editor:
editor.delete_model(MobileConflict)
self.assertNotIn(MobileConflict._meta.db_table, connection.introspection.table_names())
MigrationExecutor(connection).migrate(targets)
self.assertIn(MobileConflict._meta.db_table, connection.introspection.table_names())
self.assertEqual(MobileConflict.objects.count(), 0)
finally:
MigrationExecutor(connection).migrate(targets)
+15 -2
View File
@@ -13,6 +13,17 @@ from apps.workspace.models import City as CityModel
from utils.tools import inline_model_serializer, inline_serializer
from utils.pagination import CustomPagination
from django_filters import rest_framework as filters
from django.core.exceptions import ValidationError as DjangoValidationError
from utils.validations.mobile import normalize_mobile
class MobileField(serializers.CharField):
def to_internal_value(self, data):
value = super().to_internal_value(data)
try:
return normalize_mobile(value)
except DjangoValidationError:
raise serializers.ValidationError('شماره موبایل معتبر وارد کنید.')
@@ -87,6 +98,7 @@ class CustomerCreateApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
class InputSerializer(serializers.ModelSerializer):
mobile = MobileField(required=False, allow_null=True)
class Meta:
model = CustomerModel
fields = '__all__'
@@ -114,6 +126,7 @@ class CustomerUpdateApi(APIView):
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
class InputSerializer(serializers.ModelSerializer):
mobile = MobileField(required=False, allow_null=True)
class Meta:
model = CustomerModel
fields = '__all__'
@@ -125,7 +138,7 @@ class CustomerUpdateApi(APIView):
exclude = ('sys_ws', )
def patch(self, request, customer_id: int):
serializer = self.InputSerializer(data=request.data)
serializer = self.InputSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user_workspace_uuid = get_user_workspace_uuid(request=request)
@@ -215,7 +228,7 @@ class CustomerCreateApiV2(APIView):
class InputSerializer(serializers.Serializer):
name_family = serializers.CharField(max_length=25, required=True)
mobile = serializers.IntegerField(required=True)
mobile = MobileField(required=True)
phone = serializers.IntegerField(required=True)
address = serializers.CharField(max_length=300, required=True)
city = serializers.IntegerField(required=True)
+1 -1
View File
@@ -22,6 +22,6 @@ class CustomerPanelApi(APIView):
cuser_role = customer_panel_access(request=request, **serializer.validated_data)
return Response(data={'cuser_role_id': cuser_role.id}, status=status.HTTP_200_OK)
return Response(data={'cuser_role_id': cuser_role.id if cuser_role else None}, status=status.HTTP_200_OK)
+16 -3
View File
@@ -1,3 +1,4 @@
from utils.admin_base import CRMModelAdmin
from django.contrib import admin
from apps.customform.models import (
CustomForm as CustomFormModel,
@@ -7,16 +8,28 @@ from apps.customform.models import (
# Register your models here.
class CustomFormFieldInline(admin.TabularInline):
model = CustomFormFieldModel
extra = 0
fields = ["field_name_fa","field_name_en","validator"]
readonly_fields = fields
can_delete = False
show_change_link = True
def has_add_permission(self, request, obj=None):
return False
@admin.register(CustomFormModel)
class CustomFormAdmin(admin.ModelAdmin):
class CustomFormAdmin(CRMModelAdmin):
pass
@admin.register(CustomFormDataModel)
class CustomFormDataAdmin(admin.ModelAdmin):
class CustomFormDataAdmin(CRMModelAdmin):
pass
@admin.register(CustomFormFieldModel)
class CustomFormFieldAdmin(admin.ModelAdmin):
class CustomFormFieldAdmin(CRMModelAdmin):
pass
+34 -12
View File
@@ -1,17 +1,39 @@
from django.contrib import admin
from apps.jwt_auth.models import (
JwtToken as JwtTokenModel,
CUserRoleLoginOpt as CUserRoleLoginOptModel,
)
# Register your models here.
from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt
from utils.admin_base import CRMModelAdmin
@admin.register(JwtTokenModel)
class JwtTokenAdmin(admin.ModelAdmin):
pass
@admin.register(JwtToken)
class JwtTokenAdmin(CRMModelAdmin):
list_display = ('id', 'cuser', 'cuser_role', 'expire', 'is_blocked')
list_filter = ('is_blocked', 'cuser_role__sys_ws')
search_fields = ('cuser__username', 'cuser__mobile')
exclude = ('refresh', 'access')
sensitive_fields = ('refresh', 'access')
readonly_fields = ('cuser', 'cuser_role', 'expire', 'is_blocked')
actions = ('revoke_sessions', 'export_selected')
@admin.action(description='ابطال نشست‌های انتخاب‌شده', permissions=['change'])
def revoke_sessions(self, request, queryset):
count = queryset.update(is_blocked=True)
for obj in queryset:
self.log_change(request, obj, 'نشست از پنل مدیریت ابطال شد.')
self.message_user(request, f'{count} نشست ابطال شد.')
def has_add_permission(self, request):
return False
@admin.register(CUserRoleLoginOptModel)
class CUserRoleLoginOptAdmin(admin.ModelAdmin):
pass
@admin.register(CUserRoleLoginOpt)
class CUserRoleLoginOptAdmin(CRMModelAdmin):
list_display = ('id', 'cuser', 'cuser_role', 'expire')
search_fields = ('cuser__username', 'cuser__mobile')
exclude = ('uuid',)
sensitive_fields = ('uuid',)
readonly_fields = ('cuser', 'cuser_role', 'expire')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
+19
View File
@@ -0,0 +1,19 @@
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_simplejwt.authentication import JWTAuthentication
from apps.jwt_auth.models import JwtToken
from apps.jwt_auth.session import validate_session_claims
class SessionJWTAuthentication(JWTAuthentication):
"""A signed JWT is usable only while its recorded session remains active."""
def get_user(self, validated_token):
user = super().get_user(validated_token)
session = JwtToken.objects.select_related('cuser_role__role_type', 'cuser_role__sys_ws', 'cuser_role__cuser').filter(
access=str(validated_token), cuser=user, is_blocked=False,
).first()
if not session:
raise AuthenticationFailed('نشست شما معتبر نیست. لطفاً دوباره وارد شوید.', code='session_invalid')
validate_session_claims(session, validated_token)
return user
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-01-27 09:24
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0074_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('7652353e-6437-4f8d-aa3d-b1ff9de99aac'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-01-27 11:50
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0075_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('f97fef73-113f-42f4-bd03-893cd0a6c4cd'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-01-27 12:04
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0076_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('21a181c7-140b-4044-b43a-310712b904ba'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-02-06 10:23
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0077_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('78001c0b-3608-492f-b7c6-0a834b160c85'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-02-06 10:25
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0078_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('66663299-ba4b-46f8-8e68-62c6be077d93'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-02-06 11:44
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0079_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('b639ab0a-7a39-41f2-a2cf-d65736eb71ef'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-02-06 12:30
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0080_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('8f0b7e63-3d78-4de2-9e59-931a8734fc02'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-02-10 09:59
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0081_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('6537ef3d-8ef9-486d-a1b6-7892e021dc19'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-02-10 11:06
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0082_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('745db968-c736-42cd-ad3d-4de41e8e5b98'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-02-18 09:44
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0083_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('96211808-5203-451b-a6ce-eee394e02afc'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-02-27 09:59
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0084_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('561d07a5-fff4-436f-bab3-4e3b16e05fba'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-04-16 06:28
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0085_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('b5346fad-8d28-482d-8ed9-2807850b6a4d'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-07-25 05:38
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0086_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('0fc1ec70-e817-495e-91c9-e98666cbc2c1'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-07-25 05:42
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0087_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('35e24d20-a3c0-4e33-9307-aff890cfd593'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-08-24 13:14
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0088_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('70141bbf-e757-4eae-a6a4-7d430084c8a6'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-08-24 13:21
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0089_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('48476cd6-6133-4355-a10b-319b7de479ee'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-08-27 06:14
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0090_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('2072fa9d-9601-4c50-b1ee-bd2b437bee2c'), unique=True),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.1 on 2024-08-27 06:38
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('jwt_auth', '0091_alter_cuserroleloginopt_uuid'),
]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt',
name='uuid',
field=models.UUIDField(default=uuid.UUID('52471ac1-6299-4ffa-b56f-8b1e8a343d18'), unique=True),
),
]
@@ -0,0 +1,12 @@
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('jwt_auth', '0092_alter_cuserroleloginopt_uuid')]
operations = [
migrations.AlterField(
model_name='cuserroleloginopt', name='uuid',
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
]
+1 -1
View File
@@ -22,4 +22,4 @@ class CUserRoleLoginOpt(models.Model):
cuser_role = models.ForeignKey(CuserRoleModel, on_delete=models.CASCADE)
cuser = models.ForeignKey(CUser, null=True, blank=True, default=None, on_delete=models.CASCADE)
expire = models.DateTimeField(null=False, blank=False)
uuid = models.UUIDField(default=uuid.uuid4(), unique=True, null=False, blank=False)
uuid = models.UUIDField(default=uuid.uuid4, unique=True, null=False, blank=False)
+110 -78
View File
@@ -1,90 +1,122 @@
import uuid
import random
import secrets
from datetime import timedelta
from django.contrib.auth import get_user_model
from apps.jwt_auth.models import CUserRoleLoginOpt
from apps.cuser.models import CuserRole as CuserRoleModel, SmsOpt as SmsOptModel
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils import timezone
from rest_framework import exceptions
from apps.cuser.models import CuserRole, SmsOpt
from apps.jwt_auth.models import CUserRoleLoginOpt
from utils.services.sms import DefaultSmsProvider
from utils.validations.mobile import normalize_mobile
CUser = get_user_model()
SMS_RESEND_INTERVAL = 60
def login_by_username(username: str, password: str) -> CUserRoleLoginOpt:
class SmsRequestThrottled(exceptions.Throttled):
def __init__(self, wait):
super().__init__(wait=wait)
self.detail = {'detail': 'کد ورود قبلاً ارسال شده است. کمی صبر کنید و کد قبلی را وارد کنید.',
'retry_after': wait}
class SmsDeliveryUnavailable(exceptions.APIException):
status_code = 503
default_detail = 'ارسال پیامک انجام نشد. لطفاً کمی بعد دوباره تلاش کنید.'
default_code = 'sms_failed'
def eligible_roles(user, customer_only=False):
roles = CuserRole.objects.select_related('role_type', 'sys_ws', 'cuser').filter(
cuser=user, is_active=True, mobile_verify=True, sys_ws__is_active=True,
)
if customer_only:
roles = roles.filter(role_type__role_name_en='workspace_customer', customer_panel=True)
return [role for role in roles if
(not role.sys_ws.expire or role.sys_ws.expire > timezone.now()) and
(role.role_type.role_name_en != 'workspace_customer' or role.customer_panel is True)]
def create_login_options(user, roles):
CUserRoleLoginOpt.objects.filter(cuser=user).delete()
options = []
for role in roles:
options.append(CUserRoleLoginOpt.objects.create(
cuser=user, cuser_role=role, expire=timezone.now() + timedelta(minutes=5),
))
return options
@transaction.atomic
def login_by_username(username: str, password: str):
user = CUser.objects.select_for_update().filter(username=username).first()
if not user or not user.check_password(password):
raise exceptions.ValidationError({'error': 'نام کاربری یا رمز عبور اشتباه است.'})
if not user.is_active:
raise exceptions.PermissionDenied('حساب کاربری شما غیرفعال است.')
roles = eligible_roles(user)
if not roles:
raise exceptions.PermissionDenied('نقش یا دسترسی فعال برای این حساب وجود ندارد.')
return create_login_options(user, roles)
def mobile_user(mobile):
try:
print(username)
user = CUser.objects.get(username=username)
print(user)
if user and user.check_password(password):
cuser_roles = CuserRoleModel.objects.filter(cuser=user)
CUserRoleLoginOpt.objects.filter(cuser_role__cuser=user).delete()
if cuser_roles:
for user_role_obj in cuser_roles:
if not (user_role_obj.is_active and user_role_obj.mobile_verify):
raise exceptions.ValidationError({"error": 'Not a valid user'})
expire_datetime = timezone.datetime.now() + timezone.timedelta(minutes=5)
user_role_opt = CUserRoleLoginOpt()
user_role_opt.cuser_role = user_role_obj
user_role_opt.cuser = user_role_obj.cuser
user_role_opt.expire = expire_datetime
user_role_opt.uuid = uuid.uuid4()
user_role_opt.save()
login_opts = CUserRoleLoginOpt.objects.filter(cuser_role__cuser=user)
return login_opts
else:
raise exceptions.ValidationError({'error': "User not have role"})
else:
raise exceptions.ValidationError({'error': "Username or password is not correct"})
except CUser.DoesNotExist:
raise exceptions.ValidationError({'error': "Username or password is not correct"})
mobile = normalize_mobile(mobile)
except DjangoValidationError as error:
raise exceptions.ValidationError(error.message_dict)
user = CUser.objects.filter(mobile=mobile).first()
if not user:
raise exceptions.ValidationError({'mobile': 'کاربری با این شماره موبایل ثبت نشده است.'})
if not user.is_active:
raise exceptions.PermissionDenied('حساب کاربری شما غیرفعال است.')
roles = eligible_roles(user, customer_only=True)
if not roles:
raise exceptions.PermissionDenied('دسترسی شما به پنل مشتریان فعال نیست.')
return user, roles
@transaction.atomic
def login_by_mobile(mobile: int):
try:
exists_cuser = CUser.objects.get(mobile=mobile)
random_number = random.randint(100000, 999999)
try:
SmsOptModel.objects.filter(mobile=mobile).delete()
except Exception as exp:
pass
opt_code = SmsOptModel(mobile=mobile, opt=123456)
opt_code.save()
except Exception as exp:
raise exceptions.ValidationError({'error': "mobile not found"})
user, _ = mobile_user(mobile)
# Serialize code requests for this account, retaining the old valid code if
# the provider refuses the new message.
CUser.objects.select_for_update().get(pk=user.pk)
# Recheck eligibility after waiting for concurrent requests/administrative edits.
user, _ = mobile_user(user.mobile)
current = SmsOpt.objects.filter(mobile=user.mobile, expire__gt=timezone.now()).order_by('-created_at').first()
if current:
remaining = SMS_RESEND_INTERVAL - (timezone.now() - current.created_at).total_seconds()
if remaining > 0:
from math import ceil
raise SmsRequestThrottled(ceil(remaining))
code = secrets.randbelow(900000) + 100000
delivery = DefaultSmsProvider().send_sms(str(user.mobile), f'کد ورود شما: {code}')
if delivery is False:
raise SmsDeliveryUnavailable()
if delivery is True:
SmsOpt.objects.filter(mobile=user.mobile).delete()
else:
# Keep any previous unexpired code too: either SMS may have arrived.
SmsOpt.objects.filter(mobile=user.mobile, expire__lte=timezone.now()).delete()
SmsOpt.objects.create(mobile=user.mobile, opt=code, expire=timezone.now() + timedelta(minutes=3))
return {'mobile': user.mobile, 'delivery_status': 'accepted' if delivery is True else 'unconfirmed',
'message': 'opt code send' if delivery is True else 'وضعیت ارسال پیامک تأیید نشد. اگر کد را دریافت کرده‌اید، آن را وارد کنید.',
'retry_after': SMS_RESEND_INTERVAL}
def verify_mobile_and_get_token(mobile: int, opt: int) -> CUserRoleLoginOpt:
try:
opt_obj = SmsOptModel.objects.filter(mobile=mobile, opt=opt)[0]
if opt_obj:
cuser_roles = CuserRoleModel.objects.filter(cuser__mobile=mobile, is_active=True)
CUserRoleLoginOpt.objects.filter(cuser_role__in=cuser_roles).delete()
if cuser_roles:
for user_role_obj in cuser_roles:
expire_datetime = timezone.datetime.now() + timezone.timedelta(minutes=5)
user_role_opt = CUserRoleLoginOpt()
user_role_opt.cuser_role = user_role_obj
user_role_opt.cuser = user_role_obj.cuser
user_role_opt.expire = expire_datetime
user_role_opt.uuid = uuid.uuid4()
user_role_opt.save()
login_opts = CUserRoleLoginOpt.objects.filter(cuser_role__in=cuser_roles)
SmsOptModel.objects.filter(mobile=mobile).delete()
return login_opts
except Exception as exp:
raise exceptions.ValidationError({'error': "cannot verify this mobile and opt code:"+str(exp)})
@transaction.atomic
def verify_mobile_and_get_token(mobile: int, opt: int):
user, roles = mobile_user(mobile)
CUser.objects.select_for_update().get(pk=user.pk)
code = SmsOpt.objects.select_for_update().filter(
mobile=user.mobile, opt=opt, expire__gt=timezone.now(),
).first()
if not code:
raise exceptions.ValidationError({'opt': 'کد تأیید اشتباه است یا اعتبار آن تمام شده است.'})
options = create_login_options(user, roles)
SmsOpt.objects.filter(mobile=user.mobile).delete()
return options
+49
View File
@@ -0,0 +1,49 @@
from datetime import datetime, timezone as datetime_timezone
from django.utils import timezone
from django.db import transaction
from rest_framework.exceptions import AuthenticationFailed
from rest_framework_simplejwt.tokens import RefreshToken
from apps.jwt_auth.models import JwtToken
def validate_role_access(role):
if not (role.cuser and role.cuser.is_active and role.is_active and role.mobile_verify):
raise AuthenticationFailed('حساب کاربری یا نقش شما غیرفعال است. دوباره وارد شوید.', code='session_invalid')
workspace = role.sys_ws
if not workspace or not workspace.is_active or (workspace.expire and workspace.expire <= timezone.now()):
raise AuthenticationFailed('دسترسی فضای کاری غیرفعال یا منقضی شده است.', code='session_invalid')
if role.role_type.role_name_en == 'workspace_customer' and role.customer_panel is not True:
raise AuthenticationFailed('دسترسی شما به پنل مشتریان فعال نیست.', code='session_invalid')
@transaction.atomic
def issue_tokens(role, session=None):
validate_role_access(role)
if session is None:
session = JwtToken.objects.create(cuser=role.cuser, cuser_role=role, refresh='')
refresh = RefreshToken.for_user(role.cuser)
refresh['workspace_uuid'] = str(role.sys_ws.uuid)
refresh['cuser_role_uuid'] = str(role.uuid)
refresh['user_uuid'] = str(role.cuser.uuid)
refresh['session_id'] = session.pk
data = {'refresh': str(refresh), 'access': str(refresh.access_token)}
session.refresh = data['refresh']
session.access = data['access']
session.expire = datetime.fromtimestamp(refresh['exp'], tz=datetime_timezone.utc)
session.save()
return data
def validate_session_claims(session, token):
if session.expire and session.expire <= timezone.now():
raise AuthenticationFailed('نشست شما منقضی شده است. دوباره وارد شوید.', code='session_invalid')
role = session.cuser_role
validate_role_access(role)
expected = {'user_id': session.cuser_id, 'user_uuid': str(role.cuser.uuid),
'cuser_role_uuid': str(role.uuid), 'workspace_uuid': str(role.sys_ws.uuid)}
if token.get('session_id') is not None:
expected['session_id'] = session.pk
if role.cuser_id != session.cuser_id or any(token.get(key) != value for key, value in expected.items()):
raise AuthenticationFailed('نشست شما معتبر نیست. دوباره وارد شوید.', code='session_invalid')
+131
View File
@@ -0,0 +1,131 @@
"""Customer OTP journey, repeat submits, and recoverable failure contracts."""
from datetime import timedelta
from unittest.mock import Mock, patch
from django.test import override_settings
from django.utils import timezone
import requests
from apps.cuser.models import SmsOpt
from apps.jwt_auth.test_regressions import RegressionFixtures
class CustomerLoginTests(RegressionFixtures):
def test_unrecognized_provider_reply_keeps_delivered_code_usable(self):
for body in ('1', '654321987', '<string>accepted</string>', ''):
with self.subTest(body=body):
SmsOpt.objects.all().delete()
with patch('utils.services.sms.requests.get', return_value=Mock(text=body)):
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 202)
self.assertEqual(sent.data['result']['delivery_status'], 'unconfirmed')
code = SmsOpt.objects.get(mobile=self.user.mobile).opt
verified = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code})
self.assertEqual(verified.status_code, 200)
self.assertEqual(self.post('token/', {'uuid_opt': verified.data[0]['uuid']}).status_code, 200)
def test_lost_provider_reply_keeps_code_and_still_throttles_repeat_sms(self):
for error in (requests.Timeout, requests.ConnectionError):
with self.subTest(error=error):
SmsOpt.objects.all().delete()
with patch('utils.services.sms.requests.get', side_effect=error) as send:
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 202)
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 429)
send.assert_called_once()
code = SmsOpt.objects.get().opt
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code}).status_code, 200)
def test_uncertain_resend_retains_previous_delivered_code(self):
original = self.otp()
SmsOpt.objects.filter(pk=original.pk).update(created_at=timezone.now() - timedelta(seconds=61))
with patch('utils.services.sms.requests.get', return_value=Mock(text='unexpected')):
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 202)
self.assertEqual(SmsOpt.objects.filter(mobile=self.user.mobile).count(), 2)
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': original.opt}).status_code, 200)
self.assertFalse(SmsOpt.objects.filter(mobile=self.user.mobile).exists())
@override_settings(SMS_SUCCESS_CODES='0,1', SMS_FAILURE_CODES='error,-1')
def test_configured_response_codes_and_explicit_rejection(self):
with patch('utils.services.sms.requests.get', return_value=Mock(text='\ufeff1\r\n')):
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.data['result']['delivery_status'], 'accepted')
original = SmsOpt.objects.get()
SmsOpt.objects.filter(pk=original.pk).update(created_at=timezone.now() - timedelta(seconds=61))
with patch('utils.services.sms.requests.get', return_value=Mock(text='-1')):
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 503)
self.assertEqual(SmsOpt.objects.get().pk, original.pk)
def test_complete_customer_journey_login_refresh_and_logout(self):
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True) as send:
sent = self.post('login/mobile/', {'mobile': '۰۹۱۲۳۴۵۶۷۸۹'})
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.data['result']['mobile'], self.user.mobile)
self.assertEqual(sent.data['result']['retry_after'], 60)
send.assert_called_once()
code = SmsOpt.objects.get(mobile=self.user.mobile).opt
options = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code})
self.assertEqual(options.status_code, 200)
self.assertEqual(len(options.data), 1)
self.assertEqual(options.data[0]['cuser_role']['role_type']['role_name_en'], 'workspace_customer')
tokens = self.post('token/', {'uuid_opt': options.data[0]['uuid']})
self.assertEqual(tokens.status_code, 200)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + tokens.data['access'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 200)
rotated = self.post('token/refresh/', {'refresh': tokens.data['refresh']})
self.assertEqual(rotated.status_code, 200)
self.assertNotEqual(rotated.data['refresh'], tokens.data['refresh'])
self.assertEqual(self.post('logout/', {'refresh': tokens.data['refresh']}).status_code, 204)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + rotated.data['access'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': rotated.data['refresh']}).status_code, 401)
def test_repeat_send_does_not_invalidate_code_or_send_another_sms(self):
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True) as send:
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 200)
original = SmsOpt.objects.get(mobile=self.user.mobile)
for _ in range(3):
repeated = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(repeated.status_code, 429)
self.assertIn('قبلاً ارسال', repeated.data['detail'])
self.assertGreater(int(repeated['Retry-After']), 0)
self.assertEqual(SmsOpt.objects.get().pk, original.pk)
self.assertEqual(SmsOpt.objects.get().opt, original.opt)
send.assert_called_once()
verified = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': original.opt})
self.assertEqual(verified.status_code, 200)
def test_resend_after_interval_replaces_code_once(self):
original = self.otp()
SmsOpt.objects.filter(pk=original.pk).update(created_at=timezone.now() - timedelta(seconds=61))
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True) as send:
response = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(response.status_code, 200)
self.assertFalse(SmsOpt.objects.filter(pk=original.pk).exists())
self.assertEqual(SmsOpt.objects.filter(mobile=self.user.mobile).count(), 1)
send.assert_called_once()
def test_expired_code_does_not_prevent_requesting_a_new_one(self):
self.otp(expired=True)
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True):
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 200)
def test_panel_disabled_between_send_and_verify_has_clear_error(self):
code = self.otp()
self.role.customer_panel = False
self.role.save(update_fields=['customer_panel'])
response = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code.opt})
self.assertEqual(response.status_code, 403)
self.assertIn('پنل مشتریان', str(response.data))
def test_double_verify_and_double_token_exchange_cannot_create_multiple_sessions(self):
from apps.jwt_auth.models import JwtToken
code = self.otp()
first = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code.opt})
self.assertEqual(first.status_code, 200)
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code.opt}).status_code, 400)
option = first.data[0]['uuid']
self.assertEqual(self.post('token/', {'uuid_opt': option}).status_code, 200)
self.assertEqual(self.post('token/', {'uuid_opt': option}).status_code, 401)
self.assertEqual(JwtToken.objects.count(), 1)
+42
View File
@@ -0,0 +1,42 @@
"""Refresh locking must work on PostgreSQL despite nullable role relations."""
from django.db import connection
from django.test import skipUnlessDBFeature
from django.test.utils import CaptureQueriesContext
from apps.cuser.models import CuserRole
from apps.jwt_auth.models import JwtToken
from apps.jwt_auth.test_regressions import RegressionFixtures
class RefreshLockingTests(RegressionFixtures):
@skipUnlessDBFeature('has_select_for_update_of')
def test_refresh_locks_only_session_and_rejects_reuse(self):
tokens = self.use_role()
with CaptureQueriesContext(connection) as queries:
response = self.post('token/refresh/', {'refresh': tokens['refresh']})
self.assertEqual(response.status_code, 200)
locking = [query['sql'] for query in queries if 'FOR UPDATE' in query['sql'].upper()]
self.assertEqual(len(locking), 1)
# The actual endpoint SQL retains LEFT JOINs for nullable relations but
# targets only the JWT row in its PostgreSQL locking clause.
self.assertIn('LEFT OUTER JOIN', locking[0].upper())
expected = 'FOR UPDATE OF ' + connection.ops.quote_name(JwtToken._meta.db_table)
self.assertEqual(locking[0].split('FOR UPDATE', 1)[1].strip(), expected.split('FOR UPDATE', 1)[1].strip())
self.assertNotEqual(response.data['refresh'], tokens['refresh'])
self.assertEqual(JwtToken.objects.count(), 1)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_refresh_for_role_without_workspace_is_unauthorized(self):
tokens = self.use_role()
CuserRole.objects.filter(pk=self.role.pk).update(sys_ws=None)
response = self.post('token/refresh/', {'refresh': tokens['refresh']})
self.assertEqual(response.status_code, 401)
self.assertIn('فضای کاری', str(response.data))
self.assertEqual(JwtToken.objects.get().refresh, tokens['refresh'])
def test_refresh_for_role_without_user_is_unauthorized(self):
tokens = self.use_role()
CuserRole.objects.filter(pk=self.role.pk).update(cuser=None)
response = self.post('token/refresh/', {'refresh': tokens['refresh']})
self.assertEqual(response.status_code, 401)
self.assertEqual(JwtToken.objects.get().refresh, tokens['refresh'])
+494
View File
@@ -0,0 +1,494 @@
"""Authentication, duplicate mobiles and ordering integration regressions."""
from datetime import timedelta
from io import BytesIO
from unittest.mock import Mock, patch
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.utils import timezone
from openpyxl import load_workbook
from rest_framework.test import APITestCase
from rest_framework_simplejwt.tokens import RefreshToken
from apps.cuser.cuser_model import CUser
from apps.cuser.models import CuserRole, RoleType, SmsOpt
from apps.customer.models import Customer, ResponsibleGroup, ShiveAshenaei
from apps.jwt_auth.models import CUserRoleLoginOpt, JwtToken
from apps.jwt_auth.session import issue_tokens
from apps.shop.admin import ProductAdmin, ProductCategoryAdmin, excel_response
from apps.shop.models import Product, ProductCategory, Order, OrderItem
from apps.workspace.models import City, Workspace
from utils.services.sms import DosefrNavadHashtSMS
from utils.validations.mobile import normalize_mobile
class RegressionFixtures(APITestCase):
@classmethod
def setUpTestData(cls):
cls.workspace = Workspace.objects.create(name='Test', username='test')
cls.other_workspace = Workspace.objects.create(name='Other', username='other')
cls.user = CUser.objects.create_user('client', 'password', mobile=9123456789, name_family='Client', is_active=True)
cls.customer_type = RoleType.objects.create(role_name='Customer', role_name_en='workspace_customer')
cls.operator_type = RoleType.objects.create(role_name='Operator', role_name_en='workspace_operator')
cls.admin_type = RoleType.objects.create(role_name='Admin', role_name_en='workspace_admin')
cls.role = CuserRole.objects.create(cuser=cls.user, role_type=cls.customer_type, sys_ws=cls.workspace,
name_family='Client', mobile=cls.user.mobile, is_active=True,
mobile_verify=True, customer_panel=True)
cls.operator = CuserRole.objects.create(cuser=cls.user, role_type=cls.operator_type, sys_ws=cls.workspace,
name_family='Operator', mobile=cls.user.mobile, is_active=True, mobile_verify=True)
cls.customer = Customer.objects.create(name_family='Client', mobile=cls.user.mobile, cuser_role=cls.role, sys_ws=cls.workspace)
cls.city = City.objects.create(name='City')
cls.group = ResponsibleGroup.objects.create(name='Group', sys_ws=cls.workspace)
cls.shive = ShiveAshenaei.objects.create(name='Web', sys_ws=cls.workspace)
def use_role(self, role=None):
tokens = issue_tokens(role or self.role)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + tokens['access'])
return tokens
def post(self, path, data):
return self.client.post('/auth/' + path, data, format='json')
def otp(self, expired=False):
return SmsOpt.objects.create(mobile=self.user.mobile, opt=123456,
expire=timezone.now() + timedelta(minutes=-1 if expired else 3))
class AuthRegressionTests(RegressionFixtures):
def test_unknown_mobile_has_useful_400(self):
response = self.post('login/mobile/', {'mobile': '09999999999'})
self.assertEqual(response.status_code, 400)
self.assertIn('ثبت نشده', str(response.data))
def test_invalid_mobile_has_400(self):
self.assertEqual(self.post('login/mobile/', {'mobile': '123'}).status_code, 400)
def test_disabled_customer_panel_has_403_and_sends_no_sms(self):
self.role.customer_panel = False
self.role.save()
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms') as send:
response = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(response.status_code, 403)
self.assertIn('پنل مشتریان', str(response.data))
send.assert_not_called()
def test_inactive_user_has_403(self):
self.user.is_active = False
self.user.save()
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 403)
def test_sms_failure_has_503_and_preserves_old_otp(self):
old = self.otp()
SmsOpt.objects.filter(pk=old.pk).update(created_at=timezone.now() - timedelta(minutes=2))
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=False):
response = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(response.status_code, 503)
self.assertIn('ارسال پیامک', str(response.data))
self.assertTrue(SmsOpt.objects.filter(pk=old.pk).exists())
def test_successful_sms_saves_only_one_code(self):
old = self.otp()
SmsOpt.objects.filter(pk=old.pk).update(created_at=timezone.now() - timedelta(minutes=2))
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True):
response = self.post('login/mobile/', {'mobile': '+98 912-345-6789'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['result']['mobile'], self.user.mobile)
self.assertEqual(SmsOpt.objects.count(), 1)
self.assertGreaterEqual(SmsOpt.objects.get().opt, 100000)
def test_wrong_otp_preserves_valid_code(self):
old = self.otp()
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 654321}).status_code, 400)
self.assertTrue(SmsOpt.objects.filter(pk=old.pk).exists())
def test_expired_otp_rejected(self):
self.otp(expired=True)
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456}).status_code, 400)
def test_otp_is_single_use_and_roles_have_distinct_options(self):
CuserRole.objects.create(cuser=self.user, role_type=self.customer_type, sys_ws=self.other_workspace,
name_family='Other', mobile=self.user.mobile, is_active=True, mobile_verify=True, customer_panel=True)
self.otp()
response = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 2)
self.assertNotEqual(response.data[0]['uuid'], response.data[1]['uuid'])
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': 123456}).status_code, 400)
def test_bad_password_has_400(self):
self.assertEqual(self.post('login/username/', {'username': 'client', 'password': 'wrong'}).status_code, 400)
def test_public_login_ignores_stale_authorization(self):
self.client.credentials(HTTP_AUTHORIZATION='Bearer invalid')
response = self.post('login/username/', {'username': 'client', 'password': 'password'})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 2)
def test_option_expiry_and_replay_have_401(self):
option = CUserRoleLoginOpt.objects.create(cuser=self.user, cuser_role=self.role, expire=timezone.now()-timedelta(seconds=1))
self.assertEqual(self.post('token/', {'uuid_opt': str(option.uuid)}).status_code, 401)
option.expire = timezone.now()+timedelta(minutes=5)
option.save()
result = self.post('token/', {'uuid_opt': str(option.uuid)})
self.assertEqual(result.status_code, 200)
self.assertEqual(self.post('token/', {'uuid_opt': str(option.uuid)}).status_code, 401)
self.assertEqual(JwtToken.objects.count(), 1)
def test_anonymous_info_has_401(self):
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
def test_signed_but_unrecorded_access_is_rejected(self):
refresh = RefreshToken.for_user(self.user)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + str(refresh.access_token))
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
def test_blocked_access_and_refresh_are_rejected(self):
tokens = self.use_role()
JwtToken.objects.update(is_blocked=True)
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_missing_session_rejects_access_and_refresh(self):
tokens = self.use_role()
JwtToken.objects.all().delete()
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_refresh_rotates_and_retains_workspace(self):
tokens = self.use_role(self.operator)
response = self.post('token/refresh/', {'refresh': tokens['refresh']})
self.assertEqual(response.status_code, 200)
self.assertNotEqual(response.data['refresh'], tokens['refresh'])
self.assertEqual(RefreshToken(response.data['refresh'])['workspace_uuid'], str(self.workspace.uuid))
self.assertEqual(JwtToken.objects.count(), 1)
self.assertGreater(JwtToken.objects.get().expire, timezone.now())
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + response.data['access'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 200)
def test_disabling_panel_revokes_both_tokens(self):
tokens = self.use_role()
self.role.customer_panel = False
self.role.save()
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_expired_workspace_revokes_both_tokens(self):
tokens = self.use_role()
self.workspace.expire = timezone.now()-timedelta(seconds=1)
self.workspace.save()
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_logout_revokes_session_without_valid_access(self):
tokens = self.use_role()
self.client.credentials(HTTP_AUTHORIZATION='Bearer expired')
self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + tokens['access'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204)
def test_user_duplicate_formats_are_rejected(self):
for mobile in ['09123456789', '+98 912-345-6789', '۰۹۱۲۳۴۵۶۷۸۹', '00989123456789']:
with self.subTest(mobile=mobile), self.assertRaises(ValidationError):
CUser.objects.create_user('duplicate', 'password', mobile=mobile)
self.assertEqual(CUser.objects.count(), 1)
def test_customer_duplicate_has_database_constraint(self):
with self.assertRaises(IntegrityError), transaction.atomic():
Customer.objects.create(name_family='Duplicate', mobile=self.user.mobile, sys_ws=self.workspace)
Customer.objects.create(name_family='Other', mobile=self.user.mobile, sys_ws=self.other_workspace)
Customer.objects.create(name_family='Null1', sys_ws=self.workspace)
Customer.objects.create(name_family='Null2', sys_ws=self.workspace)
def test_create_customer_v2_duplicate_has_mobile_error(self):
self.use_role(self.operator)
response = self.client.post('/customer/v2/create/', {
'name_family': 'Duplicate', 'mobile': '+98 912-345-6789', 'phone': 2133333333,
'address': 'Address', 'city': self.city.pk, 'shive_ashenaei': self.shive.pk, 'responsible_group': self.group.pk,
}, format='json')
self.assertEqual(response.status_code, 400)
self.assertIn('mobile', response.data)
self.assertEqual(Customer.objects.count(), 1)
def test_partial_customer_update_rejects_duplicate_and_allows_same_mobile(self):
other = Customer.objects.create(name_family='Other', mobile=9333333333, sys_ws=self.workspace)
self.use_role(self.operator)
response = self.client.patch(f'/customer/update/{other.pk}/', {'mobile': '09123456789'}, format='json')
self.assertEqual(response.status_code, 400)
self.assertIn('mobile', response.data)
response = self.client.patch(f'/customer/update/{self.customer.pk}/', {'mobile': self.user.mobile}, format='json')
self.assertEqual(response.status_code, 200)
def test_panel_link_is_scoped_to_workspace(self):
other_operator = CuserRole.objects.create(cuser=self.user, role_type=self.operator_type, sys_ws=self.other_workspace,
name_family='Operator2', mobile=self.user.mobile, is_active=True, mobile_verify=True)
other_customer = Customer.objects.create(name_family='Other', mobile=self.user.mobile, sys_ws=self.other_workspace)
self.use_role(other_operator)
response = self.client.post('/customer/customer-panel/', {'customer_id': other_customer.pk, 'mobile': self.user.mobile, 'status': True}, format='json')
self.assertEqual(response.status_code, 200)
other_customer.refresh_from_db()
self.assertNotEqual(other_customer.cuser_role_id, self.role.pk)
self.assertEqual(other_customer.cuser_role.sys_ws_id, self.other_workspace.pk)
def test_disable_panel_without_link_does_not_create_user(self):
other = Customer.objects.create(name_family='Other', mobile=9333333333, sys_ws=self.workspace)
self.use_role(self.operator)
response = self.client.post('/customer/customer-panel/', {'customer_id': other.pk, 'mobile': other.mobile, 'status': False}, format='json')
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.data['cuser_role_id'])
self.assertEqual(CUser.objects.count(), 1)
def test_provider_checks_body_and_handles_timeout(self):
import requests
with patch('utils.services.sms.requests.get', return_value=Mock(text='0')) as send:
self.assertTrue(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test'))
self.assertEqual(send.call_args.kwargs['timeout'], 10)
with patch('utils.services.sms.requests.get', return_value=Mock(text='error')):
self.assertFalse(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test'))
with patch('utils.services.sms.requests.get', side_effect=requests.Timeout):
self.assertIsNone(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test'))
def test_iranian_mobile_prefix_is_not_accidentally_removed(self):
self.assertEqual(normalize_mobile('9891234567'), 9891234567)
def test_logout_during_refresh_rotation_revokes_new_tokens(self):
tokens = self.use_role()
rotated = self.post('token/refresh/', {'refresh': tokens['refresh']})
self.assertEqual(rotated.status_code, 200)
self.assertEqual(self.post('logout/', {'refresh': tokens['refresh']}).status_code, 204)
self.assertEqual(self.post('token/refresh/', {'refresh': rotated.data['refresh']}).status_code, 401)
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + rotated.data['access'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
def test_mobile_change_disconnects_old_panel_and_revokes_old_session(self):
tokens = issue_tokens(self.role)
self.use_role(self.operator)
response = self.client.patch(f'/customer/update/{self.customer.pk}/', {'mobile': '09333333333'}, format='json')
self.assertEqual(response.status_code, 200)
self.customer.refresh_from_db()
self.role.refresh_from_db()
self.assertIsNone(self.customer.cuser_role_id)
self.assertFalse(self.role.customer_panel)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_duplicate_migration_quarantines_without_deleting_customers(self):
import importlib
from django.apps import apps
from django.db import connection
duplicate = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
migration = importlib.import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
duplicate.refresh_from_db()
self.customer.refresh_from_db()
self.role.refresh_from_db()
self.assertIsNone(duplicate.mobile)
self.assertIsNone(self.customer.mobile)
self.assertIsNone(self.customer.cuser_role_id)
self.assertFalse(self.role.customer_panel)
from apps.customer.models import MobileConflict
self.assertEqual(MobileConflict.objects.get(record_id=duplicate.pk).original_mobile, 989123456789)
def test_migration_normalizes_legacy_country_prefix(self):
import importlib
from django.apps import apps
from django.db import connection
legacy = Customer.objects.create(name_family='Legacy', mobile=989333333333, sys_ws=self.workspace)
migration = importlib.import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
legacy.refresh_from_db()
self.assertEqual(legacy.mobile, 9333333333)
def test_customer_excel_import_reports_duplicate_and_rolls_back_all_rows(self):
import pandas as pd
self.city.ostan = City.objects.create(name='Province', is_ostan=True)
self.city.save()
role = CuserRole.objects.create(cuser=self.user, role_type=self.admin_type, sys_ws=self.workspace,
name_family='Admin', mobile=self.user.mobile, is_active=True, mobile_verify=True)
self.use_role(role)
rows = [{'name': 'New', 'mobile': 9333333333, 'phone': None, 'address': None,
'ostan': 'Province', 'shar': 'City', 'id_mantaghe': self.group.pk},
{'name': 'Duplicate', 'mobile': self.user.mobile, 'phone': None, 'address': None,
'ostan': 'Province', 'shar': 'City', 'id_mantaghe': self.group.pk}]
with patch('apps.shop.views.excel_import.pd.read_excel', return_value=pd.DataFrame(rows)):
response = self.client.get('/shop/import/customer/')
self.assertEqual(response.status_code, 400)
self.assertIn('mobile', response.data['errors'])
self.assertEqual(Customer.objects.count(), 1)
def test_anonymous_import_requests_are_rejected(self):
self.assertEqual(self.client.get('/shop/import/customer/').status_code, 401)
self.assertEqual(self.client.get('/shop/import/').status_code, 401)
def test_recorded_session_expiry_is_enforced(self):
tokens = self.use_role()
JwtToken.objects.update(expire=timezone.now()-timedelta(seconds=1))
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_inactive_role_and_workspace_reject_access(self):
self.use_role()
self.role.is_active = False
self.role.save()
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.role.is_active = True
self.role.save()
self.workspace.is_active = False
self.workspace.save()
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
def test_admin_user_creation_hashes_password_and_normalizes_mobile(self):
self.user.is_staff = True
self.user.is_superuser = True
self.user.save()
self.client.force_login(self.user)
response = self.client.post('/admin/cuser/cuser/add/', {
'username': 'new-user', 'name_family': 'New User', 'mobile': '+98 933-333-3333',
'password1': 'Valid-Password-9320!', 'password2': 'Valid-Password-9320!',
'is_active': 'on', 'is_staff': 'on', '_save': 'Save',
})
self.assertEqual(response.status_code, 302)
user = CUser.objects.get(username='new-user')
self.assertEqual(user.mobile, 9333333333)
self.assertTrue(user.check_password('Valid-Password-9320!'))
self.assertNotEqual(user.password, 'Valid-Password-9320!')
def test_admin_duplicate_user_mobile_is_a_form_error(self):
self.user.is_staff = True
self.user.is_superuser = True
self.user.save()
self.client.force_login(self.user)
response = self.client.post('/admin/cuser/cuser/add/', {
'username': 'duplicate', 'mobile': '+98 912-345-6789',
'password1': 'Valid-Password-9320!', 'password2': 'Valid-Password-9320!', '_save': 'Save',
})
self.assertEqual(response.status_code, 200)
self.assertIn('mobile', response.context['adminform'].form.errors)
self.assertEqual(CUser.objects.count(), 1)
class OrderingRegressionTests(RegressionFixtures):
def setUp(self):
self.first = ProductCategory.objects.create(name='First', sys_ws=self.workspace, order_cat=1, excel_order=20)
self.second = ProductCategory.objects.create(name='Second', sys_ws=self.workspace, order_cat=2, excel_order=10)
self.unordered = ProductCategory.objects.create(name='Unordered', sys_ws=self.workspace)
self.a = Product.objects.create(name='A', category=self.first, sys_ws=self.workspace, price=10, order_prd=2, excel_order=1, attr={'acc_vahed': 'Unit'})
self.b = Product.objects.create(name='B', category=self.first, sys_ws=self.workspace, price=20, order_prd=1, excel_order=2)
self.c = Product.objects.create(name='C', category=self.second, sys_ws=self.workspace, price=30, order_prd=1)
def test_display_order_groups_categories_and_products(self):
self.assertEqual(list(ProductCategory.objects.values_list('name', flat=True)), ['First', 'Second', 'Unordered'])
self.assertEqual(list(Product.objects.values_list('name', flat=True)), ['B', 'A', 'C'])
def test_admin_excel_independent_order_and_current_column_order(self):
model_admin = ProductAdmin(Product, admin.site)
sheet = load_workbook(BytesIO(model_admin.export_excel_order(None, Product.objects.all()).content)).active
self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['C', 'A', 'B'])
sheet = load_workbook(BytesIO(model_admin.export_display_order(None, Product.objects.order_by('-name')).content)).active
self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['C', 'B', 'A'])
category_admin = ProductCategoryAdmin(ProductCategory, admin.site)
sheet = load_workbook(BytesIO(category_admin.export_excel_order(None, ProductCategory.objects.all()).content)).active
self.assertEqual([row[1] for row in list(sheet.values)[1:]], ['Second', 'First', 'Unordered'])
def test_empty_excel_order_falls_back_to_display(self):
ProductCategory.objects.update(excel_order=None)
Product.objects.update(excel_order=None)
response = ProductAdmin(Product, admin.site).export_excel_order(None, Product.objects.all())
sheet = load_workbook(BytesIO(response.content)).active
self.assertEqual([row[2] for row in list(sheet.values)[1:]], ['B', 'A', 'C'])
def test_excel_keeps_formula_like_names_as_text(self):
response = excel_response(('Name',), [('=1+1',)], 'test.xlsx')
cell = load_workbook(BytesIO(response.content)).active['A2']
self.assertEqual(cell.data_type, 's')
self.assertEqual(cell.value, '=1+1')
def test_category_api_is_ordered_and_does_not_leak_other_workspace(self):
foreign = ProductCategory.objects.create(name='Foreign', sys_ws=self.other_workspace, order_cat=0)
Product.objects.create(name='Foreign product', sys_ws=self.other_workspace, category=foreign, price=1)
self.use_role()
response = self.client.get('/shop/v2/product-category/list/')
self.assertEqual(response.status_code, 200)
self.assertEqual([row['name'] for row in response.data], ['First', 'Second'])
response = self.client.get(f'/shop/product/list/{self.first.pk}/')
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.data['category_img'])
self.assertEqual([row['name'] for row in response.data['products']], ['B', 'A'])
def test_catalog_order_changes_follow_database_and_zero_precedes_null(self):
Product.objects.create(name='Unordered product', category=self.unordered, sys_ws=self.workspace, price=1)
self.use_role()
ProductCategory.objects.filter(pk=self.second.pk).update(order_cat=0)
response = self.client.get('/shop/v2/product-category/list/')
self.assertEqual([row['name'] for row in response.data], ['Second', 'First', 'Unordered'])
Product.objects.filter(pk=self.a.pk).update(order_prd=0)
Product.objects.filter(pk=self.b.pk).update(order_prd=None)
response = self.client.get(f'/shop/product/list/{self.first.pk}/')
self.assertEqual([row['name'] for row in response.data['products']], ['A', 'B'])
Product.objects.create(name='Custom later', category=self.first, sys_ws=self.workspace, price=1, view_only=True, order_prd=None)
Product.objects.create(name='Custom first', category=self.first, sys_ws=self.workspace, price=1, view_only=True, order_prd=0)
response = self.client.get(f'/shop/product/list/{self.first.pk}/')
self.assertEqual([row['name'] for row in response.data['view_only_products']], ['Custom first', 'Custom later'])
def test_order_excel_uses_current_database_order_independently_of_insertion(self):
order = Order.objects.create(customer=self.customer, total_price=60, factor_type=1, sys_ws=self.workspace)
for product in [self.c, self.b, self.a]:
OrderItem.objects.create(order=order, product=product, product_name=product.name, price=product.price, amount=1)
self.use_role(self.operator)
ProductCategory.objects.filter(pk=self.first.pk).update(excel_order=0)
Product.objects.filter(pk=self.b.pk).update(excel_order=0)
response = self.client.get(f'/shop/order/{order.pk}/operator/excel/')
self.assertEqual(response.status_code, 200)
sheet = load_workbook(BytesIO(response.content)).active
self.assertEqual([row[0] for row in list(sheet.values)[1:]], ['FirstB', 'FirstA', 'SecondC'])
ProductCategory.objects.update(excel_order=None)
Product.objects.update(excel_order=None)
ProductCategory.objects.filter(pk=self.second.pk).update(order_cat=0)
response = self.client.get(f'/shop/order/{order.pk}/operator/excel/')
sheet = load_workbook(BytesIO(response.content)).active
self.assertEqual([row[0] for row in list(sheet.values)[1:]], ['SecondC', 'FirstB', 'FirstA'])
def test_order_excel_matches_independent_order_and_handles_empty_order(self):
order = Order.objects.create(customer=self.customer, total_price=60, factor_type=1, sys_ws=self.workspace)
for product in [self.b, self.a, self.c]:
OrderItem.objects.create(order=order, product=product, product_name=product.name, price=product.price, amount=1)
self.use_role(self.operator)
response = self.client.get(f'/shop/order/{order.pk}/operator/excel/')
self.assertEqual(response.status_code, 200)
sheet = load_workbook(BytesIO(response.content)).active
self.assertEqual([row[0] for row in list(sheet.values)[1:]], ['SecondC', 'FirstA', 'FirstB'])
OrderItem.objects.all().delete()
response = self.client.get(f'/shop/order/{order.pk}/operator/excel/')
self.assertEqual(response.status_code, 200)
self.assertEqual(load_workbook(BytesIO(response.content)).active.max_row, 1)
def test_order_excel_requires_operator_and_matching_workspace(self):
order = Order.objects.create(customer=self.customer, total_price=0, factor_type=1, sys_ws=self.other_workspace)
self.use_role()
self.assertEqual(self.client.get(f'/shop/order/{order.pk}/operator/excel/').status_code, 403)
self.use_role(self.operator)
self.assertEqual(self.client.get(f'/shop/order/{order.pk}/operator/excel/').status_code, 400)
def test_admin_bulk_edit_category_and_product_orders(self):
self.user.is_staff = True
self.user.is_superuser = True
self.user.save()
self.client.force_login(self.user)
for model, field, objects in [('productcategory', 'order_cat', [self.first, self.second, self.unordered]),
('product', 'order_prd', [self.a, self.b, self.c])]:
path = f'/admin/shop/{model}/'
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
data = {'_save': 'Save', 'form-TOTAL_FORMS': len(objects), 'form-INITIAL_FORMS': len(objects),
'form-MIN_NUM_FORMS': 0, 'form-MAX_NUM_FORMS': 1000}
for index, obj in enumerate(objects):
data.update({f'form-{index}-id': obj.pk, f'form-{index}-{field}': 10+index,
f'form-{index}-excel_order': 20-index})
self.assertEqual(self.client.post(path, data).status_code, 302)
for index, obj in enumerate(objects):
obj.refresh_from_db()
self.assertEqual(getattr(obj, field), 10+index)
self.assertEqual(obj.excel_order, 20-index)
+3 -1
View File
@@ -4,10 +4,12 @@ from apps.jwt_auth.views import (
CustomTokenObtainPairView,
LoginByUserView,
LoginByMobileView,
VerifyMobileAndGetTokenView
VerifyMobileAndGetTokenView,
LogoutView,
)
urlpatterns = [
path('logout/', LogoutView.as_view(), name='logout'),
path('login/username/', LoginByUserView.as_view(), name='login_by_username'),
path('login/mobile/', LoginByMobileView.as_view(), name='login_by_mobile'),
path('login/mobile/verify/', VerifyMobileAndGetTokenView.as_view(), name='verify_mobile'),
+98 -244
View File
@@ -1,297 +1,151 @@
from django.shortcuts import render
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework import serializers
from rest_framework.response import Response
from django.db import transaction
from apps.jwt_auth.models import JwtToken as JwtTokenModel, CUserRoleLoginOpt
from apps.cuser.models import CuserRole as CuserRoleModel, RoleType as RoleTypeModel
from apps.customer.models import Customer as CustomerModel, CustomerResponsible as CustomerResponsibleModel
from rest_framework_simplejwt.tokens import RefreshToken, AccessToken, Token
from rest_framework_simplejwt.authentication import JWTAuthentication
from apps.workspace.models import Workspace as WorkspaceModel
from django.utils import timezone
from rest_framework import serializers, status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import RefreshToken
from apps.cuser.models import CUser, CuserRole, RoleType
from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt
from apps.jwt_auth.services import login_by_username, login_by_mobile, verify_mobile_and_get_token
from django.contrib.auth import get_user_model
from rest_framework import status
CUser = get_user_model()
from apps.jwt_auth.session import issue_tokens, validate_session_claims
from apps.workspace.models import Workspace
# Create your views here.
class VerifyMobileAndGetTokenView(APIView):
class InputSerializer(serializers.Serializer):
mobile = serializers.IntegerField(required=True)
opt = serializers.IntegerField(required=True)
class OutputSerializer(serializers.ModelSerializer):
class CUserRoleSerializer(serializers.ModelSerializer):
class RoleTypeSerializer(serializers.ModelSerializer):
class Meta:
model = RoleTypeModel
fields = ('role_name', )
model = RoleType
fields = ('role_name', 'role_name_en')
class RoleSerializer(serializers.ModelSerializer):
class WorkspaceSerializer(serializers.ModelSerializer):
class Meta:
model = Workspace
fields = ('id', 'name')
role_type = RoleTypeSerializer()
sys_ws = WorkspaceSerializer()
class Meta:
model = CuserRoleModel
fields = ('name_family', 'title', 'role_type')
model = CuserRole
fields = ('name_family', 'title', 'role_type', 'sys_ws')
cuser_role = CUserRoleSerializer()
class LoginOptionSerializer(serializers.ModelSerializer):
cuser_role = RoleSerializer()
class Meta:
model = CUserRoleLoginOpt
fields = ('cuser_role', 'expire', 'uuid')
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
login_opts = verify_mobile_and_get_token(**serializer.validated_data)
class PublicAuthView(APIView):
authentication_classes = ()
permission_classes = (AllowAny,)
response_data = self.OutputSerializer(login_opts, many=True).data
return Response(response_data)
def get_authenticate_header(self, request):
return 'Bearer realm="api"'
class LoginByMobileView(APIView):
class VerifyMobileAndGetTokenView(PublicAuthView):
class InputSerializer(serializers.Serializer):
mobile = serializers.IntegerField(required=True)
mobile = serializers.CharField(required=True)
opt = serializers.IntegerField(required=True, min_value=100000, max_value=999999)
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
login_by_mobile(**serializer.validated_data)
output = {
"result": {
"mobile": serializer.validated_data.get('mobile'),
"message": "opt code send"
}
}
return Response(output)
options = verify_mobile_and_get_token(**serializer.validated_data)
return Response(LoginOptionSerializer(options, many=True).data)
class LoginByUserView(APIView):
class LoginByMobileView(PublicAuthView):
class InputSerializer(serializers.Serializer):
mobile = serializers.CharField(required=True)
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
result = login_by_mobile(**serializer.validated_data)
return Response({'result': result}, status=202 if result['delivery_status'] == 'unconfirmed' else 200)
class LoginByUserView(PublicAuthView):
class InputSerializer(serializers.Serializer):
username = serializers.CharField(required=True)
password = serializers.CharField(required=True)
class OutputSerializer(serializers.ModelSerializer):
class CUserRoleSerializer(serializers.ModelSerializer):
class RoleTypeSerializer(serializers.ModelSerializer):
class Meta:
model = RoleTypeModel
fields = ('role_name', )
role_type = RoleTypeSerializer()
class Meta:
model = CuserRoleModel
fields = ('name_family', 'title', 'role_type')
cuser_role = CUserRoleSerializer()
class Meta:
model = CUserRoleLoginOpt
fields = ('cuser_role', 'expire', 'uuid')
password = serializers.CharField(required=True, trim_whitespace=False, write_only=True)
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
username = serializer.validated_data.get('username')
password = serializer.validated_data.get('password')
login_opts = login_by_username(username=username, password=password)
response_data = self.OutputSerializer(login_opts, many=True).data
return Response(response_data)
options = login_by_username(**serializer.validated_data)
return Response(LoginOptionSerializer(options, many=True).data)
class CustomTokenObtainPairView(APIView):
class CustomTokenObtainPairView(PublicAuthView):
class InputSerializer(serializers.Serializer):
uuid_opt = serializers.CharField(required=True)
class OutputSerializer(serializers.Serializer):
access = serializers.CharField(required=True)
refresh = serializers.CharField(required=True)
uuid_opt = serializers.UUIDField(required=True)
@transaction.atomic
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
uuid_opt = serializer.validated_data.get('uuid_opt')
try:
user_login_opt = CUserRoleLoginOpt.objects.get(uuid=uuid_opt)
user = user_login_opt.cuser
cuser_role = user_login_opt.cuser_role
user_id = CUserRoleLoginOpt.objects.values_list('cuser_id', flat=True).get(
uuid=serializer.validated_data['uuid_opt'], expire__gt=timezone.now())
CUser.objects.select_for_update().get(pk=user_id)
option = CUserRoleLoginOpt.objects.select_related(
'cuser_role__cuser', 'cuser_role__role_type', 'cuser_role__sys_ws',
).get(uuid=serializer.validated_data['uuid_opt'], expire__gt=timezone.now())
except (CUserRoleLoginOpt.DoesNotExist, CUser.DoesNotExist):
raise InvalidToken('اعتبار درخواست ورود تمام شده است. دوباره وارد شوید.')
if option.cuser_id != option.cuser_role.cuser_id:
raise InvalidToken('درخواست ورود معتبر نیست.')
data = issue_tokens(option.cuser_role)
CUserRoleLoginOpt.objects.filter(cuser_id=option.cuser_id).delete()
return Response(data)
workspace = cuser_role.sys_ws
if user_login_opt:
refresh = RefreshToken.for_user(user)
#if 'user_id' in refresh:
# del refresh['user_id']
#print(cuser_role.uuid)
'''try:
#customerObj = CustomerModel.objects.get(cuser_role__uuid=str(cuser_role.uuid))
# workspace = customerObj.sys_ws
workspace = cuser_role.sys_ws
except:
operator = CustomerResponsibleModel.objects.get(cuser_role=cuser_role)
workspace = operator.sys_ws'''
refresh['workspace_uuid'] = str(workspace.uuid)
refresh['cuser_role_uuid'] = str(cuser_role.uuid)
refresh['user_uuid'] = str(user.uuid)
#print(str(refresh))
try:
jwt_db = JwtTokenModel(refresh=str(refresh), access=str(refresh.access_token),
cuser=user, cuser_role=cuser_role)
jwt_db.save()
try:
CUserRoleLoginOpt.objects.filter(cuser=user).delete()
except Exception as exp:
pass
except Exception as exp:
return Response({'error': str(exp)})
new_token_data = {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
out_data = self.OutputSerializer(data=new_token_data)
out_data.is_valid(raise_exception=True)
return Response(out_data.data)
else:
return Response({"error": 'userpass is not True'})
except CUserRoleLoginOpt.DoesNotExist:
return Response({"error": 'Token not valid'})
except Exception as exp:
return Response({"error": 'user or pass is not true 2'})
class CustomTokenRefreshView(APIView):
class CustomTokenRefreshView(PublicAuthView):
class InputSerializer(serializers.Serializer):
refresh = serializers.CharField(required=True)
class OutputSerializer(serializers.Serializer):
access = serializers.CharField(required=True)
@transaction.atomic
def post(self, request):
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
refresh_value = serializer.validated_data['refresh']
try:
refresh = RefreshToken(refresh_value)
# Nullable user/workspace joins must not participate in PostgreSQL's
# row lock. The session alone serializes refresh-token rotation.
session = JwtToken.objects.select_for_update(of=('self',)).select_related(
'cuser_role__cuser', 'cuser_role__role_type', 'cuser_role__sys_ws',
).get(refresh=refresh_value, is_blocked=False)
except (TokenError, JwtToken.DoesNotExist):
raise InvalidToken('نشست شما منقضی یا باطل شده است. دوباره وارد شوید.')
validate_session_claims(session, refresh)
return Response(issue_tokens(session.cuser_role, session=session))
class LogoutView(PublicAuthView):
class InputSerializer(serializers.Serializer):
refresh = serializers.CharField(required=True)
def post(self, request):
with transaction.atomic():
try:
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
refresh_token = serializer.validated_data.get('refresh')
print(refresh_token)
# check: token is valid?
ref_token = RefreshToken(token=refresh_token)
print("ref")
# first token must exists in whitelist and not blocked
token_exist = JwtTokenModel.objects.get(refresh=str(refresh_token))
print("exsist")
token_is_blocked = JwtTokenModel.objects.get(refresh=str(refresh_token)).is_blocked
print("yess")
if token_exist and token_is_blocked is False:
JwtTokenModel.objects.get(refresh=str(refresh_token)).delete()
else:
raise ValueError('refresh token is not in whitelist or blocked!')
user_uuid = ref_token.payload.get('user_uuid')
user_id = ref_token.payload.get('user_id')
cuser_role_uuid = ref_token.payload.get('cuser_role_uuid')
token_exp_timestamp = ref_token.payload.get('exp')
token_exp = timezone.make_aware(timezone.datetime.fromtimestamp(token_exp_timestamp), timezone.utc)
user = CUser.objects.get(id=user_id)
cuser_role = CuserRoleModel.objects.get(uuid=cuser_role_uuid)
print("OKH###")
if ref_token:
new_token = RefreshToken.for_user(user)
#if 'user_id' in new_token:
#del new_token['user_id']
new_token['user_uuid'] = str(user.uuid)
new_token['cuser_role_uuid'] = str(cuser_role.uuid)
print("OKOKKO 555")
#workspace = WorkspaceModel.objects.get(cuser_role_owner__uuid=str(cuser_role.uuid))
value = serializer.validated_data['refresh']
try:
customerObj = CustomerModel.objects.get(cuser_role__uuid=str(cuser_role.uuid))
new_token['workspace_uuid'] = str(customerObj.sys_ws.uuid)
except:
operator = CustomerResponsibleModel.objects.get(cuser_role__uuid=str(cuser_role.uuid))
new_token['workspace_uuid'] = str(operator.sys_ws.uuid)
new_access_token = new_token.access_token
new_token_data = {
'refresh': str(new_token),
'access': str(new_access_token),
}
print("OKOKKO")
new_token_obj = JwtTokenModel(refresh=str(new_token),
access=str(new_token_data.get('access')),
expire=token_exp,
cuser=user,
cuser_role=cuser_role
)
print("OKOKKO 222")
new_token_obj.save()
out_data = self.OutputSerializer(data=new_token_data)
out_data.is_valid(raise_exception=True)
return Response(out_data.data)
except Exception as e:
transaction.set_rollback(True)
return Response({"error": str(e)})
token = RefreshToken(value)
except TokenError:
return Response(status=status.HTTP_204_NO_CONTENT)
# Possession of the signed refresh token permits logout even after access expires.
if type(token.get('session_id')) is int and type(token.get('user_id')) is int:
JwtToken.objects.filter(pk=token['session_id'], cuser_id=token['user_id']).update(is_blocked=True)
else:
JwtToken.objects.filter(refresh=value).update(is_blocked=True)
return Response(status=status.HTTP_204_NO_CONTENT)
+23 -6
View File
@@ -1,36 +1,53 @@
from utils.admin_base import CRMModelAdmin
from django.contrib import admin
from apps.lead.models import *
# Register your models here.
class LeadCommentInline(admin.TabularInline):
model = LeadComment
extra = 0
fields = ["desc","cuser_role","created_at"]
readonly_fields = fields
can_delete = False
show_change_link = True
def has_add_permission(self, request, obj=None):
return False
@admin.register(Lead)
class LeadAdmin(admin.ModelAdmin):
class LeadAdmin(CRMModelAdmin):
pass
@admin.register(LeadComment)
class LeadCommentAdmin(admin.ModelAdmin):
class LeadCommentAdmin(CRMModelAdmin):
pass
@admin.register(LeadStep)
class LeadStepAdmin(admin.ModelAdmin):
class LeadStepAdmin(CRMModelAdmin):
pass
@admin.register(LeadType)
class LeadTypeAdmin(admin.ModelAdmin):
class LeadTypeAdmin(CRMModelAdmin):
pass
@admin.register(LeadSource)
class LeadSourceAdmin(admin.ModelAdmin):
class LeadSourceAdmin(CRMModelAdmin):
pass
@admin.register(LeadReason)
class LeadReasonAdmin(admin.ModelAdmin):
class LeadReasonAdmin(CRMModelAdmin):
pass
@admin.register(LeadEvent)
class LeadEventAdmin(CRMModelAdmin):
pass
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-01-27 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0028_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('UN', 'unknown'), ('FA', 'FAIL'), ('SU', 'OK')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-01-27 11:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0029_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('SU', 'OK'), ('FA', 'FAIL'), ('UN', 'unknown')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-01-27 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0030_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('UN', 'unknown'), ('SU', 'OK'), ('FA', 'FAIL')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-06 10:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0031_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('UN', 'unknown'), ('FA', 'FAIL'), ('SU', 'OK')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-06 10:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0032_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('SU', 'OK'), ('FA', 'FAIL'), ('UN', 'unknown')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-06 11:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0033_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('FA', 'FAIL'), ('SU', 'OK'), ('UN', 'unknown')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-06 12:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0034_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('SU', 'OK'), ('FA', 'FAIL'), ('UN', 'unknown')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-10 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0035_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('SU', 'OK'), ('UN', 'unknown'), ('FA', 'FAIL')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-10 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0036_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('UN', 'unknown'), ('SU', 'OK'), ('FA', 'FAIL')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-18 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0037_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('FA', 'FAIL'), ('SU', 'OK'), ('UN', 'unknown')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-27 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0038_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('SU', 'OK'), ('UN', 'unknown'), ('FA', 'FAIL')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-04-16 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0039_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('FA', 'FAIL'), ('SU', 'OK'), ('UN', 'unknown')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-07-25 05:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0040_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('UN', 'unknown'), ('FA', 'FAIL'), ('SU', 'OK')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 4.2.1 on 2024-07-25 05:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0041_alter_lead_status'),
]
operations = [
migrations.AlterField(
model_name='lead',
name='desc',
field=models.TextField(),
),
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('SU', 'OK'), ('FA', 'FAIL'), ('UN', 'unknown')], default='UN', max_length=2, null=True),
),
migrations.AlterField(
model_name='lead',
name='title',
field=models.CharField(max_length=300),
),
migrations.AlterField(
model_name='leadcomment',
name='desc',
field=models.TextField(),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 4.2.1 on 2024-08-24 13:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0042_alter_lead_desc_alter_lead_status_alter_lead_title_and_more'),
]
operations = [
migrations.AddField(
model_name='lead',
name='mobile1',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='lead',
name='mobile2',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='lead',
name='phone',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('UN', 'unknown'), ('SU', 'OK'), ('FA', 'FAIL')], default='UN', max_length=2, null=True),
),
]
+28
View File
@@ -0,0 +1,28 @@
# Generated by Django 4.2.1 on 2024-08-24 13:21
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('workspace', '0016_remove_workspace_cuser_owner'),
('lead', '0043_lead_mobile1_lead_mobile2_lead_phone_and_more'),
]
operations = [
migrations.CreateModel(
name='LeadEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=50)),
('sys_ws', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='workspace.workspace')),
],
options={
'abstract': False,
},
),
]
@@ -0,0 +1,24 @@
# Generated by Django 4.2.1 on 2024-08-27 06:14
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('lead', '0044_leadevent'),
]
operations = [
migrations.AddField(
model_name='lead',
name='lead_event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='lead.leadevent'),
),
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(blank=True, choices=[('FA', 'FAIL'), ('UN', 'unknown'), ('SU', 'OK')], default='UN', max_length=2, null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.2.1 on 2024-08-27 06:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lead', '0045_lead_lead_event_alter_lead_status'),
]
operations = [
migrations.AddField(
model_name='leadevent',
name='message',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='leadevent',
name='send_sms',
field=models.BooleanField(blank=True, default=False, null=True),
),
]
+15 -3
View File
@@ -30,6 +30,14 @@ class LeadStep(BaseModel):
def __str__(self):
return self.name
class LeadEvent(BaseModel):
name = models.CharField(max_length=50)
message = models.TextField(null=True, blank=True)
send_sms = models.BooleanField(default=False, null=True, blank=True)
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False)
def __str__(self):
return self.name
class LeadReason(BaseModel):
name = models.CharField(max_length=50)
@@ -48,9 +56,13 @@ class Lead(BaseModel):
("UN", "unknown"),
}
title = models.CharField(max_length=100)
desc = models.CharField(max_length=300)
title = models.CharField(max_length=300)
desc = models.TextField()
mobile1 = models.PositiveBigIntegerField(null=True, blank=True)
mobile2 = models.PositiveBigIntegerField(null=True, blank=True)
phone = models.PositiveBigIntegerField(null=True, blank=True)
customer = models.ForeignKey(CustomerModel, on_delete=models.CASCADE, null=False, blank=False)
lead_event = models.ForeignKey(LeadEvent, on_delete=models.SET_NULL, null=True, blank=True)
product_type = models.ForeignKey(ProductTypeModel, on_delete=models.SET_NULL, null=True, blank=True)
type = models.ForeignKey(LeadType, on_delete=models.SET_NULL, null=True, blank=True)
source = models.ForeignKey(LeadSource, on_delete=models.SET_NULL, null=True, blank=True)
@@ -68,7 +80,7 @@ class Lead(BaseModel):
class LeadComment(BaseModel):
lead = models.ForeignKey(Lead, on_delete=models.CASCADE, null=False, blank=False, related_name='lead_comment')
desc = models.CharField(max_length=300)
desc = models.TextField()
cuser_role = models.ForeignKey(CuserRoleModel, on_delete=models.SET_NULL, null=True, blank=True)
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False)
+24 -4
View File
@@ -1,9 +1,9 @@
from apps.lead.models import Lead as LeadModel, LeadType as LeadTypeModel,\
LeadSource as LeadSourceModel, LeadStep as LeadStepModel
LeadSource as LeadSourceModel, LeadStep as LeadStepModel, LeadEvent as LeadEventModel
from apps.shop.models import ProductType as ProductTypeModel
from apps.workspace.models import City as CityModel
from utils.get_user_workspace import get_user_workspace_uuid
from utils.get_cuser_role import get_cuser_role_uuid
from utils.get_cuser_role import get_cuser_role_uuid, get_cuser_role
from rest_framework import exceptions
@@ -12,7 +12,16 @@ def list_leads(request) -> LeadModel:
try:
workspace_uuid = get_user_workspace_uuid(request)
cuser_role_uuid = get_cuser_role_uuid(request)
leads = LeadModel.objects.filter(sys_ws__uuid=workspace_uuid, cuser_role__uuid=cuser_role_uuid).order_by('-created_at')
cuser_role = get_cuser_role(request)
base_filter = {
'sys_ws__uuid': workspace_uuid,
}
#if not cuser_role.is_admin:
# base_filter['cuser_role__uuid'] = cuser_role_uuid
leads = LeadModel.objects.filter(**base_filter).order_by('-created_at')
return leads
except Exception as exp:
@@ -25,7 +34,16 @@ def retrieve_lead(request, id: int) -> LeadModel:
try:
workspace_uuid = get_user_workspace_uuid(request)
cuser_role_uuid = get_cuser_role_uuid(request)
lead = LeadModel.objects.get(id=id, sys_ws__uuid=workspace_uuid, cuser_role__uuid=cuser_role_uuid)
cuser_role = get_cuser_role(request)
base_filter = {
'sys_ws__uuid': workspace_uuid,
}
#if not cuser_role.is_admin:
# base_filter['cuser_role__uuid'] = cuser_role_uuid
lead = LeadModel.objects.get(id=id, **base_filter)
return lead
except Exception as exp:
message = f'Error retrieve Leads: {exp}'
@@ -66,6 +84,7 @@ def fields_new_lead_form(request):
product_types = ProductTypeModel.objects.filter(sys_ws__uuid=workspace_uuid)
lead_source = LeadSourceModel.objects.filter(sys_ws__uuid=workspace_uuid)
lead_step = LeadStepModel.objects.filter(sys_ws__uuid=workspace_uuid)
lead_event = LeadEventModel.objects.filter(sys_ws__uuid=workspace_uuid)
shahr = CityModel.objects.filter()
data = {
@@ -73,6 +92,7 @@ def fields_new_lead_form(request):
'lead_types': lead_type,
'lead_source': lead_source,
'lead_steps': lead_step,
'lead_event': lead_event,
'shahr': shahr,
}
return data
+8 -1
View File
@@ -1,7 +1,8 @@
from rest_framework import serializers
from apps.lead.models import (
LeadType as LeadTypeModel, LeadSource as LeadSourceModel,
LeadStep as LeadStepModel, LeadReason as LeadReasonModel
LeadStep as LeadStepModel, LeadReason as LeadReasonModel,
LeadEvent as LeadEventModel
)
from apps.shop.models import ProductType as ProductTypeModel
from apps.workspace.models import City as CityModel
@@ -39,6 +40,12 @@ class LeadReasonSerializers(serializers.ModelSerializer):
exclude = ['sys_ws', 'created_at', 'updated_at']
class LeadEventSerializers(serializers.ModelSerializer):
class Meta:
model = LeadEventModel
exclude = ['sys_ws', 'created_at', 'updated_at']
class ShahrSerializers(serializers.ModelSerializer):
class Meta:
model = CityModel
+41 -11
View File
@@ -1,16 +1,17 @@
from apps.lead.models import Lead as LeadModel,\
LeadType as LeadTypeModel, LeadStep as LeadStepModel, LeadSource as LeadSourceModel, LeadComment as LeadCommentModel
LeadType as LeadTypeModel, LeadStep as LeadStepModel, LeadSource as LeadSourceModel, LeadComment as LeadCommentModel, LeadEvent as LeadEventModel
from apps.shop.models import ProductType as ProductTypeModel
from utils.get_user_workspace import get_user_workspace
from utils.get_cuser_role import get_cuser_role
from utils.validations import ObjectValidationById
from apps.customer.models import Customer as CustomerModel
from apps.workspace.models import City as CityModel
from utils.services.sms import DefaultSmsProvider
from rest_framework import exceptions
def create_new_lead(request, title: str, customer_id: str, desc: str, product_type_id: int,
lead_type_id: int, lead_source_id: int, city_id: int, lead_step_id: int) -> LeadModel:
def create_new_lead(request, title: str, customer_id: str, mobile1: int, desc: str, product_type_id: int,
lead_type_id: int, lead_source_id: int, city_id: int, lead_step_id: int, mobile2: int = None, phone: int = None, lead_event_id: int = None) -> LeadModel:
try:
sys_ws = get_user_workspace(request=request)
cuser_role = get_cuser_role(request=request)
@@ -22,9 +23,20 @@ def create_new_lead(request, title: str, customer_id: str, desc: str, product_ty
product_type = ObjectValidationById(object_model=ProductTypeModel, request=request).is_valid(product_type_id)
city = ObjectValidationById(object_model=CityModel, request=request).is_valid(city_id)
lead_event_obj = None
new_lead = LeadModel()
new_lead.title = title
new_lead.customer = customer
if mobile1:
new_lead.mobile1 = mobile1
if mobile2:
new_lead.mobile2 = mobile2
if phone:
new_lead.phone = phone
if lead_event_id and (lead_event_id != '0' or lead_event_id != 0):
lead_event_obj = LeadEventModel.objects.get(id=lead_event_id, sys_ws=sys_ws)
new_lead.lead_event = lead_event_obj
new_lead.desc = desc
new_lead.type = lead_type
new_lead.step = lead_step
@@ -36,6 +48,14 @@ def create_new_lead(request, title: str, customer_id: str, desc: str, product_ty
new_lead.save()
# send sms
if lead_event_obj and lead_event_obj.send_sms:
sms_provider = DefaultSmsProvider()
if mobile1:
sms_provider.send_sms(str(mobile1), lead_event_obj.message)
if mobile2:
sms_provider.send_sms(str(mobile2), lead_event_obj.message)
return new_lead
except Exception as exp:
@@ -45,10 +65,10 @@ def create_new_lead(request, title: str, customer_id: str, desc: str, product_ty
def update_lead(request, lead_id: int, title: str, customer_id: str, desc: str, product_type_id: int,
lead_type_id: int, lead_source_id: int, city_id: int, lead_step_id: int) -> LeadModel:
lead_type_id: int, lead_source_id: int, city_id: int, lead_step_id: int, mobile1: int = None, mobile2: int = None, phone: int = None) -> LeadModel:
try:
sys_ws = get_user_workspace(request=request)
cuser_role = get_cuser_role(request=request)
#cuser_role = get_cuser_role(request=request)
customer = ObjectValidationById(object_model=CustomerModel, request=request).is_valid(int(customer_id))
lead_type = ObjectValidationById(object_model=LeadTypeModel, request=request).is_valid(lead_type_id)
@@ -57,9 +77,16 @@ def update_lead(request, lead_id: int, title: str, customer_id: str, desc: str,
product_type = ObjectValidationById(object_model=ProductTypeModel, request=request).is_valid(product_type_id)
city = ObjectValidationById(object_model=CityModel, request=request).is_valid(city_id)
lead = LeadModel.objects.get(id=lead_id, sys_ws=sys_ws, cuser_role=cuser_role)
#lead = LeadModel.objects.get(id=lead_id, sys_ws=sys_ws, cuser_role=cuser_role)
lead = LeadModel.objects.get(id=lead_id, sys_ws=sys_ws)
lead.title = title
lead.customer = customer
if mobile1:
lead.mobile1 = mobile1
if mobile2:
lead.mobile2 = mobile2
if phone:
lead.phone = phone
lead.desc = desc
lead.type = lead_type
lead.step = lead_step
@@ -96,9 +123,10 @@ def update_lead_status(request, lead_id: int, status: str) -> LeadModel:
def update_lead_comment(request, lead_comment_id: int, desc: str) -> LeadModel:
try:
sys_ws = get_user_workspace(request=request)
cuser_role = get_cuser_role(request=request)
#cuser_role = get_cuser_role(request=request)
lead_comment = LeadCommentModel.objects.get(id=lead_comment_id, sys_ws=sys_ws, cuser_role=cuser_role)
#lead_comment = LeadCommentModel.objects.get(id=lead_comment_id, sys_ws=sys_ws, cuser_role=cuser_role)
lead_comment = LeadCommentModel.objects.get(id=lead_comment_id, sys_ws=sys_ws)
lead_comment.desc = desc
lead_comment.save()
@@ -116,7 +144,8 @@ def create_lead_comment(request, lead_id, desc: str) -> LeadCommentModel:
cuser_role = get_cuser_role(request=request)
lead_comment = LeadCommentModel()
lead_comment.lead = LeadModel.objects.get(id=lead_id, sys_ws=sys_ws, cuser_role=cuser_role)
#lead_comment.lead = LeadModel.objects.get(id=lead_id, sys_ws=sys_ws, cuser_role=cuser_role)
lead_comment.lead = LeadModel.objects.get(id=lead_id, sys_ws=sys_ws)
lead_comment.sys_ws = sys_ws
lead_comment.cuser_role = cuser_role
lead_comment.desc = desc
@@ -132,9 +161,10 @@ def create_lead_comment(request, lead_id, desc: str) -> LeadCommentModel:
def delete_lead_comment(request, lead_comment_id: int) -> LeadModel:
try:
sys_ws = get_user_workspace(request=request)
cuser_role = get_cuser_role(request=request)
#cuser_role = get_cuser_role(request=request)
lead_comment = LeadCommentModel.objects.get(id=lead_comment_id, sys_ws=sys_ws, cuser_role=cuser_role)
#lead_comment = LeadCommentModel.objects.get(id=lead_comment_id, sys_ws=sys_ws, cuser_role=cuser_role)
lead_comment = LeadCommentModel.objects.get(id=lead_comment_id, sys_ws=sys_ws)
lead_comment.delete()
return lead_comment
+14 -4
View File
@@ -4,7 +4,7 @@ from rest_framework.generics import ListAPIView
from rest_framework.response import Response
from rest_framework import serializers
from apps.lead.serializers import LeadTypeSerializers, ProductTypeSerializers,\
LeadSourceSerializers, LeadStepSerializers, ShahrSerializers
LeadSourceSerializers, LeadStepSerializers, ShahrSerializers, LeadEventSerializers
from apps.lead.selectors import fields_new_lead_form
from apps.lead.services import create_new_lead, update_lead, update_lead_status,\
update_lead_comment, delete_lead_comment, create_lead_comment
@@ -25,6 +25,7 @@ class LeadFormItemsApi(APIView):
lead_types = LeadTypeSerializers(many=True)
product_types = ProductTypeSerializers(many=True)
lead_source = LeadSourceSerializers(many=True)
lead_event = LeadEventSerializers(many=True)
lead_steps = LeadStepSerializers(many=True)
shahr = ShahrSerializers(many=True)
@@ -41,7 +42,11 @@ class LeadCreateApi(APIView):
class InputSerializer(serializers.Serializer):
title = serializers.CharField(max_length=100, required=True)
customer_id = serializers.IntegerField(required=True)
desc = serializers.CharField(max_length=200, required=True)
desc = serializers.CharField(required=True)
mobile1 = serializers.IntegerField(required=True)
mobile2 = serializers.IntegerField(required=False)
lead_event_id = serializers.IntegerField(required=False)
phone = serializers.IntegerField(required=False)
lead_type_id = serializers.IntegerField(required=True)
product_type_id = serializers.IntegerField(required=True)
lead_source_id = serializers.IntegerField(required=True)
@@ -68,7 +73,7 @@ class LeadUpdateApi(APIView):
class InputSerializer(serializers.Serializer):
title = serializers.CharField(max_length=100, required=True)
customer_id = serializers.IntegerField(required=True)
desc = serializers.CharField(max_length=200, required=True)
desc = serializers.CharField(max_length=2000, required=True)
lead_type_id = serializers.IntegerField(required=True)
product_type_id = serializers.IntegerField(required=True)
lead_source_id = serializers.IntegerField(required=True)
@@ -139,6 +144,11 @@ class LeadListApi(ListAPIView):
'ostan': inline_serializer(many=False, fields={'name': serializers.CharField() })
})
cuser_role = inline_serializer(many=False, fields={
'id': serializers.IntegerField(),
'name_family': serializers.CharField()
})
class Meta:
model = LeadModel
fields = '__all__'
@@ -223,7 +233,7 @@ class LeadRetrieveApi(APIView):
class LeadCommentCreateApi(APIView):
class InputSerializer(serializers.Serializer):
desc = serializers.CharField(max_length=200, required=True)
desc = serializers.CharField(required=True)
class OutputSerializer(serializers.Serializer):
id = serializers.IntegerField(required=True)
+2 -1
View File
@@ -1,3 +1,4 @@
from utils.admin_base import CRMModelAdmin
from django.contrib import admin
from apps.reminder.models import *
@@ -7,5 +8,5 @@ from apps.reminder.models import *
@admin.register(Reminder)
class ReminderAdmin(admin.ModelAdmin):
class ReminderAdmin(CRMModelAdmin):
pass
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-01-27 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0024_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('LE', 'LEAD'), ('CU', 'CUSTOMER'), ('UN', 'UNKNOWN'), ('NO', 'NOTE')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-01-27 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0025_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('UN', 'UNKNOWN'), ('LE', 'LEAD'), ('CU', 'CUSTOMER'), ('NO', 'NOTE')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-06 10:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0026_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('LE', 'LEAD'), ('NO', 'NOTE'), ('UN', 'UNKNOWN'), ('CU', 'CUSTOMER')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-06 10:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0027_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('LE', 'LEAD'), ('UN', 'UNKNOWN'), ('NO', 'NOTE'), ('CU', 'CUSTOMER')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-06 12:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0028_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('NO', 'NOTE'), ('UN', 'UNKNOWN'), ('CU', 'CUSTOMER'), ('LE', 'LEAD')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-10 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0029_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('LE', 'LEAD'), ('NO', 'NOTE'), ('UN', 'UNKNOWN'), ('CU', 'CUSTOMER')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-10 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0030_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('CU', 'CUSTOMER'), ('NO', 'NOTE'), ('LE', 'LEAD'), ('UN', 'UNKNOWN')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-18 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0031_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('UN', 'UNKNOWN'), ('CU', 'CUSTOMER'), ('LE', 'LEAD'), ('NO', 'NOTE')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-02-27 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0032_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('LE', 'LEAD'), ('UN', 'UNKNOWN'), ('CU', 'CUSTOMER'), ('NO', 'NOTE')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-04-16 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0033_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('CU', 'CUSTOMER'), ('NO', 'NOTE'), ('UN', 'UNKNOWN'), ('LE', 'LEAD')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-07-25 05:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0034_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('UN', 'UNKNOWN'), ('CU', 'CUSTOMER'), ('LE', 'LEAD'), ('NO', 'NOTE')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-07-25 05:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0035_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('NO', 'NOTE'), ('LE', 'LEAD'), ('CU', 'CUSTOMER'), ('UN', 'UNKNOWN')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-08-24 13:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0036_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('LE', 'LEAD'), ('CU', 'CUSTOMER'), ('UN', 'UNKNOWN'), ('NO', 'NOTE')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-08-24 13:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0037_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('LE', 'LEAD'), ('UN', 'UNKNOWN'), ('CU', 'CUSTOMER'), ('NO', 'NOTE')], default='UN', max_length=2),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2024-08-27 06:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reminder', '0038_alter_reminder_type'),
]
operations = [
migrations.AlterField(
model_name='reminder',
name='type',
field=models.CharField(choices=[('CU', 'CUSTOMER'), ('UN', 'UNKNOWN'), ('LE', 'LEAD'), ('NO', 'NOTE')], default='UN', max_length=2),
),
]
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more