Compare commits
28
Commits
e983e0cc43
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28ac2167c | ||
|
|
4dc6d98abf | ||
|
|
ae4dbab3cc | ||
|
|
48d0ecba79 | ||
|
|
56d8760613 | ||
|
|
c8d45dd07c | ||
|
|
423d4cf40e | ||
|
|
2dde1c8a0f | ||
|
|
5e29e9d902 | ||
|
|
c4e1444452 | ||
|
|
05037793e1 | ||
|
|
9c2c2d91de | ||
|
|
b1023a161f | ||
|
|
46971f67e9 | ||
|
|
c7577185a0 | ||
|
|
2db88970ed | ||
|
|
550c113f7f | ||
|
|
708d24c613 | ||
|
|
8941fded53 | ||
|
|
0d0c2452c8 | ||
|
|
813e5055b2 | ||
|
|
076ba42ab2 | ||
|
|
fc914d6f6d | ||
|
|
db708a662e | ||
|
|
5e396c0e32 | ||
|
|
43f5dd4594 | ||
|
|
ef18434b86 | ||
|
|
f7dbd02a6c |
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
@@ -7,6 +7,8 @@ __pycache__/
|
|||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
*.xlsx
|
||||||
|
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
|
|||||||
Generated
+10
@@ -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
@@ -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>
|
||||||
Generated
+5
-2
@@ -13,8 +13,11 @@
|
|||||||
</facet>
|
</facet>
|
||||||
</component>
|
</component>
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$" />
|
<content url="file://$MODULE_DIR$">
|
||||||
<orderEntry type="inheritedJdk" />
|
<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" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PyDocumentationSettings">
|
<component name="PyDocumentationSettings">
|
||||||
|
|||||||
Generated
+4
-1
@@ -1,4 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<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>
|
</project>
|
||||||
Generated
+166
-18
@@ -1,14 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="87c5db51-3e89-4a98-9bbb-121c9c52ab3c" name="Changes" comment="">
|
<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$/.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>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -31,23 +28,35 @@
|
|||||||
<component name="MarkdownSettingsMigration">
|
<component name="MarkdownSettingsMigration">
|
||||||
<option name="stateVersion" value="1" />
|
<option name="stateVersion" value="1" />
|
||||||
</component>
|
</component>
|
||||||
|
<component name="ProjectColorInfo">{
|
||||||
|
"associatedIndex": 3
|
||||||
|
}</component>
|
||||||
<component name="ProjectId" id="2SvKNIPBcTXVjDKPSdS8NHW9I0w" />
|
<component name="ProjectId" id="2SvKNIPBcTXVjDKPSdS8NHW9I0w" />
|
||||||
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
|
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
|
||||||
<component name="ProjectViewState">
|
<component name="ProjectViewState">
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent">{
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"Django Server.crmApi.executor": "Run",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.OpenDjangoStructureViewOnStart": "true",
|
||||||
"WebServerToolWindowFactoryState": "false",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
"last_opened_file_path": "E:/Projects/crmProject/Project/api-Project/crmApi",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"node.js.detected.package.eslint": "true",
|
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"WebServerToolWindowFactoryState": "false",
|
||||||
"settings.editor.selected.configurable": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable"
|
"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">
|
<component name="RecentsManager">
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="E:\Projects\crmProject\Project\api-Project\crmApi\utils" />
|
<recent name="E:\Projects\crmProject\Project\api-Project\crmApi\utils" />
|
||||||
@@ -57,13 +66,14 @@
|
|||||||
<component name="RunManager">
|
<component name="RunManager">
|
||||||
<configuration name="crmApi" type="Python.DjangoServer" factoryName="Django server">
|
<configuration name="crmApi" type="Python.DjangoServer" factoryName="Django server">
|
||||||
<module name="crmApi" />
|
<module name="crmApi" />
|
||||||
|
<option name="ENV_FILES" value="" />
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
<option name="PARENT_ENVS" value="true" />
|
<option name="PARENT_ENVS" value="true" />
|
||||||
<envs>
|
<envs>
|
||||||
<env name="PYTHONUNBUFFERED" value="1" />
|
<env name="PYTHONUNBUFFERED" value="1" />
|
||||||
</envs>
|
</envs>
|
||||||
<option name="SDK_HOME" value="" />
|
<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="IS_MODULE_SDK" value="true" />
|
||||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||||
@@ -79,6 +89,14 @@
|
|||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</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="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||||
<component name="TaskManager">
|
<component name="TaskManager">
|
||||||
<task active="true" id="Default" summary="Default task">
|
<task active="true" id="Default" summary="Default task">
|
||||||
@@ -96,7 +114,49 @@
|
|||||||
<workItem from="1697269439761" duration="66295000" />
|
<workItem from="1697269439761" duration="66295000" />
|
||||||
<workItem from="1702900258020" duration="63755000" />
|
<workItem from="1702900258020" duration="63755000" />
|
||||||
<workItem from="1704088148329" duration="105568000" />
|
<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>
|
</task>
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
@@ -114,4 +174,92 @@
|
|||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</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>
|
</project>
|
||||||
@@ -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
@@ -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
@@ -1,4 +1,7 @@
|
|||||||
|
from utils.admin_base import CRMModelAdmin
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from apps.cuser.forms import CUserCreationForm, CUserChangeForm
|
||||||
from apps.cuser.models import (
|
from apps.cuser.models import (
|
||||||
CUser as CUserModel,
|
CUser as CUserModel,
|
||||||
CuserRole as CuserRoleModel,
|
CuserRole as CuserRoleModel,
|
||||||
@@ -8,20 +11,50 @@ from apps.cuser.models import (
|
|||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
|
||||||
|
|
||||||
class CUserAdmin(admin.ModelAdmin):
|
class CUserAdmin(UserAdmin):
|
||||||
pass
|
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):
|
class CUserRoleAdmin(CRMModelAdmin):
|
||||||
pass
|
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):
|
class RoleTypeAdmin(CRMModelAdmin):
|
||||||
pass
|
list_display = ('id', 'role_name', 'role_name_en', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
class SmsOptAdmin(admin.ModelAdmin):
|
class SmsOptAdmin(CRMModelAdmin):
|
||||||
pass
|
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)
|
admin.site.register(CUserModel, CUserAdmin)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
||||||
from utils.base_model import BaseModel
|
from utils.base_model import BaseModel
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from utils.validations.mobile import normalize_mobile
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
@@ -8,9 +10,11 @@ class CUserManager(BaseUserManager):
|
|||||||
def create_user(self, username, password, **extra_fields):
|
def create_user(self, username, password, **extra_fields):
|
||||||
if not username:
|
if not username:
|
||||||
raise ValueError("Please Enter username")
|
raise ValueError("Please Enter username")
|
||||||
|
extra_fields['mobile'] = normalize_mobile(extra_fields.get('mobile'))
|
||||||
user = self.model(username=username, **extra_fields)
|
user = self.model(username=username, **extra_fields)
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
user.save()
|
user.full_clean()
|
||||||
|
user.save(using=self._db)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_superuser(self, username, password, **extra_fields):
|
def create_superuser(self, username, password, **extra_fields):
|
||||||
@@ -24,13 +28,14 @@ class CUserManager(BaseUserManager):
|
|||||||
class CUser(BaseModel, AbstractBaseUser, PermissionsMixin):
|
class CUser(BaseModel, AbstractBaseUser, PermissionsMixin):
|
||||||
username = models.CharField(max_length=50, null=False, blank=False, unique=True)
|
username = models.CharField(max_length=50, null=False, blank=False, unique=True)
|
||||||
mobile = models.PositiveBigIntegerField(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_active = models.BooleanField(default=False)
|
||||||
is_staff = models.BooleanField(default=False)
|
is_staff = models.BooleanField(default=False)
|
||||||
is_superuser = models.BooleanField(default=False)
|
is_superuser = models.BooleanField(default=False)
|
||||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||||
|
|
||||||
USERNAME_FIELD = 'username'
|
USERNAME_FIELD = 'username'
|
||||||
|
REQUIRED_FIELDS = ['mobile']
|
||||||
|
|
||||||
objects = CUserManager()
|
objects = CUserManager()
|
||||||
|
|
||||||
@@ -44,3 +49,9 @@ class CUser(BaseModel, AbstractBaseUser, PermissionsMixin):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.name_family} - {self.username}'
|
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": "کاربری با این شماره موبایل قبلاً ثبت شده است."})
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -26,6 +26,7 @@ class CuserRole(BaseModel):
|
|||||||
mobile = models.PositiveBigIntegerField(null=False, blank=False)
|
mobile = models.PositiveBigIntegerField(null=False, blank=False)
|
||||||
mobile_verify = models.BooleanField(default=False)
|
mobile_verify = models.BooleanField(default=False)
|
||||||
customer_panel = models.BooleanField(default=None, blank=True, null=True)
|
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)
|
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)
|
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||||
|
|||||||
+7
-1
@@ -3,15 +3,21 @@ from apps.cuser.models import CuserRole as CuserRoleModel
|
|||||||
from utils.get_cuser_role import get_cuser_role_uuid
|
from utils.get_cuser_role import get_cuser_role_uuid
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
||||||
|
|
||||||
class CUserGetInfo(views.APIView):
|
class CUserGetInfo(views.APIView):
|
||||||
|
permission_classes = (IsAuthenticated,)
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
|
||||||
cuser_role_uuid = get_cuser_role_uuid(request)
|
cuser_role_uuid = get_cuser_role_uuid(request)
|
||||||
cuser_obj = CuserRoleModel.objects.get(uuid=cuser_role_uuid)
|
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
@@ -1,5 +1,7 @@
|
|||||||
|
from utils.admin_base import CRMModelAdmin
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from apps.customer.models import (
|
from apps.customer.models import (
|
||||||
|
MobileConflict,
|
||||||
Customer as CustomerModel,
|
Customer as CustomerModel,
|
||||||
CustomerNote as CustomerNoteModel,
|
CustomerNote as CustomerNoteModel,
|
||||||
ShiveAshenaei as ShiveAshenaeiModel,
|
ShiveAshenaei as ShiveAshenaeiModel,
|
||||||
@@ -11,36 +13,65 @@ from apps.customer.models import (
|
|||||||
# Register your models here.
|
# 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)
|
@admin.register(CustomerModel)
|
||||||
class CustomerAdmin(admin.ModelAdmin):
|
class CustomerAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomerNoteModel)
|
@admin.register(CustomerNoteModel)
|
||||||
class CustomerNoteAdmin(admin.ModelAdmin):
|
class CustomerNoteAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ShiveAshenaeiModel)
|
@admin.register(ShiveAshenaeiModel)
|
||||||
class ShiveAshenaeiAdmin(admin.ModelAdmin):
|
class ShiveAshenaeiAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ResponsibleGroupModel)
|
@admin.register(ResponsibleGroupModel)
|
||||||
class ResponsibleGroupAdmin(admin.ModelAdmin):
|
class ResponsibleGroupAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomerResponsibleModel)
|
@admin.register(CustomerResponsibleModel)
|
||||||
class CustomerResponsibleAdmin(admin.ModelAdmin):
|
class CustomerResponsibleAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomerGroupTypeModel)
|
@admin.register(CustomerGroupTypeModel)
|
||||||
class CustomerGroupTypeAdmin(admin.ModelAdmin):
|
class CustomerGroupTypeAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomerGroupModel)
|
@admin.register(CustomerGroupModel)
|
||||||
class CustomerGroupAdmin(admin.ModelAdmin):
|
class CustomerGroupAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
@@ -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': 'تعارضهای شماره قدیمی'},
|
||||||
|
)]
|
||||||
@@ -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
@@ -3,9 +3,30 @@ from utils.base_model import BaseModel
|
|||||||
from apps.workspace.models import City as CityModel, Workspace as WorkspaceModel
|
from apps.workspace.models import City as CityModel, Workspace as WorkspaceModel
|
||||||
from apps.cuser.models import CuserRole as CuserRoleModel
|
from apps.cuser.models import CuserRole as CuserRoleModel
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from utils.validations.mobile import normalize_mobile
|
||||||
|
from django.utils import timezone
|
||||||
# Create your models here.
|
# 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):
|
class ShiveAshenaei(BaseModel):
|
||||||
name = models.CharField(max_length=15, null=False, blank=False)
|
name = models.CharField(max_length=15, null=False, blank=False)
|
||||||
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, 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):
|
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)
|
mobile = models.PositiveBigIntegerField(null=True, blank=True)
|
||||||
phone = models.PositiveBigIntegerField(null=True, blank=True)
|
phone = models.PositiveBigIntegerField(null=True, blank=True)
|
||||||
address = models.CharField(max_length=100, null=True, blank=True)
|
address = models.CharField(max_length=100, null=True, blank=True)
|
||||||
@@ -61,13 +82,24 @@ class Customer(BaseModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['sys_ws', 'mobile'], name='customer_workspace_mobile_unique'),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.name_family)
|
return str(self.name_family)
|
||||||
|
|
||||||
def clean(self):
|
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':
|
if self.cuser_role and not self.cuser_role.role_type.role_name_en == 'workspace_customer':
|
||||||
raise ValidationError("cuser_role type must be 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):
|
class CustomerNote(BaseModel):
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ def list_customer_v2(request):
|
|||||||
user_workspace_uuid = get_user_workspace_uuid(request=request)
|
user_workspace_uuid = get_user_workspace_uuid(request=request)
|
||||||
user_cuser_role_uuid = get_cuser_role_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,
|
customer_objs = CustomerModel.objects.filter(sys_ws__uuid=user_workspace_uuid)
|
||||||
responsible_group__customer_responsible__in=customer_responsible)
|
|
||||||
|
|
||||||
return customer_objs
|
return customer_objs
|
||||||
except Exception as exp:
|
except Exception as exp:
|
||||||
@@ -101,7 +101,6 @@ def customer_form_item(request):
|
|||||||
'shahr': shahr,
|
'shahr': shahr,
|
||||||
'responsible_groups': responsible_groups
|
'responsible_groups': responsible_groups
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+73
-47
@@ -10,7 +10,9 @@ from apps.cuser.models import CuserRole as CuserRoleModel
|
|||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
from utils.get_user_workspace import get_user_workspace, get_user_workspace_uuid
|
from utils.get_user_workspace import get_user_workspace, get_user_workspace_uuid
|
||||||
from utils.get_cuser_role import get_cuser_role
|
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
|
from django.contrib.auth import get_user_model
|
||||||
import uuid
|
import uuid
|
||||||
import random
|
import random
|
||||||
@@ -20,11 +22,20 @@ CUser = get_user_model()
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def customer_panel_access(request, mobile: int, customer_id: int, status: bool) -> CuserRoleModel:
|
def customer_panel_access(request, mobile: int, customer_id: int, status: bool) -> CuserRoleModel:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
workspace = get_user_workspace(request)
|
workspace = get_user_workspace(request)
|
||||||
customer = CustomerModel.objects.get(id=customer_id, sys_ws=workspace)
|
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:
|
try:
|
||||||
cuser_obj = CUser.objects.get(mobile=mobile)
|
cuser_obj = CUser.objects.get(mobile=mobile)
|
||||||
except CUser.DoesNotExist:
|
except CUser.DoesNotExist:
|
||||||
@@ -36,29 +47,29 @@ def customer_panel_access(request, mobile: int, customer_id: int, status: bool)
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
from apps.cuser.models import RoleType
|
||||||
cuser_role_obj = CuserRoleModel.objects.get(cuser=cuser_obj, role_type_id=3)
|
if not cuser_obj.is_active:
|
||||||
cuser_role_obj.customer_panel = status
|
raise exceptions.PermissionDenied('حساب کاربری مربوط به این شماره غیرفعال است.')
|
||||||
cuser_role_obj.save()
|
role_type = RoleType.objects.get(role_name_en='workspace_customer')
|
||||||
except CuserRoleModel.DoesNotExist:
|
cuser_role_obj, _ = CuserRoleModel.objects.get_or_create(
|
||||||
cuser_role_obj = CuserRoleModel.objects.create(
|
cuser=cuser_obj, role_type=role_type, sys_ws=workspace,
|
||||||
cuser=cuser_obj,
|
defaults={'name_family': customer.name_family, 'mobile': mobile,
|
||||||
role_type_id=3,
|
'mobile_verify': True, 'is_active': True, 'customer_panel': True},
|
||||||
name_family=customer.name_family,
|
|
||||||
mobile=mobile,
|
|
||||||
mobile_verify=True,
|
|
||||||
is_active=True,
|
|
||||||
customer_panel=status,
|
|
||||||
sys_ws=workspace
|
|
||||||
)
|
)
|
||||||
|
if CustomerModel.objects.filter(cuser_role=cuser_role_obj, sys_ws=workspace).exclude(pk=customer.pk).exists():
|
||||||
if cuser_role_obj:
|
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.cuser_role = cuser_role_obj
|
||||||
|
customer.full_clean()
|
||||||
customer.save()
|
customer.save()
|
||||||
return cuser_role_obj
|
return cuser_role_obj
|
||||||
else:
|
except exceptions.APIException:
|
||||||
raise Exception
|
raise
|
||||||
|
except DjangoValidationError as exp:
|
||||||
|
raise exceptions.ValidationError(exp.message_dict)
|
||||||
|
except IntegrityError:
|
||||||
|
raise exceptions.ValidationError({'mobile': 'این شماره موبایل قبلاً ثبت شده است.'})
|
||||||
except Exception as exp:
|
except Exception as exp:
|
||||||
message = f'Error Create or Update Customer panel accsess: {exp}'
|
message = f'Error Create or Update Customer panel accsess: {exp}'
|
||||||
raise exceptions.ValidationError({'error': message})
|
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})
|
raise exceptions.ValidationError({'error': message})
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def create_customer(name_family: str , mobile: int, phone: int, address: str, city: int, shive_ashenaei: int,
|
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:
|
responsible_group: int, cuser_role: int = None, workspace: WorkspaceModel = None) -> CustomerModel:
|
||||||
try:
|
try:
|
||||||
@@ -122,39 +134,48 @@ def create_customer(name_family: str , mobile: int, phone: int, address: str, ci
|
|||||||
customer_obj.full_clean()
|
customer_obj.full_clean()
|
||||||
customer_obj.save()
|
customer_obj.save()
|
||||||
return customer_obj
|
return customer_obj
|
||||||
|
except DjangoValidationError as exp:
|
||||||
|
raise exceptions.ValidationError(exp.message_dict)
|
||||||
|
except IntegrityError:
|
||||||
|
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
|
||||||
except Exception as exp:
|
except Exception as exp:
|
||||||
message = f'Error Creating Customer: {exp}'
|
message = f'Error Creating Customer: {exp}'
|
||||||
raise exceptions.ValidationError({'error': message})
|
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,
|
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:
|
city: int = None, shive_ashenaei: int = None, responsible_group: int = None, cuser_role: int = None, workspace_uuid: str = None) -> CustomerModel:
|
||||||
try:
|
try:
|
||||||
customer_obj = CustomerModel.objects.get(id=customer_id, sys_ws__uuid=workspace_uuid)
|
customer = CustomerModel.objects.select_for_update().get(id=customer_id, sys_ws__uuid=workspace_uuid)
|
||||||
|
set_customer_mobile(customer, mobile)
|
||||||
if name_family is not None:
|
fields = {'name_family': name_family, 'phone': phone, 'address': address, 'city': city,
|
||||||
customer_obj.name_family = name_family
|
'shive_ashenaei': shive_ashenaei, 'responsible_group': responsible_group, 'cuser_role': cuser_role}
|
||||||
if mobile is not None:
|
for field, value in fields.items():
|
||||||
customer_obj.mobile = mobile
|
if value is not None:
|
||||||
if phone is not None:
|
setattr(customer, field, value)
|
||||||
customer_obj.phone = phone
|
customer.full_clean()
|
||||||
if address is not None:
|
customer.save()
|
||||||
customer_obj.address = address
|
return customer
|
||||||
if city is not None:
|
except DjangoValidationError as error:
|
||||||
customer_obj.city = city
|
raise exceptions.ValidationError(error.message_dict)
|
||||||
if shive_ashenaei is not None:
|
except IntegrityError:
|
||||||
customer_obj.shive_ashenaei = shive_ashenaei
|
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
|
||||||
if responsible_group is not None:
|
except CustomerModel.DoesNotExist:
|
||||||
customer_obj.responsible_group = responsible_group
|
raise exceptions.NotFound('مشتری در این فضای کاری پیدا نشد.')
|
||||||
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})
|
|
||||||
|
|
||||||
|
|
||||||
def delete_customer(customer_id: int, workspace_uuid: str = None) -> CustomerModel:
|
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})
|
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,
|
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:
|
responsible_group: int, cuser_role: int = None) -> CustomerModel:
|
||||||
try:
|
try:
|
||||||
@@ -186,6 +208,10 @@ def create_customer_v2(request, name_family: str , mobile: int, phone: int, addr
|
|||||||
customer_obj.full_clean()
|
customer_obj.full_clean()
|
||||||
customer_obj.save()
|
customer_obj.save()
|
||||||
return customer_obj
|
return customer_obj
|
||||||
|
except DjangoValidationError as exp:
|
||||||
|
raise exceptions.ValidationError(exp.message_dict)
|
||||||
|
except IntegrityError:
|
||||||
|
raise exceptions.ValidationError({'mobile': 'مشتری با این شماره موبایل قبلاً ثبت شده است.'})
|
||||||
except Exception as exp:
|
except Exception as exp:
|
||||||
message = f'Error Creating Customer: {exp}'
|
message = f'Error Creating Customer: {exp}'
|
||||||
raise exceptions.ValidationError({'error': message})
|
raise exceptions.ValidationError({'error': message})
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -13,6 +13,17 @@ from apps.workspace.models import City as CityModel
|
|||||||
from utils.tools import inline_model_serializer, inline_serializer
|
from utils.tools import inline_model_serializer, inline_serializer
|
||||||
from utils.pagination import CustomPagination
|
from utils.pagination import CustomPagination
|
||||||
from django_filters import rest_framework as filters
|
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)
|
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
|
||||||
|
|
||||||
class InputSerializer(serializers.ModelSerializer):
|
class InputSerializer(serializers.ModelSerializer):
|
||||||
|
mobile = MobileField(required=False, allow_null=True)
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomerModel
|
model = CustomerModel
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
@@ -114,6 +126,7 @@ class CustomerUpdateApi(APIView):
|
|||||||
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
|
permission_classes = (IsAuthenticated, custom_permissions.WorkspaceOperatorPermission)
|
||||||
|
|
||||||
class InputSerializer(serializers.ModelSerializer):
|
class InputSerializer(serializers.ModelSerializer):
|
||||||
|
mobile = MobileField(required=False, allow_null=True)
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomerModel
|
model = CustomerModel
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
@@ -125,7 +138,7 @@ class CustomerUpdateApi(APIView):
|
|||||||
exclude = ('sys_ws', )
|
exclude = ('sys_ws', )
|
||||||
|
|
||||||
def patch(self, request, customer_id: int):
|
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)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
user_workspace_uuid = get_user_workspace_uuid(request=request)
|
user_workspace_uuid = get_user_workspace_uuid(request=request)
|
||||||
@@ -215,7 +228,7 @@ class CustomerCreateApiV2(APIView):
|
|||||||
|
|
||||||
class InputSerializer(serializers.Serializer):
|
class InputSerializer(serializers.Serializer):
|
||||||
name_family = serializers.CharField(max_length=25, required=True)
|
name_family = serializers.CharField(max_length=25, required=True)
|
||||||
mobile = serializers.IntegerField(required=True)
|
mobile = MobileField(required=True)
|
||||||
phone = serializers.IntegerField(required=True)
|
phone = serializers.IntegerField(required=True)
|
||||||
address = serializers.CharField(max_length=300, required=True)
|
address = serializers.CharField(max_length=300, required=True)
|
||||||
city = serializers.IntegerField(required=True)
|
city = serializers.IntegerField(required=True)
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ class CustomerPanelApi(APIView):
|
|||||||
|
|
||||||
cuser_role = customer_panel_access(request=request, **serializer.validated_data)
|
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from utils.admin_base import CRMModelAdmin
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from apps.customform.models import (
|
from apps.customform.models import (
|
||||||
CustomForm as CustomFormModel,
|
CustomForm as CustomFormModel,
|
||||||
@@ -7,16 +8,28 @@ from apps.customform.models import (
|
|||||||
# Register your models here.
|
# 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)
|
@admin.register(CustomFormModel)
|
||||||
class CustomFormAdmin(admin.ModelAdmin):
|
class CustomFormAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomFormDataModel)
|
@admin.register(CustomFormDataModel)
|
||||||
class CustomFormDataAdmin(admin.ModelAdmin):
|
class CustomFormDataAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomFormFieldModel)
|
@admin.register(CustomFormFieldModel)
|
||||||
class CustomFormFieldAdmin(admin.ModelAdmin):
|
class CustomFormFieldAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
+34
-12
@@ -1,17 +1,39 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from apps.jwt_auth.models import (
|
from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt
|
||||||
JwtToken as JwtTokenModel,
|
from utils.admin_base import CRMModelAdmin
|
||||||
CUserRoleLoginOpt as CUserRoleLoginOptModel,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(JwtTokenModel)
|
@admin.register(JwtToken)
|
||||||
class JwtTokenAdmin(admin.ModelAdmin):
|
class JwtTokenAdmin(CRMModelAdmin):
|
||||||
pass
|
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)
|
@admin.register(CUserRoleLoginOpt)
|
||||||
class CUserRoleLoginOptAdmin(admin.ModelAdmin):
|
class CUserRoleLoginOptAdmin(CRMModelAdmin):
|
||||||
pass
|
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
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,4 +22,4 @@ class CUserRoleLoginOpt(models.Model):
|
|||||||
cuser_role = models.ForeignKey(CuserRoleModel, on_delete=models.CASCADE)
|
cuser_role = models.ForeignKey(CuserRoleModel, on_delete=models.CASCADE)
|
||||||
cuser = models.ForeignKey(CUser, null=True, blank=True, default=None, 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)
|
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
@@ -1,90 +1,122 @@
|
|||||||
import uuid
|
import secrets
|
||||||
import random
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from apps.jwt_auth.models import CUserRoleLoginOpt
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from apps.cuser.models import CuserRole as CuserRoleModel, SmsOpt as SmsOptModel
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import exceptions
|
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()
|
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:
|
try:
|
||||||
print(username)
|
mobile = normalize_mobile(mobile)
|
||||||
user = CUser.objects.get(username=username)
|
except DjangoValidationError as error:
|
||||||
print(user)
|
raise exceptions.ValidationError(error.message_dict)
|
||||||
if user and user.check_password(password):
|
user = CUser.objects.filter(mobile=mobile).first()
|
||||||
|
if not user:
|
||||||
cuser_roles = CuserRoleModel.objects.filter(cuser=user)
|
raise exceptions.ValidationError({'mobile': 'کاربری با این شماره موبایل ثبت نشده است.'})
|
||||||
CUserRoleLoginOpt.objects.filter(cuser_role__cuser=user).delete()
|
if not user.is_active:
|
||||||
|
raise exceptions.PermissionDenied('حساب کاربری شما غیرفعال است.')
|
||||||
if cuser_roles:
|
roles = eligible_roles(user, customer_only=True)
|
||||||
for user_role_obj in cuser_roles:
|
if not roles:
|
||||||
|
raise exceptions.PermissionDenied('دسترسی شما به پنل مشتریان فعال نیست.')
|
||||||
if not (user_role_obj.is_active and user_role_obj.mobile_verify):
|
return user, roles
|
||||||
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"})
|
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def login_by_mobile(mobile: int):
|
def login_by_mobile(mobile: int):
|
||||||
try:
|
user, _ = mobile_user(mobile)
|
||||||
|
# Serialize code requests for this account, retaining the old valid code if
|
||||||
exists_cuser = CUser.objects.get(mobile=mobile)
|
# the provider refuses the new message.
|
||||||
|
CUser.objects.select_for_update().get(pk=user.pk)
|
||||||
random_number = random.randint(100000, 999999)
|
# Recheck eligibility after waiting for concurrent requests/administrative edits.
|
||||||
|
user, _ = mobile_user(user.mobile)
|
||||||
try:
|
current = SmsOpt.objects.filter(mobile=user.mobile, expire__gt=timezone.now()).order_by('-created_at').first()
|
||||||
SmsOptModel.objects.filter(mobile=mobile).delete()
|
if current:
|
||||||
except Exception as exp:
|
remaining = SMS_RESEND_INTERVAL - (timezone.now() - current.created_at).total_seconds()
|
||||||
pass
|
if remaining > 0:
|
||||||
|
from math import ceil
|
||||||
opt_code = SmsOptModel(mobile=mobile, opt=123456)
|
raise SmsRequestThrottled(ceil(remaining))
|
||||||
opt_code.save()
|
code = secrets.randbelow(900000) + 100000
|
||||||
|
delivery = DefaultSmsProvider().send_sms(str(user.mobile), f'کد ورود شما: {code}')
|
||||||
except Exception as exp:
|
if delivery is False:
|
||||||
raise exceptions.ValidationError({'error': "mobile not found"})
|
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:
|
@transaction.atomic
|
||||||
try:
|
def verify_mobile_and_get_token(mobile: int, opt: int):
|
||||||
opt_obj = SmsOptModel.objects.filter(mobile=mobile, opt=opt)[0]
|
user, roles = mobile_user(mobile)
|
||||||
|
CUser.objects.select_for_update().get(pk=user.pk)
|
||||||
if opt_obj:
|
code = SmsOpt.objects.select_for_update().filter(
|
||||||
cuser_roles = CuserRoleModel.objects.filter(cuser__mobile=mobile, is_active=True)
|
mobile=user.mobile, opt=opt, expire__gt=timezone.now(),
|
||||||
CUserRoleLoginOpt.objects.filter(cuser_role__in=cuser_roles).delete()
|
).first()
|
||||||
|
if not code:
|
||||||
if cuser_roles:
|
raise exceptions.ValidationError({'opt': 'کد تأیید اشتباه است یا اعتبار آن تمام شده است.'})
|
||||||
for user_role_obj in cuser_roles:
|
options = create_login_options(user, roles)
|
||||||
expire_datetime = timezone.datetime.now() + timezone.timedelta(minutes=5)
|
SmsOpt.objects.filter(mobile=user.mobile).delete()
|
||||||
|
return options
|
||||||
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)})
|
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
@@ -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'])
|
||||||
@@ -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)
|
||||||
@@ -4,10 +4,12 @@ from apps.jwt_auth.views import (
|
|||||||
CustomTokenObtainPairView,
|
CustomTokenObtainPairView,
|
||||||
LoginByUserView,
|
LoginByUserView,
|
||||||
LoginByMobileView,
|
LoginByMobileView,
|
||||||
VerifyMobileAndGetTokenView
|
VerifyMobileAndGetTokenView,
|
||||||
|
LogoutView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('logout/', LogoutView.as_view(), name='logout'),
|
||||||
path('login/username/', LoginByUserView.as_view(), name='login_by_username'),
|
path('login/username/', LoginByUserView.as_view(), name='login_by_username'),
|
||||||
path('login/mobile/', LoginByMobileView.as_view(), name='login_by_mobile'),
|
path('login/mobile/', LoginByMobileView.as_view(), name='login_by_mobile'),
|
||||||
path('login/mobile/verify/', VerifyMobileAndGetTokenView.as_view(), name='verify_mobile'),
|
path('login/mobile/verify/', VerifyMobileAndGetTokenView.as_view(), name='verify_mobile'),
|
||||||
|
|||||||
+98
-244
@@ -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 django.db import transaction
|
||||||
from apps.jwt_auth.models import JwtToken as JwtTokenModel, CUserRoleLoginOpt
|
from django.utils import timezone
|
||||||
from apps.cuser.models import CuserRole as CuserRoleModel, RoleType as RoleTypeModel
|
from rest_framework import serializers, status
|
||||||
from apps.customer.models import Customer as CustomerModel, CustomerResponsible as CustomerResponsibleModel
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken, AccessToken, Token
|
from rest_framework.response import Response
|
||||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
from rest_framework.views import APIView
|
||||||
from apps.workspace.models import Workspace as WorkspaceModel
|
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 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 apps.jwt_auth.session import issue_tokens, validate_session_claims
|
||||||
from rest_framework import status
|
from apps.workspace.models import Workspace
|
||||||
CUser = get_user_model()
|
|
||||||
|
|
||||||
# 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 RoleTypeSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RoleTypeModel
|
model = RoleType
|
||||||
fields = ('role_name', )
|
fields = ('role_name', 'role_name_en')
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSerializer(serializers.ModelSerializer):
|
||||||
|
class WorkspaceSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Workspace
|
||||||
|
fields = ('id', 'name')
|
||||||
|
|
||||||
role_type = RoleTypeSerializer()
|
role_type = RoleTypeSerializer()
|
||||||
|
sys_ws = WorkspaceSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CuserRoleModel
|
model = CuserRole
|
||||||
fields = ('name_family', 'title', 'role_type')
|
fields = ('name_family', 'title', 'role_type', 'sys_ws')
|
||||||
|
|
||||||
cuser_role = CUserRoleSerializer()
|
|
||||||
|
class LoginOptionSerializer(serializers.ModelSerializer):
|
||||||
|
cuser_role = RoleSerializer()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CUserRoleLoginOpt
|
model = CUserRoleLoginOpt
|
||||||
fields = ('cuser_role', 'expire', 'uuid')
|
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
|
def get_authenticate_header(self, request):
|
||||||
|
return 'Bearer realm="api"'
|
||||||
return Response(response_data)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginByMobileView(APIView):
|
class VerifyMobileAndGetTokenView(PublicAuthView):
|
||||||
|
|
||||||
class InputSerializer(serializers.Serializer):
|
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):
|
def post(self, request):
|
||||||
serializer = self.InputSerializer(data=request.data)
|
serializer = self.InputSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
options = verify_mobile_and_get_token(**serializer.validated_data)
|
||||||
login_by_mobile(**serializer.validated_data)
|
return Response(LoginOptionSerializer(options, many=True).data)
|
||||||
|
|
||||||
output = {
|
|
||||||
"result": {
|
|
||||||
"mobile": serializer.validated_data.get('mobile'),
|
|
||||||
"message": "opt code send"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response(output)
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
class InputSerializer(serializers.Serializer):
|
||||||
username = serializers.CharField(required=True)
|
username = serializers.CharField(required=True)
|
||||||
password = serializers.CharField(required=True)
|
password = serializers.CharField(required=True, trim_whitespace=False, write_only=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')
|
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = self.InputSerializer(data=request.data)
|
serializer = self.InputSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
options = login_by_username(**serializer.validated_data)
|
||||||
username = serializer.validated_data.get('username')
|
return Response(LoginOptionSerializer(options, many=True).data)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomTokenObtainPairView(APIView):
|
class CustomTokenObtainPairView(PublicAuthView):
|
||||||
|
|
||||||
class InputSerializer(serializers.Serializer):
|
class InputSerializer(serializers.Serializer):
|
||||||
uuid_opt = serializers.CharField(required=True)
|
uuid_opt = serializers.UUIDField(required=True)
|
||||||
|
|
||||||
class OutputSerializer(serializers.Serializer):
|
|
||||||
access = serializers.CharField(required=True)
|
|
||||||
refresh = serializers.CharField(required=True)
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = self.InputSerializer(data=request.data)
|
serializer = self.InputSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
uuid_opt = serializer.validated_data.get('uuid_opt')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_login_opt = CUserRoleLoginOpt.objects.get(uuid=uuid_opt)
|
user_id = CUserRoleLoginOpt.objects.values_list('cuser_id', flat=True).get(
|
||||||
user = user_login_opt.cuser
|
uuid=serializer.validated_data['uuid_opt'], expire__gt=timezone.now())
|
||||||
cuser_role = user_login_opt.cuser_role
|
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
|
class CustomTokenRefreshView(PublicAuthView):
|
||||||
|
|
||||||
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 InputSerializer(serializers.Serializer):
|
class InputSerializer(serializers.Serializer):
|
||||||
refresh = serializers.CharField(required=True)
|
refresh = serializers.CharField(required=True)
|
||||||
|
|
||||||
class OutputSerializer(serializers.Serializer):
|
@transaction.atomic
|
||||||
access = serializers.CharField(required=True)
|
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)
|
refresh = serializers.CharField(required=True)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
with transaction.atomic():
|
|
||||||
try:
|
|
||||||
|
|
||||||
serializer = self.InputSerializer(data=request.data)
|
serializer = self.InputSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
value = serializer.validated_data['refresh']
|
||||||
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))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
customerObj = CustomerModel.objects.get(cuser_role__uuid=str(cuser_role.uuid))
|
token = RefreshToken(value)
|
||||||
new_token['workspace_uuid'] = str(customerObj.sys_ws.uuid)
|
except TokenError:
|
||||||
except:
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
operator = CustomerResponsibleModel.objects.get(cuser_role__uuid=str(cuser_role.uuid))
|
# Possession of the signed refresh token permits logout even after access expires.
|
||||||
new_token['workspace_uuid'] = str(operator.sys_ws.uuid)
|
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:
|
||||||
new_access_token = new_token.access_token
|
JwtToken.objects.filter(refresh=value).update(is_blocked=True)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
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)})
|
|
||||||
+23
-6
@@ -1,36 +1,53 @@
|
|||||||
|
from utils.admin_base import CRMModelAdmin
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from apps.lead.models import *
|
from apps.lead.models import *
|
||||||
# Register your models here.
|
# 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)
|
@admin.register(Lead)
|
||||||
class LeadAdmin(admin.ModelAdmin):
|
class LeadAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LeadComment)
|
@admin.register(LeadComment)
|
||||||
class LeadCommentAdmin(admin.ModelAdmin):
|
class LeadCommentAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LeadStep)
|
@admin.register(LeadStep)
|
||||||
class LeadStepAdmin(admin.ModelAdmin):
|
class LeadStepAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LeadType)
|
@admin.register(LeadType)
|
||||||
class LeadTypeAdmin(admin.ModelAdmin):
|
class LeadTypeAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LeadSource)
|
@admin.register(LeadSource)
|
||||||
class LeadSourceAdmin(admin.ModelAdmin):
|
class LeadSourceAdmin(CRMModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LeadReason)
|
@admin.register(LeadReason)
|
||||||
class LeadReasonAdmin(admin.ModelAdmin):
|
class LeadReasonAdmin(CRMModelAdmin):
|
||||||
pass
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
+33
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
@@ -30,6 +30,14 @@ class LeadStep(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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):
|
class LeadReason(BaseModel):
|
||||||
name = models.CharField(max_length=50)
|
name = models.CharField(max_length=50)
|
||||||
@@ -48,9 +56,13 @@ class Lead(BaseModel):
|
|||||||
("UN", "unknown"),
|
("UN", "unknown"),
|
||||||
}
|
}
|
||||||
|
|
||||||
title = models.CharField(max_length=100)
|
title = models.CharField(max_length=300)
|
||||||
desc = 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)
|
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)
|
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)
|
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)
|
source = models.ForeignKey(LeadSource, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
@@ -68,7 +80,7 @@ class Lead(BaseModel):
|
|||||||
class LeadComment(BaseModel):
|
class LeadComment(BaseModel):
|
||||||
|
|
||||||
lead = models.ForeignKey(Lead, on_delete=models.CASCADE, null=False, blank=False, related_name='lead_comment')
|
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)
|
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)
|
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False)
|
||||||
|
|
||||||
|
|||||||
+24
-4
@@ -1,9 +1,9 @@
|
|||||||
from apps.lead.models import Lead as LeadModel, LeadType as LeadTypeModel,\
|
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.shop.models import ProductType as ProductTypeModel
|
||||||
from apps.workspace.models import City as CityModel
|
from apps.workspace.models import City as CityModel
|
||||||
from utils.get_user_workspace import get_user_workspace_uuid
|
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
|
from rest_framework import exceptions
|
||||||
|
|
||||||
|
|
||||||
@@ -12,7 +12,16 @@ def list_leads(request) -> LeadModel:
|
|||||||
try:
|
try:
|
||||||
workspace_uuid = get_user_workspace_uuid(request)
|
workspace_uuid = get_user_workspace_uuid(request)
|
||||||
cuser_role_uuid = get_cuser_role_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
|
return leads
|
||||||
except Exception as exp:
|
except Exception as exp:
|
||||||
@@ -25,7 +34,16 @@ def retrieve_lead(request, id: int) -> LeadModel:
|
|||||||
try:
|
try:
|
||||||
workspace_uuid = get_user_workspace_uuid(request)
|
workspace_uuid = get_user_workspace_uuid(request)
|
||||||
cuser_role_uuid = get_cuser_role_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
|
return lead
|
||||||
except Exception as exp:
|
except Exception as exp:
|
||||||
message = f'Error retrieve Leads: {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)
|
product_types = ProductTypeModel.objects.filter(sys_ws__uuid=workspace_uuid)
|
||||||
lead_source = LeadSourceModel.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_step = LeadStepModel.objects.filter(sys_ws__uuid=workspace_uuid)
|
||||||
|
lead_event = LeadEventModel.objects.filter(sys_ws__uuid=workspace_uuid)
|
||||||
shahr = CityModel.objects.filter()
|
shahr = CityModel.objects.filter()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -73,6 +92,7 @@ def fields_new_lead_form(request):
|
|||||||
'lead_types': lead_type,
|
'lead_types': lead_type,
|
||||||
'lead_source': lead_source,
|
'lead_source': lead_source,
|
||||||
'lead_steps': lead_step,
|
'lead_steps': lead_step,
|
||||||
|
'lead_event': lead_event,
|
||||||
'shahr': shahr,
|
'shahr': shahr,
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from apps.lead.models import (
|
from apps.lead.models import (
|
||||||
LeadType as LeadTypeModel, LeadSource as LeadSourceModel,
|
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.shop.models import ProductType as ProductTypeModel
|
||||||
from apps.workspace.models import City as CityModel
|
from apps.workspace.models import City as CityModel
|
||||||
@@ -39,6 +40,12 @@ class LeadReasonSerializers(serializers.ModelSerializer):
|
|||||||
exclude = ['sys_ws', 'created_at', 'updated_at']
|
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 ShahrSerializers(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CityModel
|
model = CityModel
|
||||||
|
|||||||
+41
-11
@@ -1,16 +1,17 @@
|
|||||||
from apps.lead.models import Lead as LeadModel,\
|
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 apps.shop.models import ProductType as ProductTypeModel
|
||||||
from utils.get_user_workspace import get_user_workspace
|
from utils.get_user_workspace import get_user_workspace
|
||||||
from utils.get_cuser_role import get_cuser_role
|
from utils.get_cuser_role import get_cuser_role
|
||||||
from utils.validations import ObjectValidationById
|
from utils.validations import ObjectValidationById
|
||||||
from apps.customer.models import Customer as CustomerModel
|
from apps.customer.models import Customer as CustomerModel
|
||||||
from apps.workspace.models import City as CityModel
|
from apps.workspace.models import City as CityModel
|
||||||
|
from utils.services.sms import DefaultSmsProvider
|
||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
|
|
||||||
|
|
||||||
def create_new_lead(request, title: str, customer_id: str, desc: str, product_type_id: int,
|
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) -> LeadModel:
|
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:
|
try:
|
||||||
sys_ws = get_user_workspace(request=request)
|
sys_ws = get_user_workspace(request=request)
|
||||||
cuser_role = get_cuser_role(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)
|
product_type = ObjectValidationById(object_model=ProductTypeModel, request=request).is_valid(product_type_id)
|
||||||
city = ObjectValidationById(object_model=CityModel, request=request).is_valid(city_id)
|
city = ObjectValidationById(object_model=CityModel, request=request).is_valid(city_id)
|
||||||
|
|
||||||
|
lead_event_obj = None
|
||||||
|
|
||||||
new_lead = LeadModel()
|
new_lead = LeadModel()
|
||||||
new_lead.title = title
|
new_lead.title = title
|
||||||
new_lead.customer = customer
|
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.desc = desc
|
||||||
new_lead.type = lead_type
|
new_lead.type = lead_type
|
||||||
new_lead.step = lead_step
|
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()
|
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
|
return new_lead
|
||||||
|
|
||||||
except Exception as exp:
|
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,
|
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:
|
try:
|
||||||
sys_ws = get_user_workspace(request=request)
|
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))
|
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)
|
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)
|
product_type = ObjectValidationById(object_model=ProductTypeModel, request=request).is_valid(product_type_id)
|
||||||
city = ObjectValidationById(object_model=CityModel, request=request).is_valid(city_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.title = title
|
||||||
lead.customer = customer
|
lead.customer = customer
|
||||||
|
if mobile1:
|
||||||
|
lead.mobile1 = mobile1
|
||||||
|
if mobile2:
|
||||||
|
lead.mobile2 = mobile2
|
||||||
|
if phone:
|
||||||
|
lead.phone = phone
|
||||||
lead.desc = desc
|
lead.desc = desc
|
||||||
lead.type = lead_type
|
lead.type = lead_type
|
||||||
lead.step = lead_step
|
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:
|
def update_lead_comment(request, lead_comment_id: int, desc: str) -> LeadModel:
|
||||||
try:
|
try:
|
||||||
sys_ws = get_user_workspace(request=request)
|
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.desc = desc
|
||||||
|
|
||||||
lead_comment.save()
|
lead_comment.save()
|
||||||
@@ -116,7 +144,8 @@ def create_lead_comment(request, lead_id, desc: str) -> LeadCommentModel:
|
|||||||
cuser_role = get_cuser_role(request=request)
|
cuser_role = get_cuser_role(request=request)
|
||||||
|
|
||||||
lead_comment = LeadCommentModel()
|
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.sys_ws = sys_ws
|
||||||
lead_comment.cuser_role = cuser_role
|
lead_comment.cuser_role = cuser_role
|
||||||
lead_comment.desc = desc
|
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:
|
def delete_lead_comment(request, lead_comment_id: int) -> LeadModel:
|
||||||
try:
|
try:
|
||||||
sys_ws = get_user_workspace(request=request)
|
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()
|
lead_comment.delete()
|
||||||
|
|
||||||
return lead_comment
|
return lead_comment
|
||||||
|
|||||||
+14
-4
@@ -4,7 +4,7 @@ from rest_framework.generics import ListAPIView
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from apps.lead.serializers import LeadTypeSerializers, ProductTypeSerializers,\
|
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.selectors import fields_new_lead_form
|
||||||
from apps.lead.services import create_new_lead, update_lead, update_lead_status,\
|
from apps.lead.services import create_new_lead, update_lead, update_lead_status,\
|
||||||
update_lead_comment, delete_lead_comment, create_lead_comment
|
update_lead_comment, delete_lead_comment, create_lead_comment
|
||||||
@@ -25,6 +25,7 @@ class LeadFormItemsApi(APIView):
|
|||||||
lead_types = LeadTypeSerializers(many=True)
|
lead_types = LeadTypeSerializers(many=True)
|
||||||
product_types = ProductTypeSerializers(many=True)
|
product_types = ProductTypeSerializers(many=True)
|
||||||
lead_source = LeadSourceSerializers(many=True)
|
lead_source = LeadSourceSerializers(many=True)
|
||||||
|
lead_event = LeadEventSerializers(many=True)
|
||||||
lead_steps = LeadStepSerializers(many=True)
|
lead_steps = LeadStepSerializers(many=True)
|
||||||
shahr = ShahrSerializers(many=True)
|
shahr = ShahrSerializers(many=True)
|
||||||
|
|
||||||
@@ -41,7 +42,11 @@ class LeadCreateApi(APIView):
|
|||||||
class InputSerializer(serializers.Serializer):
|
class InputSerializer(serializers.Serializer):
|
||||||
title = serializers.CharField(max_length=100, required=True)
|
title = serializers.CharField(max_length=100, required=True)
|
||||||
customer_id = serializers.IntegerField(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)
|
lead_type_id = serializers.IntegerField(required=True)
|
||||||
product_type_id = serializers.IntegerField(required=True)
|
product_type_id = serializers.IntegerField(required=True)
|
||||||
lead_source_id = serializers.IntegerField(required=True)
|
lead_source_id = serializers.IntegerField(required=True)
|
||||||
@@ -68,7 +73,7 @@ class LeadUpdateApi(APIView):
|
|||||||
class InputSerializer(serializers.Serializer):
|
class InputSerializer(serializers.Serializer):
|
||||||
title = serializers.CharField(max_length=100, required=True)
|
title = serializers.CharField(max_length=100, required=True)
|
||||||
customer_id = serializers.IntegerField(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)
|
lead_type_id = serializers.IntegerField(required=True)
|
||||||
product_type_id = serializers.IntegerField(required=True)
|
product_type_id = serializers.IntegerField(required=True)
|
||||||
lead_source_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() })
|
'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:
|
class Meta:
|
||||||
model = LeadModel
|
model = LeadModel
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
@@ -223,7 +233,7 @@ class LeadRetrieveApi(APIView):
|
|||||||
class LeadCommentCreateApi(APIView):
|
class LeadCommentCreateApi(APIView):
|
||||||
|
|
||||||
class InputSerializer(serializers.Serializer):
|
class InputSerializer(serializers.Serializer):
|
||||||
desc = serializers.CharField(max_length=200, required=True)
|
desc = serializers.CharField(required=True)
|
||||||
|
|
||||||
class OutputSerializer(serializers.Serializer):
|
class OutputSerializer(serializers.Serializer):
|
||||||
id = serializers.IntegerField(required=True)
|
id = serializers.IntegerField(required=True)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from utils.admin_base import CRMModelAdmin
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from apps.reminder.models import *
|
from apps.reminder.models import *
|
||||||
|
|
||||||
@@ -7,5 +8,5 @@ from apps.reminder.models import *
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Reminder)
|
@admin.register(Reminder)
|
||||||
class ReminderAdmin(admin.ModelAdmin):
|
class ReminderAdmin(CRMModelAdmin):
|
||||||
pass
|
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
Reference in new issue
Block a user