Browse Source
feat: initialize Django backend project structure with core apps, API views, and configurations
main
feat: initialize Django backend project structure with core apps, API views, and configurations
main
commit
3e9f6f994c
226 changed files with 103855 additions and 0 deletions
-
66.dockerignore
-
24.env.dev
-
35.env.example
-
23.env.prod
-
419.gitignore
-
32Dockerfile
-
46Dockerfile.prod
-
195README.md
-
100apps/account/API_ERRORS.md
-
0apps/account/__init__.py
-
32apps/account/admin/__init__.py
-
59apps/account/admin/location.py
-
14apps/account/admin/notification.py
-
158apps/account/admin/user.py
-
7apps/account/apps.py
-
28apps/account/custom_user_login.py
-
835apps/account/doc.py
-
0apps/account/management/__init__.py
-
0apps/account/management/commands/__init__,py
-
0apps/account/management/commands/__init__.py
-
28apps/account/management/commands/assign_professor_slugs.py
-
187apps/account/management/commands/audit_admin_panel_access.py
-
175apps/account/management/commands/create_geonames_table.py
-
52apps/account/management/commands/create_groups.py
-
208apps/account/management/commands/seed_notification_templates.py
-
115apps/account/management/commands/send_test_notifications.py
-
55apps/account/management/commands/set_default_passwords.py
-
37apps/account/management/commands/test_guest_token.py
-
41apps/account/manager.py
-
176apps/account/migrations/0001_initial.py
-
0apps/account/migrations/__init__.py
-
3apps/account/models/__init__.py
-
51apps/account/models/groups.py
-
53apps/account/models/notification.py
-
238apps/account/models/user.py
-
119apps/account/notification_service.py
-
56apps/account/permissions.py
-
4apps/account/serializers/__init__.py
-
11apps/account/serializers/auth.py
-
37apps/account/serializers/location_history.py
-
36apps/account/serializers/notification.py
-
377apps/account/serializers/user.py
-
29apps/account/serializers/user_web.py
-
39apps/account/tasks.py
-
40apps/account/templates/account/group_help_text.html
-
839apps/account/templates/account/json_editor_field.html
-
33apps/account/templates/account/user_list_section.html
-
47apps/account/tests/test_admin_panel_access.py
-
47apps/account/urls.py
-
4apps/account/views/__init__.py
-
163apps/account/views/auth.py
-
69apps/account/views/location_history.py
-
103apps/account/views/notification.py
-
942apps/account/views/user.py
-
0apps/api/__init__.py
-
28apps/api/admin.py
-
6apps/api/apps.py
-
42apps/api/decorators.py
-
52apps/api/migrations/0001_initial.py
-
0apps/api/migrations/__init__.py
-
125apps/api/models.py
-
71apps/api/permissions.py
-
36apps/api/serializers.py
-
3apps/api/tests.py
-
8apps/api/urls.py
-
2apps/api/views.py
-
14apps/api/views/__init__.py
-
61apps/api/views/admin_dashboard.py
-
74apps/api/views/api_views.py
-
83apps/api/views/swagger_views.py
-
29centrifugo/config.json
-
8config/__init__.py
-
16config/asgi.py
-
22config/celery.py
-
66config/enhanced_auth_middleware.py
-
14config/language_code_middleware.py
-
22config/middleware/__init__.py
-
15config/redis_config.py
-
0config/settings/__init__.py
-
379config/settings/base.py
-
24config/settings/develop.py
-
50config/settings/production.py
-
12config/settings/test.py
-
118config/urls.py
-
16config/wsgi.py
-
91docker-compose.prod.yml
-
76docker-compose.yml
-
2dynamic_preferences/__init__.py
-
139dynamic_preferences/admin.py
-
0dynamic_preferences/api/__init__.py
-
71dynamic_preferences/api/serializers.py
-
179dynamic_preferences/api/viewsets.py
-
25dynamic_preferences/apps.py
-
108dynamic_preferences/dynamic_preferences_registry.py
-
32dynamic_preferences/exceptions.py
-
152dynamic_preferences/forms.py
-
95dynamic_preferences/locale/ar/LC_MESSAGES/django.po
-
82dynamic_preferences/locale/az/LC_MESSAGES/django.po
-
82dynamic_preferences/locale/bn/LC_MESSAGES/django.po
-
90dynamic_preferences/locale/de/LC_MESSAGES/django.po
@ -0,0 +1,66 @@ |
|||||
|
.git |
||||
|
.gitignore |
||||
|
.gitattributes |
||||
|
__pycache__ |
||||
|
*.py[cod] |
||||
|
*$py.class |
||||
|
*.so |
||||
|
.Python |
||||
|
*.egg-info |
||||
|
dist |
||||
|
build |
||||
|
*.egg |
||||
|
venv |
||||
|
env |
||||
|
ENV |
||||
|
env.bak |
||||
|
venv.bak |
||||
|
.venv |
||||
|
node_modules |
||||
|
.vscode |
||||
|
.idea |
||||
|
*.swp |
||||
|
*.swo |
||||
|
*~ |
||||
|
.DS_Store |
||||
|
Thumbs.db |
||||
|
docker-compose*.yml |
||||
|
Dockerfile* |
||||
|
.dockerignore |
||||
|
.env |
||||
|
.env.dev |
||||
|
.env.development |
||||
|
.env.local |
||||
|
.env.test |
||||
|
*.log |
||||
|
logs |
||||
|
*.sqlite3 |
||||
|
*.sql |
||||
|
*.tar.gz |
||||
|
*.zip |
||||
|
*.bak |
||||
|
.pytest_cache |
||||
|
.coverage |
||||
|
htmlcov |
||||
|
.tox |
||||
|
.nox |
||||
|
.mypy_cache |
||||
|
.dmypy.json |
||||
|
dmypy.json |
||||
|
test_*.py |
||||
|
tests |
||||
|
*.md |
||||
|
docs |
||||
|
Jenkinsfile |
||||
|
.jenkins |
||||
|
.letta |
||||
|
.claude |
||||
|
.qodo |
||||
|
.zencoder |
||||
|
volumes |
||||
|
staticfiles |
||||
|
media |
||||
|
media_volume |
||||
|
recordings |
||||
|
data/*.xlsx |
||||
|
data/*.csv |
||||
@ -0,0 +1,24 @@ |
|||||
|
# Local Development Environment |
||||
|
DJANGO_SECRET_KEY=dev-insecure-secret-key-for-local-development-only |
||||
|
DJANGO_DEBUG=True |
||||
|
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,* |
||||
|
DJANGO_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 |
||||
|
DJANGO_SETTINGS_MODULE=config.settings.base |
||||
|
|
||||
|
# Database (PostgreSQL) |
||||
|
POSTGRES_DB=app_db |
||||
|
POSTGRES_USER=postgres |
||||
|
POSTGRES_PASSWORD=postgres |
||||
|
POSTGRES_HOST=postgres |
||||
|
POSTGRES_PORT=5432 |
||||
|
|
||||
|
# Redis & Celery |
||||
|
REDIS_URL=redis://redis:6379/0 |
||||
|
|
||||
|
# Default Region for phone validation |
||||
|
PHONENUMBER_DEFAULT_REGION=US |
||||
|
|
||||
|
# Centrifugo Real-Time Messaging Engine |
||||
|
CENTRIFUGO_API_URL=http://centrifugo:8000/api |
||||
|
CENTRIFUGO_SECRET=super_secret_centrifugo_token_key_hmac_256 |
||||
|
CENTRIFUGO_API_KEY=centrifugo_internal_api_access_key |
||||
@ -0,0 +1,35 @@ |
|||||
|
# ----------------------------------------------------------------------------- |
||||
|
# Django Environment Configuration Template |
||||
|
# Copy this file to .env or .env.dev and fill in your values. |
||||
|
# ----------------------------------------------------------------------------- |
||||
|
|
||||
|
# Django Core |
||||
|
DJANGO_SECRET_KEY=change-this-secret-key-in-production |
||||
|
DJANGO_DEBUG=True |
||||
|
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost |
||||
|
DJANGO_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 |
||||
|
DJANGO_SETTINGS_MODULE=config.settings.base |
||||
|
|
||||
|
# Database (PostgreSQL) |
||||
|
POSTGRES_DB=app_db |
||||
|
POSTGRES_USER=postgres |
||||
|
POSTGRES_PASSWORD=postgres |
||||
|
POSTGRES_HOST=postgres |
||||
|
POSTGRES_PORT=5432 |
||||
|
|
||||
|
# Redis & Cache & Celery Broker |
||||
|
REDIS_URL=redis://redis:6379/0 |
||||
|
|
||||
|
# Phone Numbers & Internationalization |
||||
|
PHONENUMBER_DEFAULT_REGION=US |
||||
|
|
||||
|
# Monitoring & Error Tracking (Optional) |
||||
|
SENTRY_DSN= |
||||
|
|
||||
|
# CORS Configuration (Production) |
||||
|
CORS_ALLOWED_ORIGINS= |
||||
|
|
||||
|
# Centrifugo Real-Time Messaging Engine |
||||
|
CENTRIFUGO_API_URL=http://centrifugo:8000/api |
||||
|
CENTRIFUGO_SECRET=super_secret_centrifugo_token_key_hmac_256 |
||||
|
CENTRIFUGO_API_KEY=centrifugo_internal_api_access_key |
||||
@ -0,0 +1,23 @@ |
|||||
|
# Production Environment Template |
||||
|
DJANGO_SECRET_KEY=generate-a-strong-random-secret-key-here |
||||
|
DJANGO_DEBUG=False |
||||
|
DJANGO_ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com |
||||
|
DJANGO_TRUSTED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com |
||||
|
DJANGO_SETTINGS_MODULE=config.settings.production |
||||
|
|
||||
|
# Database (PostgreSQL) |
||||
|
POSTGRES_USER=app_user |
||||
|
POSTGRES_DB=app_db |
||||
|
POSTGRES_PASSWORD=your-secure-postgres-password |
||||
|
POSTGRES_PORT=5432 |
||||
|
POSTGRES_HOST=postgres |
||||
|
|
||||
|
# Redis & Celery |
||||
|
REDIS_URL=redis://redis:6379/0 |
||||
|
|
||||
|
# Timezone |
||||
|
TIMEZONE=UTC |
||||
|
CELERY_TIMEZONE=UTC |
||||
|
|
||||
|
# Monitoring (Optional) |
||||
|
SENTRY_DSN= |
||||
@ -0,0 +1,419 @@ |
|||||
|
settings.json |
||||
|
# migrations/ |
||||
|
.DS_Store |
||||
|
local-cdn/ |
||||
|
# .env-dev |
||||
|
# .env-prod |
||||
|
# Byte-compiled / optimized / DLL files |
||||
|
__pycache__/ |
||||
|
*.py[cod] |
||||
|
*$py.class |
||||
|
|
||||
|
static/ |
||||
|
# C extensions |
||||
|
*.so |
||||
|
|
||||
|
# Distribution / packaging |
||||
|
.Python |
||||
|
build/ |
||||
|
develop-eggs/ |
||||
|
dist/ |
||||
|
downloads/ |
||||
|
eggs/ |
||||
|
.eggs/ |
||||
|
lib/ |
||||
|
lib64/ |
||||
|
parts/ |
||||
|
sdist/ |
||||
|
var/ |
||||
|
wheels/ |
||||
|
pip-wheel-metadata/ |
||||
|
share/python-wheels/ |
||||
|
*.egg-info/ |
||||
|
.installed.cfg |
||||
|
*.egg |
||||
|
MANIFEST |
||||
|
# In the name of Allah |
||||
|
# Byte-compiled / optimized / DLL files |
||||
|
__pycache__/ |
||||
|
*.py[cod] |
||||
|
*$py.class |
||||
|
|
||||
|
# C extensions |
||||
|
*.so |
||||
|
|
||||
|
# Distribution / packaging |
||||
|
.Python |
||||
|
env/ |
||||
|
build/ |
||||
|
develop-eggs/ |
||||
|
dist/ |
||||
|
downloads/ |
||||
|
eggs/ |
||||
|
.eggs/ |
||||
|
lib/ |
||||
|
lib64/ |
||||
|
sdist/ |
||||
|
var/ |
||||
|
wheels/ |
||||
|
*.egg-info/ |
||||
|
.installed.cfg |
||||
|
*.egg |
||||
|
|
||||
|
# PyInstaller |
||||
|
# Usually these files are written by a python script from a template |
||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it. |
||||
|
*.manifest |
||||
|
*.spec |
||||
|
|
||||
|
# Installer logs |
||||
|
pip-log.txt |
||||
|
pip-delete-this-directory.txt |
||||
|
|
||||
|
# Unit test / coverage reports |
||||
|
htmlcov/ |
||||
|
.tox/ |
||||
|
.coverage |
||||
|
.coverage.* |
||||
|
.cache |
||||
|
nosetests.xml |
||||
|
coverage.xml |
||||
|
*.cover |
||||
|
.hypothesis/ |
||||
|
|
||||
|
# Translations |
||||
|
*.mo |
||||
|
*.pot |
||||
|
|
||||
|
# Django stuff: |
||||
|
*.log |
||||
|
local_settings.py |
||||
|
|
||||
|
# Flask stuff: |
||||
|
instance/ |
||||
|
.webassets-cache |
||||
|
|
||||
|
# Scrapy stuff: |
||||
|
.scrapy |
||||
|
|
||||
|
# Sphinx documentation |
||||
|
docs/_build/ |
||||
|
|
||||
|
# PyBuilder |
||||
|
target/ |
||||
|
|
||||
|
# Jupyter Notebook |
||||
|
.ipynb_checkpoints |
||||
|
|
||||
|
# pyenv |
||||
|
.python-version |
||||
|
|
||||
|
# celery beat schedule file |
||||
|
celerybeat-schedule |
||||
|
|
||||
|
# SageMath parsed files |
||||
|
*.sage.py |
||||
|
|
||||
|
# dotenv |
||||
|
.env |
||||
|
|
||||
|
# virtualenv |
||||
|
.venv |
||||
|
venv/ |
||||
|
ENV/ |
||||
|
.vscode |
||||
|
.idea |
||||
|
|
||||
|
*.mp4 |
||||
|
# Spyder project settings |
||||
|
.spyderproject |
||||
|
.spyproject |
||||
|
|
||||
|
# Rope project settings |
||||
|
.ropeproject |
||||
|
|
||||
|
# mkdocs documentation |
||||
|
/site |
||||
|
|
||||
|
# mypy |
||||
|
.mypy_cache/ |
||||
|
|
||||
|
.DS_Store |
||||
|
*.sqlite3 |
||||
|
media/ |
||||
|
*.pyc |
||||
|
*.db |
||||
|
*.pid |
||||
|
|
||||
|
# Ignore Django Migrations in Development if you are working on team |
||||
|
|
||||
|
#Only for Development only |
||||
|
#**/migrations/** |
||||
|
#!**/migrations/__init__.py |
||||
|
|
||||
|
#comment migrations ignorance bcz we need it to be exist |
||||
|
|
||||
|
|
||||
|
#server gitignore |
||||
|
passenger_wsgi.py |
||||
|
.htaccess |
||||
|
static/uploads/ |
||||
|
static/quran_audios |
||||
|
tmp/ |
||||
|
Pipfile.lock |
||||
|
quran-pages-audios/*.zip |
||||
|
quran.sql |
||||
|
tafsir.sql |
||||
|
output_file.sql |
||||
|
|
||||
|
src |
||||
|
calendar.json |
||||
|
apps/mafatih/data/mafatih_indonesia/*.json |
||||
|
apps/mafatih/data/mafatih_indonesia/1 |
||||
|
apps/mafatih/data/Germany Duas/*.xlsx |
||||
|
!apps/mafatih/data/mafatih_indonesia/final_jun_11.json |
||||
|
volumes/ |
||||
|
|
||||
|
apps/mafatih/data/*.json |
||||
|
apps/ahkam/data/*.json |
||||
|
!apps/ahkam/data/makarem_fa_data.json |
||||
|
|
||||
|
mediafiles/* |
||||
|
wabot/ |
||||
|
Sabeel Media Content/ |
||||
|
|
||||
|
|
||||
|
*.lock |
||||
|
*.toml |
||||
|
# PyInstaller |
||||
|
# Usually these files are written by a python script from a template |
||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it. |
||||
|
*.manifest |
||||
|
*.spec |
||||
|
|
||||
|
# Installer logs |
||||
|
pip-log.txt |
||||
|
pip-delete-this-directory.txt |
||||
|
|
||||
|
# Unit test / coverage reports |
||||
|
htmlcov/ |
||||
|
.tox/ |
||||
|
.nox/ |
||||
|
.coverage |
||||
|
.coverage.* |
||||
|
.cache |
||||
|
nosetests.xml |
||||
|
coverage.xml |
||||
|
*.cover |
||||
|
*.py,cover |
||||
|
.hypothesis/ |
||||
|
.pytest_cache/ |
||||
|
|
||||
|
# Translations |
||||
|
*.mo |
||||
|
*.pot |
||||
|
|
||||
|
# Django stuff: |
||||
|
*.log |
||||
|
local_settings.py |
||||
|
db.sqlite3 |
||||
|
db.sqlite3-journal |
||||
|
|
||||
|
# Flask stuff: |
||||
|
instance/ |
||||
|
.webassets-cache |
||||
|
|
||||
|
# Scrapy stuff: |
||||
|
.scrapy |
||||
|
|
||||
|
# Sphinx documentation |
||||
|
docs/_build/ |
||||
|
|
||||
|
# PyBuilder |
||||
|
target/ |
||||
|
|
||||
|
# Jupyter Notebook |
||||
|
.ipynb_checkpoints |
||||
|
|
||||
|
# IPython |
||||
|
profile_default/ |
||||
|
ipython_config.py |
||||
|
|
||||
|
# pyenv |
||||
|
.python-version |
||||
|
|
||||
|
# pipenv |
||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. |
||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies |
||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not |
||||
|
# install all needed dependencies. |
||||
|
#Pipfile.lock |
||||
|
|
||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow |
||||
|
__pypackages__/ |
||||
|
|
||||
|
# Celery stuff |
||||
|
celerybeat-schedule |
||||
|
celerybeat.pid |
||||
|
|
||||
|
# SageMath parsed files |
||||
|
*.sage.py |
||||
|
|
||||
|
# Environments |
||||
|
# # .env |
||||
|
# .venv |
||||
|
# # env/ |
||||
|
# venv/ |
||||
|
# ENV/ |
||||
|
# env.bak/ |
||||
|
# venv.bak/ |
||||
|
|
||||
|
# Spyder project settings |
||||
|
.spyderproject |
||||
|
.spyproject |
||||
|
|
||||
|
# Rope project settings |
||||
|
.ropeproject |
||||
|
|
||||
|
# mkdocs documentation |
||||
|
/site |
||||
|
|
||||
|
# mypy |
||||
|
.mypy_cache/ |
||||
|
.dmypy.json |
||||
|
dmypy.json |
||||
|
|
||||
|
# Pyre type checker |
||||
|
.pyre/ |
||||
|
|
||||
|
|
||||
|
# Logs |
||||
|
logs |
||||
|
*.log |
||||
|
npm-debug.log* |
||||
|
yarn-debug.log* |
||||
|
yarn-error.log* |
||||
|
lerna-debug.log* |
||||
|
.pnpm-debug.log* |
||||
|
|
||||
|
# Diagnostic reports (https://nodejs.org/api/report.html) |
||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json |
||||
|
|
||||
|
# Runtime data |
||||
|
pids |
||||
|
*.pid |
||||
|
*.seed |
||||
|
*.pid.lock |
||||
|
|
||||
|
# Directory for instrumented libs generated by jscoverage/JSCover |
||||
|
lib-cov |
||||
|
|
||||
|
# Coverage directory used by tools like istanbul |
||||
|
coverage |
||||
|
*.lcov |
||||
|
|
||||
|
# nyc test coverage |
||||
|
.nyc_output |
||||
|
|
||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) |
||||
|
.grunt |
||||
|
|
||||
|
# Bower dependency directory (https://bower.io/) |
||||
|
bower_components |
||||
|
|
||||
|
# node-waf configuration |
||||
|
.lock-wscript |
||||
|
|
||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html) |
||||
|
build/Release |
||||
|
|
||||
|
# Dependency directories |
||||
|
node_modules/ |
||||
|
jspm_packages/ |
||||
|
|
||||
|
# Snowpack dependency directory (https://snowpack.dev/) |
||||
|
web_modules/ |
||||
|
|
||||
|
# TypeScript cache |
||||
|
*.tsbuildinfo |
||||
|
|
||||
|
# Optional npm cache directory |
||||
|
.npm |
||||
|
|
||||
|
# Optional eslint cache |
||||
|
.eslintcache |
||||
|
|
||||
|
# Optional stylelint cache |
||||
|
.stylelintcache |
||||
|
|
||||
|
# Microbundle cache |
||||
|
.rpt2_cache/ |
||||
|
.rts2_cache_cjs/ |
||||
|
.rts2_cache_es/ |
||||
|
.rts2_cache_umd/ |
||||
|
|
||||
|
# Optional REPL history |
||||
|
.node_repl_history |
||||
|
|
||||
|
# Output of 'npm pack' |
||||
|
*.tgz |
||||
|
|
||||
|
# Yarn Integrity file |
||||
|
.yarn-integrity |
||||
|
|
||||
|
# dotenv environment variable files |
||||
|
# .env |
||||
|
# .env.development.local |
||||
|
# .env.test.local |
||||
|
# .env.production.local |
||||
|
# .env.local |
||||
|
|
||||
|
# parcel-bundler cache (https://parceljs.org/) |
||||
|
.cache |
||||
|
.parcel-cache |
||||
|
|
||||
|
# Next.js build output |
||||
|
.next |
||||
|
out |
||||
|
|
||||
|
# Nuxt.js build / generate output |
||||
|
.nuxt |
||||
|
dist |
||||
|
|
||||
|
# Gatsby files |
||||
|
.cache/ |
||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js |
||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support |
||||
|
# public |
||||
|
|
||||
|
# vuepress build output |
||||
|
.vuepress/dist |
||||
|
|
||||
|
# vuepress v2.x temp and cache directory |
||||
|
.temp |
||||
|
.cache |
||||
|
|
||||
|
# Docusaurus cache and generated files |
||||
|
.docusaurus |
||||
|
|
||||
|
# Serverless directories |
||||
|
.serverless/ |
||||
|
|
||||
|
# FuseBox cache |
||||
|
.fusebox/ |
||||
|
|
||||
|
# DynamoDB Local files |
||||
|
.dynamodb/ |
||||
|
|
||||
|
# TernJS port file |
||||
|
.tern-port |
||||
|
|
||||
|
# Stores VSCode versions used for testing VSCode extensions |
||||
|
.vscode-test |
||||
|
|
||||
|
# yarn v2 |
||||
|
.yarn/cache |
||||
|
.yarn/unplugged |
||||
|
.yarn/build-state.yml |
||||
|
.yarn/install-state.gz |
||||
|
.pnp.* |
||||
@ -0,0 +1,32 @@ |
|||||
|
# pull official base image |
||||
|
FROM python:3.10 |
||||
|
|
||||
|
# set work directory |
||||
|
WORKDIR /usr/src/app |
||||
|
|
||||
|
# set environment variables |
||||
|
ENV PYTHONDONTWRITEBYTECODE 1 |
||||
|
ENV PYTHONUNBUFFERED 1 |
||||
|
|
||||
|
RUN apt-get update |
||||
|
# RUN apt-get install -y vim |
||||
|
# RUN apt-get install -y ffmpeg |
||||
|
# RUN apt-get install -y cron |
||||
|
# install dependencies |
||||
|
RUN pip install --upgrade pip |
||||
|
|
||||
|
COPY ./requirements.txt . |
||||
|
COPY .env.dev .env |
||||
|
|
||||
|
RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt |
||||
|
|
||||
|
# copy entrypoint.sh |
||||
|
COPY ./entrypoint.sh . |
||||
|
RUN sed -i 's/\r$//g' /usr/src/app/entrypoint.sh |
||||
|
RUN chmod +x /usr/src/app/entrypoint.sh |
||||
|
|
||||
|
# copy project |
||||
|
COPY . . |
||||
|
|
||||
|
# run entrypoint.sh |
||||
|
# ENTRYPOINT ["/usr/src/app/entrypoint.sh"] |
||||
@ -0,0 +1,46 @@ |
|||||
|
# ======================================================= |
||||
|
# Optimized Production Dockerfile for Django Backend |
||||
|
# Using Debian-slim for instant pre-compiled binary wheels |
||||
|
# (scikit-image, numpy, scipy install in seconds vs 10m on Alpine) |
||||
|
# ======================================================= |
||||
|
FROM python:3.10-slim |
||||
|
|
||||
|
# Set work directory |
||||
|
WORKDIR /usr/src/app |
||||
|
|
||||
|
# Set environment variables |
||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \ |
||||
|
PYTHONUNBUFFERED=1 \ |
||||
|
PIP_NO_CACHE_DIR=1 \ |
||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 |
||||
|
|
||||
|
# 1. System Dependencies (Cached permanently unless system packages change) |
||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \ |
||||
|
gcc \ |
||||
|
g++ \ |
||||
|
curl \ |
||||
|
git \ |
||||
|
libpq-dev \ |
||||
|
libjpeg62-turbo-dev \ |
||||
|
zlib1g-dev \ |
||||
|
libfreetype6-dev \ |
||||
|
gettext \ |
||||
|
ffmpeg \ |
||||
|
&& rm -rf /var/lib/apt/lists/* |
||||
|
|
||||
|
# 2. Python Dependencies (Cached unless requirements.txt changes) |
||||
|
COPY ./requirements.txt . |
||||
|
RUN pip install --upgrade pip && \ |
||||
|
pip install -r requirements.txt |
||||
|
|
||||
|
# 3. Entrypoint script (Cached) |
||||
|
COPY ./entrypoint.sh . |
||||
|
RUN sed -i 's/\r$//g' /usr/src/app/entrypoint.sh && \ |
||||
|
chmod +x /usr/src/app/entrypoint.sh |
||||
|
|
||||
|
# 4. Application Source Code (Changes frequently on git push) |
||||
|
COPY . . |
||||
|
|
||||
|
# Run entrypoint |
||||
|
ENTRYPOINT ["/usr/src/app/entrypoint.sh"] |
||||
|
|
||||
@ -0,0 +1,195 @@ |
|||||
|
# Django Backend Starter Template |
||||
|
|
||||
|
A modern, production-ready, batteries-included Django backend template built for rapid application development. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🚀 Tech Stack |
||||
|
|
||||
|
- **Framework**: Django 5.0+ |
||||
|
- **API Engine**: Django REST Framework (DRF) |
||||
|
- **Admin Interface**: [Django Unfold](https://github.com/unfoldadmin/django-unfold) (Tailwind-based modern UI) |
||||
|
- **Database**: PostgreSQL |
||||
|
- **Caching & Broker**: Redis |
||||
|
- **Task Queue**: Celery & Celery Beat |
||||
|
- **API Documentation**: Swagger UI & ReDoc via `drf-yasg` |
||||
|
- **Static Assets**: WhiteNoise |
||||
|
- **Containerization**: Docker & Docker Compose |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🌟 Key Features |
||||
|
|
||||
|
1. **Custom User Authentication (`apps/account`)**: |
||||
|
- Email-based authentication (no cumbersome usernames). |
||||
|
- Profile management with avatar, phone number, and metadata. |
||||
|
- Built-in `LoginHistory` and `LocationHistory` tracking. |
||||
|
- Standard authentication endpoints (Register, Login, Token Exchange, Password Reset, Profile Update). |
||||
|
- Clean group and role-based permissions. |
||||
|
2. **Modern Admin Panel (`utils/admin.py` & Django Unfold)**: |
||||
|
- Modern Tailwind styling with dark/light mode. |
||||
|
- Dynamic Dashboard KPI statistics. |
||||
|
- Responsive sidebar with configurable navigation. |
||||
|
3. **Interactive API Documentation (`apps/api`)**: |
||||
|
- Live Swagger UI at `/swagger/` and ReDoc at `/redoc/`. |
||||
|
- Token authentication banner for test requests. |
||||
|
- Health check endpoint at `/api/v1/health/`. |
||||
|
- Mobile app release versioning (`AppVersion`). |
||||
|
4. **Runtime Dynamic Preferences (`dynamic_preferences/`)**: |
||||
|
- Editable site-wide settings directly from the admin panel (site title, contact email, maintenance mode). |
||||
|
5. **Production Ready**: |
||||
|
- Multi-stage Dockerfile and Docker Compose setup. |
||||
|
- Nginx reverse proxy configuration. |
||||
|
- Pre-configured Gzip compression, security headers, and media streaming. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📁 Project Structure |
||||
|
|
||||
|
```text |
||||
|
├── apps/ |
||||
|
│ ├── account/ # Custom User, authentication, profile, notifications |
||||
|
│ │ ├── admin/ # Unfold user and group admin |
||||
|
│ │ ├── migrations/ # Initial schema migrations |
||||
|
│ │ ├── models/ # User, LoginHistory, Notification models |
||||
|
│ │ ├── serializers/ # DRF serializers for user & auth |
||||
|
│ │ ├── views/ # Register, login, profile, notification views |
||||
|
│ │ └── urls.py # Account API endpoints |
||||
|
│ └── api/ # Core API utilities, versions, health checks |
||||
|
│ ├── admin/ # Version & support admin |
||||
|
│ ├── migrations/ # Initial schema migrations |
||||
|
│ ├── models/ # AppVersion, SupportMessage |
||||
|
│ ├── serializers/ # API serializers |
||||
|
│ ├── views/ # HealthCheck, AppVersion, Swagger views |
||||
|
│ └── urls.py # Core API routes |
||||
|
├── config/ |
||||
|
│ ├── settings/ |
||||
|
│ │ ├── base.py # Base Django settings |
||||
|
│ │ ├── develop.py # Development settings |
||||
|
│ │ ├── production.py # Production settings |
||||
|
│ │ └── test.py # Test settings |
||||
|
│ ├── celery.py # Celery worker configuration |
||||
|
│ ├── urls.py # Root URL configuration |
||||
|
│ ├── wsgi.py # WSGI entry point |
||||
|
│ └── asgi.py # ASGI entry point |
||||
|
├── dynamic_preferences/ # In-tree runtime preferences registry |
||||
|
├── nginx/ |
||||
|
│ └── app.conf # Nginx reverse proxy config |
||||
|
├── static/ # Static CSS and JS assets |
||||
|
├── templates/ |
||||
|
│ ├── admin/ # Unfold admin dashboard & overrides |
||||
|
│ └── swagger/ # Custom Swagger UI templates |
||||
|
├── utils/ # Reusable helper modules |
||||
|
│ ├── admin.py # Unfold AdminSite and dashboard callbacks |
||||
|
│ ├── exceptions.py # DRF custom exception handler |
||||
|
│ ├── image_compression.py # Image optimization |
||||
|
│ ├── pagination.py # Standard REST pagination |
||||
|
│ └── redis.py # Redis token and cache helpers |
||||
|
├── .env.example # Environment variables template |
||||
|
├── .env.dev # Local development env defaults |
||||
|
├── docker-compose.yml # Docker compose configuration |
||||
|
├── Dockerfile # Docker build file |
||||
|
├── manage.py # Django CLI |
||||
|
└── requirements.txt # Python dependencies |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🛠️ Quick Start |
||||
|
|
||||
|
### 1. Using Docker (Recommended) |
||||
|
|
||||
|
1. **Clone or copy the template**: |
||||
|
```bash |
||||
|
git init my-project |
||||
|
cd my-project |
||||
|
``` |
||||
|
|
||||
|
2. **Setup environment variables**: |
||||
|
```bash |
||||
|
cp .env.example .env.dev |
||||
|
``` |
||||
|
|
||||
|
3. **Build and start services**: |
||||
|
```bash |
||||
|
docker compose up -d --build |
||||
|
``` |
||||
|
|
||||
|
4. **Run migrations and create superuser**: |
||||
|
```bash |
||||
|
docker compose exec web python manage.py migrate |
||||
|
docker compose exec web python manage.py createsuperuser |
||||
|
``` |
||||
|
|
||||
|
5. Access the app: |
||||
|
- **Admin Panel**: [http://localhost:8000/admin/](http://localhost:8000/admin/) |
||||
|
- **Swagger Docs**: [http://localhost:8000/swagger/](http://localhost:8000/swagger/) |
||||
|
- **Health Check**: [http://localhost:8000/api/v1/health/](http://localhost:8000/api/v1/health/) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### 2. Local Python Environment |
||||
|
|
||||
|
1. **Create and activate a virtual environment**: |
||||
|
```bash |
||||
|
python -m venv venv |
||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate |
||||
|
``` |
||||
|
|
||||
|
2. **Install dependencies**: |
||||
|
```bash |
||||
|
pip install -r requirements.txt |
||||
|
``` |
||||
|
|
||||
|
3. **Configure environment**: |
||||
|
```bash |
||||
|
cp .env.example .env |
||||
|
# Edit .env with your local PostgreSQL and Redis credentials |
||||
|
``` |
||||
|
|
||||
|
4. **Apply migrations**: |
||||
|
```bash |
||||
|
python manage.py migrate |
||||
|
python manage.py createsuperuser |
||||
|
``` |
||||
|
|
||||
|
5. **Run the development server**: |
||||
|
```bash |
||||
|
python manage.py runserver |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🔑 Environment Variables Reference |
||||
|
|
||||
|
| Variable | Description | Default | |
||||
|
|----------|-------------|---------| |
||||
|
| `DJANGO_SECRET_KEY` | Unique Django secret key | (Required in production) | |
||||
|
| `DJANGO_DEBUG` | Enable debug mode | `True` | |
||||
|
| `DJANGO_ALLOWED_HOSTS` | Comma-separated allowed hostnames | `127.0.0.1,localhost` | |
||||
|
| `POSTGRES_DB` | PostgreSQL database name | `app_db` | |
||||
|
| `POSTGRES_USER` | PostgreSQL user | `postgres` | |
||||
|
| `POSTGRES_PASSWORD` | PostgreSQL password | `postgres` | |
||||
|
| `POSTGRES_HOST` | PostgreSQL host | `postgres` / `localhost` | |
||||
|
| `POSTGRES_PORT` | PostgreSQL port | `5432` | |
||||
|
| `REDIS_URL` | Redis connection URL | `redis://redis:6379/0` | |
||||
|
| `SENTRY_DSN` | Sentry error tracking DSN | (Optional) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📡 API Endpoints Overview |
||||
|
|
||||
|
- **Auth & Account**: |
||||
|
- `POST /api/v1/account/register/` - User registration |
||||
|
- `POST /api/v1/account/login/` - User login & token generation |
||||
|
- `GET /api/v1/account/profile/` - Authenticated user profile |
||||
|
- `PUT /api/v1/account/profile/update/` - Update profile |
||||
|
- `POST /api/v1/account/recover/` - Request password recovery |
||||
|
- `POST /api/v1/account/reset/` - Reset password |
||||
|
- **System & Utilities**: |
||||
|
- `GET /api/v1/health/` - Server health status |
||||
|
- `GET /api/v1/version/` - Latest mobile application version |
||||
|
- `POST /api/v1/contact-us/` - Submit support/contact message |
||||
|
- **Documentation**: |
||||
|
- `/swagger/` - Interactive Swagger UI |
||||
|
- `/redoc/` - ReDoc API documentation |
||||
@ -0,0 +1,100 @@ |
|||||
|
# Account API Error Documentation |
||||
|
|
||||
|
This document lists the potential errors returned by the registration and account endpoints in the `account` app, including the error messages and the reasons they occur. |
||||
|
|
||||
|
## Common Error Format |
||||
|
All errors follow a standardized JSON structure defined in the project's custom exception handler: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 400, |
||||
|
"message": "There were validation errors.", |
||||
|
"errors": [ |
||||
|
{ |
||||
|
"field": "email", |
||||
|
"message": "This email is already registered." |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. Registration Endpoints |
||||
|
**Endpoints:** `POST /register/`, `POST /web/register/` |
||||
|
|
||||
|
| Error Message | Field | Reason | |
||||
|
| :--- | :--- | :--- | |
||||
|
| `This email is already registered.` | `email` | The email address is already associated with an existing account. | |
||||
|
| `Enter a valid email address.` | `email` | The provided email format is incorrect (e.g., missing `@` or domain). | |
||||
|
| `This field is required.` | Multiple | A mandatory field (like `email`, `fullname`, or `password` for web) was missing from the request. | |
||||
|
| `This password is too short...` | `password` | (Web only) The password does not meet Django's security requirements (length, complexity). | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. Verification Endpoint |
||||
|
**Endpoint:** `POST /verify/` |
||||
|
|
||||
|
| Error Message | Field | Reason | |
||||
|
| :--- | :--- | :--- | |
||||
|
| `Verification data not found or expired.` | `code` | There is no active registration session in Redis for this email. Usually occurs if the user waits too long or tries to verify an email they didn't just register. | |
||||
|
| `The verification code has expired.` | `code` | The OTP code's Time-To-Live (TTL) has passed (usually 5-10 minutes). | |
||||
|
| `code notfound` | `code` | The provided OTP code is incorrect. | |
||||
|
| `enter code numeric` | `code` | The provided code contains non-numeric characters. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. Authentication & Login |
||||
|
**Endpoint:** `POST /login/` |
||||
|
|
||||
|
| Error Message | Field | Reason | |
||||
|
| :--- | :--- | :--- | |
||||
|
| `user not exists with this email` | `email` | No user account was found with the provided email address. | |
||||
|
| `password is incorrect` | `password` | The email is correct, but the password does not match the record in the database. | |
||||
|
| `Unable to log in with provided credentials.` | `non_field_errors` | Catch-all for failed authentication attempts. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. Guest Account Endpoints |
||||
|
**Endpoints:** `POST /guest/`, `POST /web/guest/` |
||||
|
|
||||
|
| Error Message | Field | Reason | |
||||
|
| :--- | :--- | :--- | |
||||
|
| `Device ID is required for guest users.` | `device_id` | (Mobile) The unique device identifier was not sent in the request. | |
||||
|
| `Device ID is required for web guest users.` | `device_id` | (Web) Internal error where the identifier generation failed. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. Token Exchange (Mobile Auth) |
||||
|
**Endpoint:** `POST /exchange-token/` |
||||
|
|
||||
|
| Error Message | Status Code | Reason | |
||||
|
| :--- | :--- | :--- | |
||||
|
| `توکن ارسال نشده است` | 400 | The `temp_token` was missing from the request body. | |
||||
|
| `توکن نامعتبر یا منقضی شده است` | 404 | The temporary token from the login redirect has expired or is invalid. | |
||||
|
| `توکن نامعتبر است` | 400 | The token exists but is missing required session data (`user_id`). | |
||||
|
| `کاربر یافت نشد` | 404 | The user account associated with the token has been deleted. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. Profile & Password Management |
||||
|
**Endpoints:** `GET/PUT /profile/update/`, `POST /reset/` |
||||
|
|
||||
|
| Error Message | Status Code | Reason | |
||||
|
| :--- | :--- | :--- | |
||||
|
| `Authentication credentials were not provided.` | 401 | Missing or incorrect `Authorization: Token <key>` header. | |
||||
|
| `Invalid token.` | 401 | The provided token has expired or belongs to a deleted user. | |
||||
|
| `This password is too common.` | 400 | Password reset failed because the new password is too simple. | |
||||
|
| `You do not have permission...` | 403 | The user's account has been deactivated (inactive). | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. Account Deletion |
||||
|
**Endpoint:** `DELETE /profile/delete/` |
||||
|
|
||||
|
| Error Message | Status Code | Reason | |
||||
|
| :--- | :--- | :--- | |
||||
|
| `Unable to log in with provided credentials.` | 204 | Attempted to delete the protected primary administrator account (`[email protected]`). | |
||||
|
| `User does not exist.` | 404 | The system could not find the user object to perform the soft-delete. | |
||||
@ -0,0 +1,32 @@ |
|||||
|
from unfold.components import BaseComponent, register_component |
||||
|
from django.template.loader import render_to_string |
||||
|
|
||||
|
from .user import * |
||||
|
from .location import * |
||||
|
from .notification import * |
||||
|
|
||||
|
|
||||
|
@register_component |
||||
|
class AllUserComponent(BaseComponent): |
||||
|
def get_context_data(self, **kwargs): |
||||
|
context = super().get_context_data(**kwargs) |
||||
|
context["children"] = render_to_string( |
||||
|
"admin/helpers/kpi_progress.html", |
||||
|
{ |
||||
|
"total": User.objects.filter(is_active=True).count(), |
||||
|
}, |
||||
|
) |
||||
|
return context |
||||
|
|
||||
|
|
||||
|
@register_component |
||||
|
class GuestUserComponent(BaseComponent): |
||||
|
def get_context_data(self, **kwargs): |
||||
|
context = super().get_context_data(**kwargs) |
||||
|
context["children"] = render_to_string( |
||||
|
"admin/helpers/kpi_progress.html", |
||||
|
{ |
||||
|
"total": User.objects.filter(email__isnull=True).count(), |
||||
|
}, |
||||
|
) |
||||
|
return context |
||||
@ -0,0 +1,59 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from unfold.admin import ModelAdmin, TabularInline |
||||
|
from unfold.decorators import display |
||||
|
from unfold.contrib.filters.admin import ( |
||||
|
RangeDateTimeFilter, |
||||
|
TextFilter, |
||||
|
AutocompleteSelectFilter, |
||||
|
) |
||||
|
|
||||
|
from apps.account.models import LocationHistory, User |
||||
|
from utils.admin import project_admin_site, dovoodi_admin_site |
||||
|
|
||||
|
|
||||
|
class LocationHistoryInline(TabularInline): |
||||
|
model = LocationHistory |
||||
|
extra = 0 |
||||
|
tab = True |
||||
|
fields = ('lat', 'lon', 'country', 'city', 'selected_manually', 'ip', 'timezone', 'at_time') |
||||
|
readonly_fields = ('lat', 'lon', 'country', 'city', 'selected_manually', 'ip', 'timezone', 'at_time',) |
||||
|
verbose_name = _("Location History") |
||||
|
verbose_name_plural = _("Location History") |
||||
|
can_delete = False |
||||
|
show_change_link = True |
||||
|
|
||||
|
|
||||
|
class LocationHistoryAdmin(ModelAdmin): |
||||
|
list_display = ('user', 'display_location', 'country', 'city', 'selected_manually', 'ip', 'display_at_time') |
||||
|
list_filter = [ |
||||
|
('user', AutocompleteSelectFilter), |
||||
|
'country', |
||||
|
'city', |
||||
|
'selected_manually', |
||||
|
('at_time', RangeDateTimeFilter), |
||||
|
] |
||||
|
search_fields = ('user__email', 'user__fullname', 'country', 'city', 'ip') |
||||
|
readonly_fields = ('at_time',) |
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
'fields': ('user', ('lat', 'lon'), ('country', 'city')) |
||||
|
}), |
||||
|
(_('Additional Information'), { |
||||
|
'fields': ('selected_manually', 'ip', 'timezone', 'at_time'), |
||||
|
'classes': ('tab',), |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
@display(description=_("Location")) |
||||
|
def display_location(self, instance: LocationHistory): |
||||
|
return f"{instance.lat}, {instance.lon}" |
||||
|
|
||||
|
@display(description=_("Date & Time")) |
||||
|
def display_at_time(self, instance: LocationHistory): |
||||
|
return instance.at_time.strftime("%Y-%m-%d %H:%M") if instance.at_time else "-" |
||||
|
|
||||
|
|
||||
|
|
||||
|
# Register with project admin site |
||||
|
project_admin_site.register(LocationHistory, LocationHistoryAdmin) |
||||
@ -0,0 +1,14 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from ajaxdatatable.admin import AjaxDatatable |
||||
|
|
||||
|
from apps.account.models import User, Notification |
||||
|
|
||||
|
@admin.register(Notification) |
||||
|
class NotificationAdmin(AjaxDatatable): |
||||
|
list_display = ('title', 'user', 'is_read', 'created_at') |
||||
|
list_filter = ('is_read', 'created_at') |
||||
|
search_fields = ('title', 'message', 'user__fullname') |
||||
|
list_editable = ('is_read',) |
||||
|
ordering = ('-created_at',) |
||||
|
autocomplete_fields = ['user',] |
||||
@ -0,0 +1,158 @@ |
|||||
|
from django import forms |
||||
|
from django.contrib import admin |
||||
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin |
||||
|
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin |
||||
|
from django.contrib.auth.models import Group |
||||
|
from django.db import models |
||||
|
from django.utils.html import format_html |
||||
|
from django.utils.translation import gettext_lazy as _, ngettext |
||||
|
|
||||
|
from rest_framework.authtoken.models import TokenProxy |
||||
|
from unfold.admin import ModelAdmin, StackedInline |
||||
|
from unfold.decorators import display |
||||
|
from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm |
||||
|
from unfold.contrib.filters.admin import RangeDateTimeFilter |
||||
|
|
||||
|
from apps.account.models import User, ClientUser, LocationHistory |
||||
|
from utils.admin import project_admin_site |
||||
|
from apps.account.admin.location import LocationHistoryInline |
||||
|
|
||||
|
|
||||
|
# ========================================================= |
||||
|
# 1. Base User Admin Form & Admin |
||||
|
# ========================================================= |
||||
|
|
||||
|
class UserAdminCreationForm(UserCreationForm): |
||||
|
class Meta(UserCreationForm.Meta): |
||||
|
model = User |
||||
|
fields = ("fullname", "email") |
||||
|
|
||||
|
def __init__(self, *args, **kwargs): |
||||
|
super().__init__(*args, **kwargs) |
||||
|
if 'fullname' in self.fields: |
||||
|
self.fields['fullname'].required = True |
||||
|
if 'email' in self.fields: |
||||
|
self.fields['email'].required = True |
||||
|
|
||||
|
def clean_email(self): |
||||
|
email = self.cleaned_data.get('email') |
||||
|
if User.objects.filter(email=email).exists(): |
||||
|
raise forms.ValidationError(_("A user with this email already exists.")) |
||||
|
return email |
||||
|
|
||||
|
|
||||
|
class UserAdminChangeForm(UserChangeForm): |
||||
|
class Meta(UserChangeForm.Meta): |
||||
|
model = User |
||||
|
|
||||
|
def __init__(self, *args, **kwargs): |
||||
|
super().__init__(*args, **kwargs) |
||||
|
if 'fullname' in self.fields: |
||||
|
self.fields['fullname'].required = True |
||||
|
if 'email' in self.fields: |
||||
|
self.fields['email'].required = True |
||||
|
|
||||
|
|
||||
|
class UserAdmin(ModelAdmin, BaseUserAdmin): |
||||
|
form = UserAdminChangeForm |
||||
|
add_form = UserAdminCreationForm |
||||
|
change_password_form = AdminPasswordChangeForm |
||||
|
compressed_fields = False |
||||
|
list_display = ('fullname', 'email', 'user_type', 'is_staff', 'is_active', 'display_date_joined',) |
||||
|
ordering = ("-id",) |
||||
|
search_fields = ('email', 'fullname', 'username',) |
||||
|
list_filter = [ |
||||
|
"user_type", |
||||
|
"is_active", |
||||
|
"is_staff", |
||||
|
("last_login", RangeDateTimeFilter), |
||||
|
("date_joined", RangeDateTimeFilter), |
||||
|
] |
||||
|
inlines = [LocationHistoryInline] |
||||
|
|
||||
|
add_fieldsets = ( |
||||
|
(None, { |
||||
|
'classes': ('wide',), |
||||
|
'fields': (('fullname', 'email'), 'phone_number', 'birthdate', 'gender', 'avatar', 'skill', 'info'), |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, { |
||||
|
"fields": ( |
||||
|
("fullname", "email"), |
||||
|
("password",), |
||||
|
), |
||||
|
}), |
||||
|
(_("Profile Details"), { |
||||
|
"classes": ["tab"], |
||||
|
"fields": ( |
||||
|
("avatar",), |
||||
|
("phone_number", "gender"), |
||||
|
("birthdate", "language"), |
||||
|
("skill",), |
||||
|
("info",), |
||||
|
), |
||||
|
}), |
||||
|
(_("Access & Status"), { |
||||
|
"classes": ["tab"], |
||||
|
"fields": ( |
||||
|
("user_type",), |
||||
|
("is_active", "is_staff", "is_superuser"), |
||||
|
("groups", "user_permissions"), |
||||
|
), |
||||
|
}), |
||||
|
(_("Timestamps & Metadata"), { |
||||
|
"classes": ["tab"], |
||||
|
"fields": ( |
||||
|
("date_joined", "last_login"), |
||||
|
("deleted_at",), |
||||
|
), |
||||
|
}), |
||||
|
) |
||||
|
|
||||
|
readonly_fields = ('date_joined', 'last_login') |
||||
|
|
||||
|
@display(description=_("Date Joined")) |
||||
|
def display_date_joined(self, instance: User): |
||||
|
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-" |
||||
|
|
||||
|
|
||||
|
class GroupAdmin(BaseGroupAdmin, ModelAdmin): |
||||
|
list_display = ('name', 'permissions_count') |
||||
|
search_fields = ('name',) |
||||
|
ordering = ('name',) |
||||
|
filter_horizontal = ('permissions',) |
||||
|
|
||||
|
fieldsets = ( |
||||
|
(None, {'fields': ('name',)}), |
||||
|
(_('Permissions'), {'fields': ('permissions',), 'classes': ['tab']}), |
||||
|
) |
||||
|
|
||||
|
@display(description=_("Permissions")) |
||||
|
def permissions_count(self, obj): |
||||
|
count = obj.permissions.count() |
||||
|
return ngettext("{count} permission", "{count} permissions", count).format(count=count) if count > 0 else "-" |
||||
|
|
||||
|
|
||||
|
# ========================================================= |
||||
|
# 2. Registrations |
||||
|
# ========================================================= |
||||
|
|
||||
|
try: |
||||
|
admin.site.unregister(User) |
||||
|
except admin.sites.NotRegistered: |
||||
|
pass |
||||
|
|
||||
|
try: |
||||
|
admin.site.register(User, UserAdmin) |
||||
|
except admin.sites.AlreadyRegistered: |
||||
|
pass |
||||
|
|
||||
|
project_admin_site.register(User, UserAdmin) |
||||
|
project_admin_site.register(Group, GroupAdmin) |
||||
|
|
||||
|
try: |
||||
|
admin.site.unregister(TokenProxy) |
||||
|
except admin.sites.NotRegistered: |
||||
|
pass |
||||
@ -0,0 +1,7 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
|
||||
|
|
||||
|
class AccountConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.account' |
||||
|
icon = 'mi-person' |
||||
@ -0,0 +1,28 @@ |
|||||
|
from django.contrib.auth.backends import BaseBackend |
||||
|
from django.db.models import Q |
||||
|
|
||||
|
from apps.account.models import User |
||||
|
from utils.exceptions import UserNotFoundException |
||||
|
from rest_framework.exceptions import AuthenticationFailed |
||||
|
|
||||
|
|
||||
|
class CustomLoginBackend(BaseBackend): |
||||
|
""" |
||||
|
Authenticate with username email and phone_number. |
||||
|
""" |
||||
|
|
||||
|
def authenticate(self, request, username=None, password=None): |
||||
|
if user := self.get_user(username): |
||||
|
if user.check_password(password): |
||||
|
return user |
||||
|
|
||||
|
return None |
||||
|
|
||||
|
def get_user(self, username): |
||||
|
try: |
||||
|
if isinstance(username, int): |
||||
|
return User.objects.filter(id=int(username)).first() |
||||
|
return User.objects.filter(Q(email=username) | Q(phone_number=str(username))).first() |
||||
|
|
||||
|
except User.DoesNotExist: |
||||
|
return None |
||||
@ -0,0 +1,835 @@ |
|||||
|
def doc_reset(): |
||||
|
return """ |
||||
|
# 🐈 Scenario |
||||
|
🛠️ تنظیم مجدد رمز عبور |
||||
|
|
||||
|
کاربر پس از تأیید کد بازیابی رمز عبور، میتواند رمز عبور جدید خود را تنظیم کند. برای این کار، کاربر باید رمز عبور جدید و تأیید آن را وارد کند. |
||||
|
|
||||
|
بعد از ریکاور و وریفای |
||||
|
به این صفحه برای ریست میآید |
||||
|
که باید با همان توکنی که در وریفای دریافت کرده است را درخواست کند |
||||
|
|
||||
|
(نکته بعد از ریست پسورد توکن ذخیره شده حذف شود و کاربر باید با رمز عبور جدیدی که ست کرده است مجددا لاگین را انجام دهد) |
||||
|
--- |
||||
|
|
||||
|
## 🚀 درخواست API |
||||
|
|
||||
|
### URL: |
||||
|
``` |
||||
|
POST /api/reset-password/ |
||||
|
``` |
||||
|
|
||||
|
### Header: |
||||
|
| کلید | مقدار | |
||||
|
|---------------|---------------------------------| |
||||
|
| Content-Type | application/json | |
||||
|
| Authorization | Bearer <توکن احراز هویت> | |
||||
|
|
||||
|
### Body: |
||||
|
```json |
||||
|
{ |
||||
|
"password": "newstrongpassword", |
||||
|
"password_confirmation": "newstrongpassword" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📊 پاسخها |
||||
|
|
||||
|
| کد وضعیت | توضیحات | |
||||
|
|---------------|-----------------------------------------------------------| |
||||
|
| `200` | موفقیتآمیز - رمز عبور با موفقیت تغییر یافت. | |
||||
|
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
||||
|
| `401` | عدم احراز هویت - کاربر وارد نشده است یا توکن نامعتبر است. | |
||||
|
| `500` | مشکل موقتی در سرور. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ موفقیتآمیز |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"message": "Your password has been changed successfully." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ خطا |
||||
|
|
||||
|
### رمز عبور و تأیید رمز عبور برابر نیستند: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 400, |
||||
|
"message": "Passwords do not match." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### رمز عبور کوتاهتر از 8 کاراکتر است: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 400, |
||||
|
"message": "Password must be at least 8 characters long." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### عدم احراز هویت: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "unauthorized", |
||||
|
"status_code": 401, |
||||
|
"message": "Authentication credentials were not provided or are invalid." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مشکل موقتی در سرور: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "service_unavailable", |
||||
|
"status_code": 500, |
||||
|
"message": "Service temporarily unavailable." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 💡 نکات مهم: |
||||
|
1. **رمز عبور جدید:** |
||||
|
- باید حداقل 8 کاراکتر باشد و تأیید رمز عبور (`password_confirmation`) باید با رمز عبور اصلی یکسان باشد. |
||||
|
2. **امنیت:** |
||||
|
- کاربر باید توکن احراز هویت معتبر برای تنظیم مجدد رمز عبور ارائه دهد. |
||||
|
3. **توکن احراز هویت:** |
||||
|
- فقط کاربران احراز هویت شده میتوانند رمز عبور خود را تغییر دهند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🔧 توضیحات فنی: |
||||
|
|
||||
|
### فرآیند تنظیم مجدد رمز عبور: |
||||
|
1. کاربر باید ابتدا کد بازیابی رمز عبور را تأیید کند. |
||||
|
2. پس از تأیید موفقیتآمیز، کاربر با استفاده از توکن احراز هویت، رمز عبور جدید و تأیید آن را وارد میکند. |
||||
|
3. اگر دادهها معتبر باشند، رمز عبور جدید برای کاربر تنظیم میشود. |
||||
|
4. اگر دادهها نادرست باشند، پیام خطای مناسب به کاربر بازگردانده میشود. |
||||
|
|
||||
|
### ولیدیشنها: |
||||
|
- **رمز عبور:** |
||||
|
- بررسی میشود که رمز عبور حداقل 8 کاراکتر باشد. |
||||
|
- بررسی میشود که رمز عبور و تأیید آن یکسان باشند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه درخواست: |
||||
|
|
||||
|
### درخواست کامل: |
||||
|
```json |
||||
|
{ |
||||
|
"password": "mynewpassword", |
||||
|
"password_confirmation": "mynewpassword" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### پاسخ موفق: |
||||
|
```json |
||||
|
{ |
||||
|
"message": "Your password has been changed successfully." |
||||
|
} |
||||
|
``` |
||||
|
""" |
||||
|
|
||||
|
|
||||
|
|
||||
|
def doc_recover(): |
||||
|
return """ |
||||
|
# 🐈 Scenario |
||||
|
🛠️ بازیابی رمز عبور |
||||
|
|
||||
|
کاربر با وارد کردن ایمیل خود، درخواست بازیابی رمز عبور میدهد. |
||||
|
یک کد تأیید به ایمیل کاربر ارسال میشود تا کاربر بتواند رمز عبور خود را بازیابی کند. |
||||
|
سپس کاربر باید به صفحه وریفای ریدایرکت شود |
||||
|
و بعد از تایید وریفای با توکن داده شده |
||||
|
به صفحه ریست پسورد ریدایرکت میشود تا پسور جدیدی را ست کند |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🚀 درخواست API |
||||
|
|
||||
|
### URL: |
||||
|
``` |
||||
|
POST /api/recover-password/ |
||||
|
``` |
||||
|
|
||||
|
### Header: |
||||
|
| کلید | مقدار | |
||||
|
|---------------|---------------------------------| |
||||
|
| Content-Type | application/json | |
||||
|
| Authorization | Optional (برای این endpoint نیاز نیست) | |
||||
|
|
||||
|
### Body: |
||||
|
```json |
||||
|
{ |
||||
|
"email": "[email protected]" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📊 پاسخها |
||||
|
|
||||
|
| کد وضعیت | توضیحات | |
||||
|
|---------------|-----------------------------------------------------------| |
||||
|
| `202` | موفقیتآمیز - کد بازیابی رمز عبور به ایمیل کاربر ارسال شد. | |
||||
|
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
||||
|
| `404` | کاربر یافت نشد. | |
||||
|
| `500` | مشکل موقتی در سرور. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ موفقیتآمیز |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"id": 1, |
||||
|
"fullname": "John Doe", |
||||
|
"phone_number": "1234567890", |
||||
|
"email": "[email protected]", |
||||
|
"avatar": null, |
||||
|
"message": "Forgot password code sent" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ خطا |
||||
|
|
||||
|
### کاربر یافت نشد: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "not_found", |
||||
|
"status_code": 404, |
||||
|
"message": "User not found." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مشکل موقتی در سرور: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "service_unavailable", |
||||
|
"status_code": 500, |
||||
|
"message": "Service temporarily unavailable." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 💡 نکات مهم: |
||||
|
1. **کد بازیابی رمز عبور:** |
||||
|
- کد تأیید به ایمیل کاربر ارسال میشود و باید در مرحله بعدی برای بازیابی رمز عبور استفاده شود. |
||||
|
2. **امنیت:** |
||||
|
- کد بازیابی رمز عبور فقط برای مدت محدود اعتبار دارد و بعد از آن منقضی میشود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🔧 توضیحات فنی: |
||||
|
|
||||
|
### فرآیند بازیابی رمز عبور: |
||||
|
1. کاربر ایمیل خود را وارد میکند. |
||||
|
2. سیستم بررسی میکند که آیا کاربری با این ایمیل وجود دارد یا خیر. |
||||
|
3. اگر کاربر یافت شود، یک کد تأیید بازیابی رمز عبور به ایمیل کاربر ارسال میشود. |
||||
|
4. کاربر باید این کد را در مرحله بعدی برای تنظیم رمز عبور جدید وارد کند. |
||||
|
|
||||
|
### ولیدیشنها: |
||||
|
- **ایمیل:** |
||||
|
- بررسی میشود که ایمیل وارد شده معتبر باشد. |
||||
|
- اگر کاربری با این ایمیل یافت نشود، پیام خطای مناسب برگردانده میشود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه درخواست: |
||||
|
|
||||
|
### درخواست کامل: |
||||
|
```json |
||||
|
{ |
||||
|
"email": "[email protected]" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### پاسخ موفق: |
||||
|
```json |
||||
|
{ |
||||
|
"id": 2, |
||||
|
"fullname": "Jane Doe", |
||||
|
"phone_number": "0987654321", |
||||
|
"email": "[email protected]", |
||||
|
"avatar": null, |
||||
|
"message": "Forgot password code sent" |
||||
|
} |
||||
|
``` |
||||
|
""" |
||||
|
|
||||
|
|
||||
|
|
||||
|
def doc_login(): |
||||
|
return """ |
||||
|
# 🐈 Scenario |
||||
|
🛠️ ورود به حساب کاربری |
||||
|
|
||||
|
کاربر با وارد کردن ایمیل و رمز عبور خود به سیستم وارد میشود. اگر اعتبارنامهها معتبر باشند، توکن احراز هویت برای دسترسی به دیگر بخشهای سیستم بازگردانده میشود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🚀 درخواست API |
||||
|
|
||||
|
### URL: |
||||
|
``` |
||||
|
POST /api/login/ |
||||
|
``` |
||||
|
|
||||
|
### Header: |
||||
|
| کلید | مقدار | |
||||
|
|---------------|---------------------------------| |
||||
|
| Content-Type | application/json | |
||||
|
| Authorization | Optional (برای این endpoint نیاز نیست) | |
||||
|
|
||||
|
### Body: |
||||
|
```json |
||||
|
{ |
||||
|
"email": "[email protected]", |
||||
|
"password": "strongpassword", |
||||
|
"fcm": "fcm_token_optional", |
||||
|
"device_id": "device_id_optional" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📊 پاسخها |
||||
|
|
||||
|
| کد وضعیت | توضیحات | |
||||
|
|---------------|-----------------------------------------------------------| |
||||
|
| `201` | موفقیتآمیز - کاربر با موفقیت وارد شد و توکن احراز هویت بازگردانده شد. | |
||||
|
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
||||
|
| `404` | کاربر یافت نشد. | |
||||
|
| `500` | مشکل موقتی در سرور. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ موفقیتآمیز |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"id": 1, |
||||
|
"fullname": "John Doe", |
||||
|
"email": "[email protected]", |
||||
|
"token": "abc123def456", |
||||
|
"avatar": "https://example.com/avatar.jpg" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ خطا |
||||
|
|
||||
|
### ورود ناموفق (اطلاعات اشتباه): |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "invalid_credentials", |
||||
|
"status_code": 400, |
||||
|
"message": "Unable to log in with provided credentials." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### کاربر یافت نشد: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "not_found", |
||||
|
"status_code": 404, |
||||
|
"message": "User not found." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مشکل موقتی در سرور: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "service_unavailable", |
||||
|
"status_code": 500, |
||||
|
"message": "Service temporarily unavailable." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 💡 نکات مهم: |
||||
|
1. **رمز عبور:** |
||||
|
- رمز عبور باید صحیح و مطابق با آنچه کاربر هنگام ثبتنام ارائه کرده است، باشد. |
||||
|
2. **توکن احراز هویت:** |
||||
|
- پس از ورود موفقیتآمیز، توکن احراز هویت به کاربر بازگردانده میشود که برای دسترسی به دیگر بخشهای سیستم نیاز است. |
||||
|
3. **اطلاعات دستگاه:** |
||||
|
- `fcm` و `device_id` به عنوان اطلاعات اختیاری برای شناسایی دستگاه ارسال میشوند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🔧 توضیحات فنی: |
||||
|
|
||||
|
### فرآیند ورود به حساب کاربری: |
||||
|
1. کاربر ایمیل و رمز عبور خود را وارد میکند. |
||||
|
2. سیستم سعی میکند کاربر را با استفاده از اعتبارنامههای ارائه شده احراز هویت کند. |
||||
|
3. اگر کاربر یافت شود و اعتبارنامهها صحیح باشند، یک توکن احراز هویت ایجاد شده و به کاربر بازگردانده میشود. |
||||
|
4. اگر اعتبارنامه نادرست باشند، پیام خطا برگردانده میشود. |
||||
|
|
||||
|
### ولیدیشنها: |
||||
|
- **ایمیل و رمز عبور:** |
||||
|
- بررسی میشود که ایمیل و رمز عبور وارد شده معتبر باشند. |
||||
|
- اگر کاربر با این ایمیل و رمز عبور یافت نشود، پیام خطای مناسب برگردانده میشود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه درخواست: |
||||
|
|
||||
|
### درخواست کامل: |
||||
|
```json |
||||
|
{ |
||||
|
"email": "[email protected]", |
||||
|
"password": "mypassword", |
||||
|
"fcm": "fcm_token_example", |
||||
|
"device_id": "device_id_example" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### پاسخ موفق: |
||||
|
```json |
||||
|
{ |
||||
|
"id": 2, |
||||
|
"fullname": "Jane Doe", |
||||
|
"email": "[email protected]", |
||||
|
"token": "xyz987uvw654", |
||||
|
"avatar": null |
||||
|
} |
||||
|
``` |
||||
|
""" |
||||
|
|
||||
|
|
||||
|
def doc_verify(): |
||||
|
return """ |
||||
|
# 🐈 Scenario |
||||
|
📅️ تأیید حساب کاربری با کد تأیید |
||||
|
|
||||
|
کاربر پس از ثبتنام، باید با استفاده از کد تأییدی که به ایمیل او ارسال شده است، |
||||
|
حساب کاربری خود را تأیید کند. در این مرحله، کاربر ایمیل و کد تأیید خود را ارسال میکند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🚀 درخواست API |
||||
|
|
||||
|
### URL: |
||||
|
``` |
||||
|
POST /api/verify/ |
||||
|
``` |
||||
|
|
||||
|
### Header: |
||||
|
| کلید | مقدار | |
||||
|
|---------------|---------------------------------| |
||||
|
| Content-Type | application/json | |
||||
|
| Authorization | Optional (برای این endpoint نیاز نیست) | |
||||
|
|
||||
|
### Body: |
||||
|
```json |
||||
|
{ |
||||
|
"email": "[email protected]", |
||||
|
"code": "12345" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📊 پاسخها |
||||
|
|
||||
|
| کد وضعیت | توضیحات | |
||||
|
|---------------|-----------------------------------------------------------| |
||||
|
| `201` | موفقیتآمیز - کاربر تأیید شد و توکن احراز هویت بازگردانده شد. | |
||||
|
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
||||
|
| `404` | کاربر یا کد تأیید یافت نشد. | |
||||
|
| `410` | کد تأیید منقضی شده است. | |
||||
|
| `500` | مشکل موقتی در سرور. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ موفقیتآمیز |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"token": "abc123def456", |
||||
|
"user_id": 1, |
||||
|
"phone_number": "1234567890", |
||||
|
"email": "[email protected]", |
||||
|
"fullname": "John Doe", |
||||
|
"avatar": null |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ خطا |
||||
|
|
||||
|
### کد تأیید نادرست: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "invalid_verification_code", |
||||
|
"status_code": 400, |
||||
|
"message": "The verification code is invalid." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### کد تأیید منقضی شده است: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "expired_code", |
||||
|
"status_code": 410, |
||||
|
"message": "The verification code has expired." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### کاربر یا کد تأیید یافت نشد: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "not_found", |
||||
|
"status_code": 404, |
||||
|
"message": "Verification data not found or expired." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مشکل موقتی در سرور: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "service_unavailable", |
||||
|
"status_code": 500, |
||||
|
"message": "Service temporarily unavailable." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 💡 نکات مهم: |
||||
|
1. **کد تأیید:** |
||||
|
- کد تأیید باید دقیقاً با کدی که به ایمیل کاربر ارسال شده مطابقت داشته باشد. |
||||
|
- کد تأیید فقط برای یک مدت محدود اعتبار دارد. |
||||
|
2. **خطاها:** |
||||
|
- اگر کد تأیید نادرست باشد، پیام مناسب بازگردانده میشود. |
||||
|
- اگر کد تأیید منقضی شده باشد، کاربر باید درخواست کد جدید کند. |
||||
|
3. **توکن احراز هویت:** |
||||
|
- پس از تأیید موفقیتآمیز، توکن احراز هویت به کاربر بازگردانده میشود که برای دسترسی به دیگر بخشهای سیستم نیاز است. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ولیدیشنها: |
||||
|
- **کد تأیید:** |
||||
|
- باید حداکثر 5 کاراکتر باشد. |
||||
|
- اگر کد معتبر نباشد یا منقضی شده باشد، پیام خطای مناسب برگردانده میشود. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه درخواست: |
||||
|
|
||||
|
### درخواست کامل: |
||||
|
```json |
||||
|
{ |
||||
|
"email": "[email protected]", |
||||
|
"code": "67890" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### پاسخ موفق: |
||||
|
```json |
||||
|
{ |
||||
|
"token": "xyz987uvw654", |
||||
|
"user_id": 2, |
||||
|
"phone_number": "0987654321", |
||||
|
"email": "[email protected]", |
||||
|
"fullname": "Jane Doe", |
||||
|
"avatar": null |
||||
|
} |
||||
|
``` |
||||
|
""" |
||||
|
|
||||
|
|
||||
|
def doc_register(): |
||||
|
return """ |
||||
|
# 🐈 Scenario |
||||
|
ثبت نام کاربر |
||||
|
|
||||
|
کاربر با وارد کردن اطلاعات مورد نیاز شامل نام کامل، ایمیل، رمز عبور و تأیید رمز عبور درخواست ثبتنام ارسال میکند. پس از ثبت موفق، یک کد تأیید به ایمیل ارسال میشود که برای تکمیل ثبتنام مورد نیاز است. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 🚀 درخواست API |
||||
|
|
||||
|
### URL: |
||||
|
``` |
||||
|
POST /api/register/ |
||||
|
``` |
||||
|
|
||||
|
### Header: |
||||
|
| کلید | مقدار | |
||||
|
|---------------|---------------------------------| |
||||
|
| Content-Type | application/json | |
||||
|
| Authorization | Optional (برای این endpoint نیاز نیست) | |
||||
|
|
||||
|
### Body: |
||||
|
```json |
||||
|
{ |
||||
|
"fullname": "John Doe", |
||||
|
"email": "[email protected]", |
||||
|
"password": "strongpassword", |
||||
|
"password_confirmation": "strongpassword", |
||||
|
"fcm": "fcm_token_optional", |
||||
|
"device_id": "device_id_optional" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📊 پاسخها |
||||
|
|
||||
|
| کد وضعیت | توضیحات | |
||||
|
|---------------|-----------------------------------------------------------| |
||||
|
| `202` | موفقیتآمیز - کد تأیید به ایمیل کاربر ارسال شد. | |
||||
|
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
||||
|
| `409` | ایمیل قبلاً ثبت شده است. | |
||||
|
| `404` | کاربر یا منبع یافت نشد. | |
||||
|
| `410` | کد تأیید منقضی شده است. | |
||||
|
| `500` | مشکل موقتی در سرور. | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ موفقیتآمیز |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"user": { |
||||
|
"id": 1, |
||||
|
"fullname": "John Doe", |
||||
|
"email": "[email protected]" |
||||
|
}, |
||||
|
"message": "The otp code was sent to the user's email" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه پاسخ خطا |
||||
|
|
||||
|
### ایمیل تکراری: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 409, |
||||
|
"message": "There were validation errors.", |
||||
|
"errors": [ |
||||
|
{ |
||||
|
"field": "email", |
||||
|
"message": "This email is already registered." |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### رمز عبور و تأیید رمز عبور برابر نیستند: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 400, |
||||
|
"message": "There were validation errors.", |
||||
|
"errors": [ |
||||
|
{ |
||||
|
"field": "password_confirmation", |
||||
|
"message": "Passwords do not match." |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### رمز عبور کوتاهتر از 8 کاراکتر است: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 400, |
||||
|
"message": "There were validation errors.", |
||||
|
"errors": [ |
||||
|
{ |
||||
|
"field": "password", |
||||
|
"message": "Password must be at least 8 characters long." |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### درخواست نامعتبر (فیلدهای اجباری): |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 400, |
||||
|
"message": "There were validation errors.", |
||||
|
"errors": [ |
||||
|
{ |
||||
|
"field": "fullname", |
||||
|
"message": "This field is required." |
||||
|
}, |
||||
|
{ |
||||
|
"field": "email", |
||||
|
"message": "This field is required." |
||||
|
}, |
||||
|
{ |
||||
|
"field": "password", |
||||
|
"message": "This field is required." |
||||
|
}, |
||||
|
{ |
||||
|
"field": "password_confirmation", |
||||
|
"message": "This field is required." |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### کاربر یافت نشد: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "not_found", |
||||
|
"status_code": 404, |
||||
|
"message": "The requested resource was not found." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### کد تأیید منقضی شده است: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "expired_code", |
||||
|
"status_code": 410, |
||||
|
"message": "The verification code has expired." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### مشکل موقتی در سرور: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "service_unavailable", |
||||
|
"status_code": 500, |
||||
|
"message": "Service temporarily unavailable." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 💡 نکات مهم: |
||||
|
1. **رمز عبور:** |
||||
|
- باید حداقل 8 کاراکتر باشد. |
||||
|
- رمز عبور و تأیید رمز عبور (`password_confirmation`) باید یکسان باشند. |
||||
|
2. **ایمیل:** |
||||
|
- باید یک آدرس ایمیل معتبر باشد. |
||||
|
- ایمیلهای تکراری مجاز نیستند. |
||||
|
3. **کد OTP:** |
||||
|
- کد تأیید به ایمیل ارسال میشود و برای وریفای کاربر استفاده میشود. |
||||
|
4. **فیلدهای اختیاری:** |
||||
|
- `fcm` و `device_id` در صورت نیاز میتوانند ارسال شوند اما اجباری نیستند. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### ولیدیشنها: |
||||
|
- **ایمیل:** |
||||
|
- بررسی میشود که در سیستم موجود نباشد. |
||||
|
- اگر موجود باشد، پیام خطای زیر برگردانده میشود: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 409, |
||||
|
"message": "There were validation errors.", |
||||
|
"errors": [ |
||||
|
{ |
||||
|
"field": "email", |
||||
|
"message": "This email is already registered." |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
- **رمز عبور:** |
||||
|
- بررسی میشود که حداقل 8 کاراکتر باشد: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 400, |
||||
|
"message": "There were validation errors.", |
||||
|
"errors": [ |
||||
|
{ |
||||
|
"field": "password", |
||||
|
"message": "Password must be at least 8 characters long." |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
- بررسی میشود که با `password_confirmation` یکسان باشد: |
||||
|
```json |
||||
|
{ |
||||
|
"status": "error", |
||||
|
"code": "validation_error", |
||||
|
"status_code": 400, |
||||
|
"message": "There were validation errors.", |
||||
|
"errors": [ |
||||
|
{ |
||||
|
"field": "password_confirmation", |
||||
|
"message": "Passwords do not match." |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 📄 نمونه درخواست: |
||||
|
|
||||
|
### درخواست کامل: |
||||
|
```json |
||||
|
{ |
||||
|
"fullname": "Jane Doe", |
||||
|
"email": "[email protected]", |
||||
|
"password": "securepassword", |
||||
|
"password_confirmation": "securepassword", |
||||
|
"fcm": "fcm_token_example", |
||||
|
"device_id": "device_id_example" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### پاسخ موفق: |
||||
|
```json |
||||
|
{ |
||||
|
"user": { |
||||
|
"id": 2, |
||||
|
"fullname": "Jane Doe", |
||||
|
"email": "[email protected]" |
||||
|
}, |
||||
|
"message": "The otp code was sent to the user's email" |
||||
|
} |
||||
|
``` |
||||
|
""" |
||||
@ -0,0 +1,28 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from django.db import transaction |
||||
|
from django.db.models import Q |
||||
|
|
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = "Assign slugs to all professor users that currently lack one." |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
professors = User.objects.filter( |
||||
|
user_type=User.UserType.PROFESSOR |
||||
|
).filter(Q(slug__isnull=True) | Q(slug="")) |
||||
|
|
||||
|
if not professors.exists(): |
||||
|
self.stdout.write(self.style.SUCCESS("All professor users already have slugs.")) |
||||
|
return |
||||
|
|
||||
|
updated = 0 |
||||
|
with transaction.atomic(): |
||||
|
for professor in professors.iterator(): |
||||
|
if professor.ensure_professor_profile(): |
||||
|
updated += 1 |
||||
|
|
||||
|
self.stdout.write( |
||||
|
self.style.SUCCESS(f"Assigned slugs to {updated} professor user(s).") |
||||
|
) |
||||
@ -0,0 +1,187 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from django.contrib.auth.models import Group |
||||
|
|
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = ( |
||||
|
"Audit admin panel access consistency for users. " |
||||
|
"Shows users with panel access and highlights unsafe role/flag/group combinations. " |
||||
|
"Use --fix to automatically normalize unsafe records." |
||||
|
) |
||||
|
|
||||
|
PANEL_GROUPS = {"Professor Group", "Admin Group", "Super Admin Group", "Super admin Group"} |
||||
|
|
||||
|
def add_arguments(self, parser): |
||||
|
parser.add_argument( |
||||
|
"--fix", |
||||
|
action="store_true", |
||||
|
help="Automatically fix unsafe user records.", |
||||
|
) |
||||
|
parser.add_argument( |
||||
|
"--email", |
||||
|
action="append", |
||||
|
dest="emails", |
||||
|
help="Limit audit to one or more email addresses. Can be passed multiple times.", |
||||
|
) |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
should_fix = options["fix"] |
||||
|
emails = options.get("emails") or [] |
||||
|
|
||||
|
queryset = User.objects.filter(email__isnull=False).exclude(email="").order_by("id") |
||||
|
if emails: |
||||
|
queryset = queryset.filter(email__in=emails) |
||||
|
|
||||
|
panel_users = [] |
||||
|
anomalies = [] |
||||
|
fixed_count = 0 |
||||
|
|
||||
|
for user in queryset: |
||||
|
groups = list(user.groups.values_list("name", flat=True)) |
||||
|
can_panel = user.can_access_admin_panel() |
||||
|
|
||||
|
if can_panel or user.is_staff or user.is_superuser or any(g in self.PANEL_GROUPS for g in groups): |
||||
|
panel_users.append( |
||||
|
{ |
||||
|
"id": user.id, |
||||
|
"email": user.email, |
||||
|
"user_type": user.user_type, |
||||
|
"is_staff": user.is_staff, |
||||
|
"is_superuser": user.is_superuser, |
||||
|
"groups": groups, |
||||
|
"can_panel": can_panel, |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
user_anomalies = self.get_anomalies(user, groups) |
||||
|
if user_anomalies: |
||||
|
anomalies.append( |
||||
|
{ |
||||
|
"user": user, |
||||
|
"groups": groups, |
||||
|
"anomalies": user_anomalies, |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
self.stdout.write(self.style.MIGRATE_HEADING("Panel Access Users")) |
||||
|
if panel_users: |
||||
|
for item in panel_users: |
||||
|
self.stdout.write(str(item)) |
||||
|
else: |
||||
|
self.stdout.write(self.style.SUCCESS("No panel-related users found.")) |
||||
|
|
||||
|
self.stdout.write("") |
||||
|
self.stdout.write(self.style.MIGRATE_HEADING("Anomalies")) |
||||
|
if anomalies: |
||||
|
for item in anomalies: |
||||
|
user = item["user"] |
||||
|
self.stdout.write( |
||||
|
self.style.WARNING( |
||||
|
str( |
||||
|
{ |
||||
|
"id": user.id, |
||||
|
"email": user.email, |
||||
|
"user_type": user.user_type, |
||||
|
"is_staff": user.is_staff, |
||||
|
"is_superuser": user.is_superuser, |
||||
|
"groups": item["groups"], |
||||
|
"anomalies": item["anomalies"], |
||||
|
} |
||||
|
) |
||||
|
) |
||||
|
) |
||||
|
if should_fix: |
||||
|
if self.fix_user(user, item["anomalies"]): |
||||
|
fixed_count += 1 |
||||
|
else: |
||||
|
self.stdout.write(self.style.SUCCESS("No anomalies found.")) |
||||
|
|
||||
|
if should_fix: |
||||
|
self.stdout.write("") |
||||
|
self.stdout.write(self.style.SUCCESS(f"Fixed {fixed_count} user(s).")) |
||||
|
|
||||
|
def get_anomalies(self, user, groups): |
||||
|
anomalies = [] |
||||
|
|
||||
|
if user.user_type in [User.UserType.STUDENT, User.UserType.CLIENT, User.UserType.CONSULTANT]: |
||||
|
if user.is_staff or user.is_superuser: |
||||
|
anomalies.append("low_role_with_staff_flags") |
||||
|
if any(group in self.PANEL_GROUPS for group in groups): |
||||
|
anomalies.append("low_role_with_panel_group") |
||||
|
if user.can_access_admin_panel(): |
||||
|
anomalies.append("low_role_can_access_panel") |
||||
|
|
||||
|
if user.user_type == User.UserType.PROFESSOR and "Professor Group" not in groups: |
||||
|
anomalies.append("professor_missing_group") |
||||
|
|
||||
|
if user.user_type == User.UserType.ADMIN and "Admin Group" not in groups: |
||||
|
anomalies.append("admin_missing_group") |
||||
|
|
||||
|
if user.user_type == User.UserType.SUPER_ADMIN and not any( |
||||
|
group in groups for group in ["Super Admin Group", "Super admin Group"] |
||||
|
): |
||||
|
anomalies.append("super_admin_missing_group") |
||||
|
|
||||
|
if user.user_type == User.UserType.SUPER_ADMIN and not user.is_superuser: |
||||
|
anomalies.append("super_admin_missing_superuser_flag") |
||||
|
|
||||
|
return anomalies |
||||
|
|
||||
|
def fix_user(self, user, anomalies): |
||||
|
changed = False |
||||
|
|
||||
|
if user.user_type == User.UserType.SUPER_ADMIN: |
||||
|
if not user.is_staff: |
||||
|
user.is_staff = True |
||||
|
changed = True |
||||
|
if not user.is_superuser: |
||||
|
user.is_superuser = True |
||||
|
changed = True |
||||
|
changed = self.ensure_group(user, "Super Admin Group") or changed |
||||
|
changed = self.remove_groups(user, {"Professor Group", "Admin Group", "Super admin Group"}) or changed |
||||
|
|
||||
|
elif user.user_type == User.UserType.ADMIN: |
||||
|
if user.is_superuser: |
||||
|
user.is_superuser = False |
||||
|
changed = True |
||||
|
changed = self.ensure_group(user, "Admin Group") or changed |
||||
|
changed = self.remove_groups(user, {"Professor Group", "Super Admin Group", "Super admin Group"}) or changed |
||||
|
|
||||
|
elif user.user_type == User.UserType.PROFESSOR: |
||||
|
if user.is_superuser: |
||||
|
user.is_superuser = False |
||||
|
changed = True |
||||
|
changed = self.ensure_group(user, "Professor Group") or changed |
||||
|
changed = self.remove_groups(user, {"Admin Group", "Super Admin Group", "Super admin Group"}) or changed |
||||
|
|
||||
|
elif user.user_type in [User.UserType.STUDENT, User.UserType.CLIENT, User.UserType.CONSULTANT]: |
||||
|
if user.is_staff: |
||||
|
user.is_staff = False |
||||
|
changed = True |
||||
|
if user.is_superuser: |
||||
|
user.is_superuser = False |
||||
|
changed = True |
||||
|
changed = self.remove_groups(user, self.PANEL_GROUPS) or changed |
||||
|
|
||||
|
if changed: |
||||
|
user.save() |
||||
|
self.stdout.write(self.style.SUCCESS(f"Fixed user {user.id} <{user.email}>")) |
||||
|
|
||||
|
return changed |
||||
|
|
||||
|
def ensure_group(self, user, group_name): |
||||
|
group, _ = Group.objects.get_or_create(name=group_name) |
||||
|
if not user.groups.filter(id=group.id).exists(): |
||||
|
user.groups.add(group) |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
def remove_groups(self, user, group_names): |
||||
|
groups = Group.objects.filter(name__in=group_names) |
||||
|
existing_ids = set(user.groups.filter(id__in=groups.values("id")).values_list("id", flat=True)) |
||||
|
if existing_ids: |
||||
|
user.groups.remove(*groups) |
||||
|
return True |
||||
|
return False |
||||
@ -0,0 +1,175 @@ |
|||||
|
import os |
||||
|
import csv |
||||
|
import zipfile |
||||
|
import requests |
||||
|
from pathlib import Path |
||||
|
from django.core.management.base import BaseCommand, CommandError |
||||
|
from django.db import connection |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = 'Create and populate geonames_city table with GeoNames data' |
||||
|
|
||||
|
def add_arguments(self, parser): |
||||
|
parser.add_argument( |
||||
|
'--force', |
||||
|
action='store_true', |
||||
|
help='Force recreation of table even if it exists', |
||||
|
) |
||||
|
parser.add_argument( |
||||
|
'--skip-download', |
||||
|
action='store_true', |
||||
|
help='Skip downloading data, use existing files', |
||||
|
) |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
self.stdout.write('Creating geonames_city table...') |
||||
|
|
||||
|
# Create table |
||||
|
with connection.cursor() as cursor: |
||||
|
if options['force']: |
||||
|
cursor.execute('DROP TABLE IF EXISTS geonames_city') |
||||
|
|
||||
|
cursor.execute(''' |
||||
|
CREATE TABLE IF NOT EXISTS geonames_city ( |
||||
|
id SERIAL PRIMARY KEY, |
||||
|
geonameid INTEGER, |
||||
|
name VARCHAR(200), |
||||
|
asciiname VARCHAR(200), |
||||
|
alternatenames TEXT, |
||||
|
latitude DECIMAL(10, 7), |
||||
|
longitude DECIMAL(10, 7), |
||||
|
feature_class CHAR(1), |
||||
|
feature_code VARCHAR(10), |
||||
|
country_code CHAR(2), |
||||
|
cc2 VARCHAR(200), |
||||
|
admin1_code VARCHAR(20), |
||||
|
admin2_code VARCHAR(80), |
||||
|
admin3_code VARCHAR(20), |
||||
|
admin4_code VARCHAR(20), |
||||
|
population BIGINT, |
||||
|
elevation INTEGER, |
||||
|
dem INTEGER, |
||||
|
timezone VARCHAR(40), |
||||
|
modification_date DATE |
||||
|
) |
||||
|
''') |
||||
|
|
||||
|
# Create indexes for better performance |
||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geonames_city_coords ON geonames_city (latitude, longitude)') |
||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geonames_city_country ON geonames_city (country_code)') |
||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geonames_city_feature ON geonames_city (feature_class)') |
||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geonames_city_population ON geonames_city (population)') |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS('Table created successfully')) |
||||
|
|
||||
|
if not options['skip_download']: |
||||
|
self.download_and_import_data() |
||||
|
else: |
||||
|
self.stdout.write('Skipping download, using existing data...') |
||||
|
|
||||
|
def download_and_import_data(self): |
||||
|
"""Download and import GeoNames cities data""" |
||||
|
self.stdout.write('Downloading GeoNames cities data...') |
||||
|
|
||||
|
# Create data directory |
||||
|
data_dir = Path('utils/geonames_data') |
||||
|
data_dir.mkdir(exist_ok=True) |
||||
|
|
||||
|
# Download cities500.zip (cities with population > 500) |
||||
|
url = 'https://download.geonames.org/export/dump/cities500.zip' |
||||
|
zip_path = data_dir / 'cities500.zip' |
||||
|
|
||||
|
try: |
||||
|
response = requests.get(url, stream=True) |
||||
|
response.raise_for_status() |
||||
|
|
||||
|
with open(zip_path, 'wb') as f: |
||||
|
for chunk in response.iter_content(chunk_size=8192): |
||||
|
f.write(chunk) |
||||
|
|
||||
|
self.stdout.write('Download completed') |
||||
|
|
||||
|
# Extract zip file |
||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref: |
||||
|
zip_ref.extractall(data_dir) |
||||
|
|
||||
|
# Import data |
||||
|
self.import_cities_data(data_dir / 'cities500.txt') |
||||
|
|
||||
|
except Exception as e: |
||||
|
raise CommandError(f'Failed to download/import data: {e}') |
||||
|
|
||||
|
def import_cities_data(self, txt_file): |
||||
|
"""Import cities data from GeoNames text file""" |
||||
|
self.stdout.write(f'Importing data from {txt_file}...') |
||||
|
|
||||
|
if not txt_file.exists(): |
||||
|
raise CommandError(f'File {txt_file} does not exist') |
||||
|
|
||||
|
batch_size = 1000 |
||||
|
batch = [] |
||||
|
|
||||
|
with open(txt_file, 'r', encoding='utf-8') as f: |
||||
|
for line_num, line in enumerate(f, 1): |
||||
|
if line_num % 10000 == 0: |
||||
|
self.stdout.write(f'Processing line {line_num}...') |
||||
|
|
||||
|
fields = line.strip().split('\t') |
||||
|
if len(fields) < 19: |
||||
|
continue |
||||
|
|
||||
|
try: |
||||
|
# Parse the GeoNames format |
||||
|
geonameid = int(fields[0]) |
||||
|
name = fields[1][:200] if fields[1] else '' |
||||
|
asciiname = fields[2][:200] if fields[2] else '' |
||||
|
alternatenames = fields[3] if fields[3] else '' |
||||
|
latitude = float(fields[4]) |
||||
|
longitude = float(fields[5]) |
||||
|
feature_class = fields[6] |
||||
|
feature_code = fields[7] |
||||
|
country_code = fields[8][:2] if fields[8] else '' |
||||
|
cc2 = fields[9] if fields[9] else '' |
||||
|
admin1_code = fields[10] if fields[10] else '' |
||||
|
admin2_code = fields[11] if fields[11] else '' |
||||
|
admin3_code = fields[12] if fields[12] else '' |
||||
|
admin4_code = fields[13] if fields[13] else '' |
||||
|
population = int(fields[14]) if fields[14] and fields[14] != '0' else 0 |
||||
|
elevation = int(fields[15]) if fields[15] else None |
||||
|
dem = int(fields[16]) if fields[16] else None |
||||
|
timezone = fields[17] if fields[17] else '' |
||||
|
modification_date = fields[18] if fields[18] else None |
||||
|
|
||||
|
batch.append(( |
||||
|
geonameid, name, asciiname, alternatenames, latitude, longitude, |
||||
|
feature_class, feature_code, country_code, cc2, admin1_code, |
||||
|
admin2_code, admin3_code, admin4_code, population, elevation, |
||||
|
dem, timezone, modification_date |
||||
|
)) |
||||
|
|
||||
|
if len(batch) >= batch_size: |
||||
|
self.insert_batch(batch) |
||||
|
batch = [] |
||||
|
|
||||
|
except (ValueError, IndexError) as e: |
||||
|
self.stdout.write(self.style.WARNING(f'Error parsing line {line_num}: {e}')) |
||||
|
continue |
||||
|
|
||||
|
# Insert remaining records |
||||
|
if batch: |
||||
|
self.insert_batch(batch) |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS('Data import completed')) |
||||
|
|
||||
|
def insert_batch(self, batch): |
||||
|
"""Insert a batch of records into the database""" |
||||
|
with connection.cursor() as cursor: |
||||
|
cursor.executemany(''' |
||||
|
INSERT INTO geonames_city ( |
||||
|
geonameid, name, asciiname, alternatenames, latitude, longitude, |
||||
|
feature_class, feature_code, country_code, cc2, admin1_code, |
||||
|
admin2_code, admin3_code, admin4_code, population, elevation, |
||||
|
dem, timezone, modification_date |
||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) |
||||
|
''', batch) |
||||
@ -0,0 +1,52 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from django.contrib.auth.models import Group, Permission |
||||
|
from django.contrib.contenttypes.models import ContentType |
||||
|
|
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = 'Create default groups and assign permissions to them' |
||||
|
|
||||
|
def handle(self, *args, **kwargs): |
||||
|
# تعریف گروهها و پرمیشنها |
||||
|
groups_permissions = { |
||||
|
"Professor Group": [ |
||||
|
"view_user", "add_user", "change_user" |
||||
|
], |
||||
|
"Client Group": [ |
||||
|
"view_user" |
||||
|
], |
||||
|
"Admin Group": [ |
||||
|
"view_user", "add_user", "change_user", "delete_user" |
||||
|
], |
||||
|
"Super Admin Group": [ |
||||
|
"view_user", "add_user", "change_user", "delete_user", "manage_permissions" |
||||
|
], |
||||
|
"Student Group": [ |
||||
|
"view_user" |
||||
|
] |
||||
|
} |
||||
|
|
||||
|
content_type = ContentType.objects.get_for_model(User) |
||||
|
|
||||
|
for group_name, permissions in groups_permissions.items(): |
||||
|
group, created = Group.objects.get_or_create(name=group_name) |
||||
|
if created: |
||||
|
self.stdout.write(self.style.SUCCESS(f"Group '{group_name}' created successfully.")) |
||||
|
else: |
||||
|
self.stdout.write(self.style.WARNING(f"Group '{group_name}' already exists.")) |
||||
|
|
||||
|
for perm_codename in permissions: |
||||
|
permission, created = Permission.objects.get_or_create( |
||||
|
codename=perm_codename, |
||||
|
defaults={ |
||||
|
'name': f"Can {perm_codename.replace('_', ' ')} User", |
||||
|
'content_type': content_type |
||||
|
} |
||||
|
) |
||||
|
group.permissions.add(permission) |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS("All groups and permissions have been created successfully.")) |
||||
@ -0,0 +1,208 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from apps.account.models import NotificationTemplate |
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = 'Seeds default NotificationTemplate records in the database with RU and EN translations.' |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
default_templates = [ |
||||
|
{ |
||||
|
"notification_type": "course_access_granted", |
||||
|
"name": "Доступ к курсу активирован", |
||||
|
"title_ru": "Доступ к курсу активирован", |
||||
|
"title_en": "Course Access Granted", |
||||
|
"body_ru": "Ваш доступ к курсу «{course_title}» успешно активирован.", |
||||
|
"body_en": "Your access to the course '{course_title}' has been activated.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {course_slug}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "course_registered", |
||||
|
"name": "Регистрация на курс", |
||||
|
"title_ru": "Успешная регистрация на курс", |
||||
|
"title_en": "Course Registration Successful", |
||||
|
"body_ru": "Вы успешно зарегистрировались на курс «{course_title}».", |
||||
|
"body_en": "You have successfully registered for the course '{course_title}'.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {course_slug}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "lesson_completed", |
||||
|
"name": "Урок завершен", |
||||
|
"title_ru": "Урок завершен", |
||||
|
"title_en": "Lesson Completed", |
||||
|
"body_ru": "Поздравляем! Вы завершили изучение урока «{lesson_title}».", |
||||
|
"body_en": "Congratulations! You completed the lesson '{lesson_title}'.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {lesson_title}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "course_completed", |
||||
|
"name": "Курс завершен", |
||||
|
"title_ru": "Курс завершен", |
||||
|
"title_en": "Course Completed", |
||||
|
"body_ru": "Отличная работа! Вы успешно завершили курс «{course_title}».", |
||||
|
"body_en": "Well done! You have completed the course '{course_title}'.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {course_slug}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "new_course_weekly", |
||||
|
"name": "Новый курс за неделю", |
||||
|
"title_ru": "Добавлен новый курс", |
||||
|
"title_en": "New Course Registered", |
||||
|
"body_ru": "Зарегистрирован новый курс «{course_title}». Вы можете записаться прямо сейчас.", |
||||
|
"body_en": "A new course '{course_title}' has been registered. You can enroll now.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {course_slug}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "low_quiz_result", |
||||
|
"name": "Предложение пересдать тест", |
||||
|
"title_ru": "Предложение пересдать тест", |
||||
|
"title_en": "Quiz Retake Suggestion", |
||||
|
"body_ru": "Вы набрали балл ниже проходного в тесте «{quiz_title}». Рекомендуем пройти его повторно.", |
||||
|
"body_en": "You scored below the passing threshold on quiz '{quiz_title}'. We suggest taking it again.", |
||||
|
"placeholders_info": "{quiz_title}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "live_class_rescheduled", |
||||
|
"name": "Время живого урока изменено", |
||||
|
"title_ru": "Время живого урока изменено", |
||||
|
"title_en": "Live Class Rescheduled", |
||||
|
"body_ru": "Время проведения живого урока по курсу «{course_title}» было изменено.", |
||||
|
"body_en": "The live class for '{course_title}' has been rescheduled.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {session_subject}, {session_start_time}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "live_class_cancelled", |
||||
|
"name": "Живой урок отменен", |
||||
|
"title_ru": "Живой урок отменен", |
||||
|
"title_en": "Live Class Cancelled", |
||||
|
"body_ru": "Живой урок по курсу «{course_title}» был отменен.", |
||||
|
"body_en": "The live class for '{course_title}' has been cancelled.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {session_subject}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "live_recording_available", |
||||
|
"name": "Доступна запись живого урока", |
||||
|
"title_ru": "Доступна запись урока", |
||||
|
"title_en": "Live Recording Available", |
||||
|
"body_ru": "Запись живого урока по курсу «{course_title}» уже доступна для просмотра.", |
||||
|
"body_en": "The recorded video of the live class for '{course_title}' is now available.", |
||||
|
"placeholders_info": "{course_title}, {course_name}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "missed_live_sessions", |
||||
|
"name": "Пропуск живых уроков", |
||||
|
"title_ru": "Пропуск живых уроков", |
||||
|
"title_en": "Missed Live Sessions", |
||||
|
"body_ru": "Вы пропустили 2 живых урока подряд по курсу «{course_title}». Рекомендуем посмотреть их в записи.", |
||||
|
"body_en": "You have missed 2 consecutive live sessions in the course '{course_title}'. We recommend watching the recordings.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "payment_successful", |
||||
|
"name": "Оплата успешна", |
||||
|
"title_ru": "Оплата успешна", |
||||
|
"title_en": "Payment Successful", |
||||
|
"body_ru": "Ваша оплата за курс «{course_title}» успешно проведена.", |
||||
|
"body_en": "Your payment for course '{course_title}' was successful.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "payment_failed", |
||||
|
"name": "Ошибка оплаты", |
||||
|
"title_ru": "Ошибка оплаты", |
||||
|
"title_en": "Payment Failed", |
||||
|
"body_ru": "Произошла ошибка при обработке вашей оплаты за курс «{course_title}».", |
||||
|
"body_en": "We encountered an issue processing your payment for '{course_title}'.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "refund_completed", |
||||
|
"name": "Возврат средств", |
||||
|
"title_ru": "Возврат средств выполнен", |
||||
|
"title_en": "Refund Completed", |
||||
|
"body_ru": "Возврат средств за курс «{course_title}» успешно завершен.", |
||||
|
"body_en": "A refund has been successfully completed for the course '{course_title}'.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "student_inactivity", |
||||
|
"name": "Мы скучаем по вам", |
||||
|
"title_ru": "Мы скучаем по вам!", |
||||
|
"title_en": "We Miss You!", |
||||
|
"body_ru": "Вы не изучали уроки за последнюю неделю. Ждем вас для продолжения обучения!", |
||||
|
"body_en": "You haven't taken any lessons in the last week. Come back and continue your learning journey!", |
||||
|
"placeholders_info": "{student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "teacher_reply", |
||||
|
"name": "Ответ преподавателя", |
||||
|
"title_ru": "Новое сообщение от преподавателя", |
||||
|
"title_en": "New Message from Teacher", |
||||
|
"body_ru": "Преподаватель ответил на ваше сообщение.", |
||||
|
"body_en": "The teacher has replied to your message.", |
||||
|
"placeholders_info": "{student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "teacher_reply_private", |
||||
|
"name": "Личное сообщение от преподавателя", |
||||
|
"title_ru": "Новое сообщение от преподавателя", |
||||
|
"title_en": "New Message from Teacher", |
||||
|
"body_ru": "Преподаватель ответил на ваше сообщение.", |
||||
|
"body_en": "The teacher has replied to your message.", |
||||
|
"placeholders_info": "{student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "teacher_reply_course", |
||||
|
"name": "Сообщение от преподавателя в чате курса", |
||||
|
"title_ru": "Сообщение от преподавателя в чате курса", |
||||
|
"title_en": "Teacher Post in Course Chat", |
||||
|
"body_ru": "Преподаватель опубликовал новое сообщение в чате курса.", |
||||
|
"body_en": "The teacher posted in the course chat.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "support_reply", |
||||
|
"name": "Ответ поддержки", |
||||
|
"title_ru": "Новое сообщение от поддержки", |
||||
|
"title_en": "New Message from Support", |
||||
|
"body_ru": "Служба поддержки ответила на ваше сообщение.", |
||||
|
"body_en": "The support agent has replied to your message.", |
||||
|
"placeholders_info": "{student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "certificate_issued", |
||||
|
"name": "Сертификат выдан", |
||||
|
"title_ru": "Сертификат выдан", |
||||
|
"title_en": "Certificate Issued", |
||||
|
"body_ru": "Ваш сертификат по курсу «{course_title}» успешно выпущен.", |
||||
|
"body_en": "Your certificate for the course '{course_title}' has been issued.", |
||||
|
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
||||
|
}, |
||||
|
{ |
||||
|
"notification_type": "live_class_reminder", |
||||
|
"name": "Напоминание о живом уроке", |
||||
|
"title_ru": "Напоминание о живом уроке", |
||||
|
"title_en": "Live Class Reminder", |
||||
|
"body_ru": "Напоминаем, что живой урок «{session_subject}» начнется в {session_start_time}.", |
||||
|
"body_en": "Reminder: The live class '{session_subject}' will start at {session_start_time}.", |
||||
|
"placeholders_info": "{session_subject}, {session_start_time}, {student_name}, {fullname}" |
||||
|
} |
||||
|
] |
||||
|
|
||||
|
count = 0 |
||||
|
for item in default_templates: |
||||
|
obj, created = NotificationTemplate.objects.update_or_create( |
||||
|
notification_type=item['notification_type'], |
||||
|
defaults={ |
||||
|
'name': item['name'], |
||||
|
'title_ru': item['title_ru'], |
||||
|
'title_en': item['title_en'], |
||||
|
'body_ru': item['body_ru'], |
||||
|
'body_en': item['body_en'], |
||||
|
'placeholders_info': item['placeholders_info'], |
||||
|
'is_active': True |
||||
|
} |
||||
|
) |
||||
|
if created: |
||||
|
count += 1 |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS(f"Seeding completed! Created {count} new templates, updated existing ones.")) |
||||
@ -0,0 +1,115 @@ |
|||||
|
import time |
||||
|
from django.core.management.base import BaseCommand |
||||
|
from django.contrib.auth import get_user_model |
||||
|
from apps.account.notification_service import create_and_send_notification |
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = 'Sends all non-live/non-chat test notifications to a specific user by email.' |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
email = '[email protected]' |
||||
|
User = get_user_model() |
||||
|
user = User.objects.filter(email=email).first() |
||||
|
|
||||
|
if not user: |
||||
|
self.stdout.write(self.style.ERROR(f"❌ User with email '{email}' not found.")) |
||||
|
return |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS(f"🚀 Found user: {user.username} (FCM token: {getattr(user, 'fcm', 'None')})")) |
||||
|
|
||||
|
if not getattr(user, 'fcm', None): |
||||
|
self.stdout.write(self.style.WARNING("⚠️ Warning: This user has no FCM token registered. Notifications will only be saved in the database.")) |
||||
|
|
||||
|
notifications_to_send = [ |
||||
|
# 1. Learning Process |
||||
|
{ |
||||
|
"name": "1. Course Registered", |
||||
|
"title_en": "Course Registration Successful", |
||||
|
"body_en": "You have successfully registered for the course 'Quranic Studies'.", |
||||
|
"title_fa": "ثبتنام دوره موفقیتآمیز بود", |
||||
|
"body_fa": "ثبتنام شما در دوره «مطالعات قرآنی» با موفقیت انجام شد.", |
||||
|
"data": {"type": "course_registered", "course_id": "1"} |
||||
|
}, |
||||
|
{ |
||||
|
"name": "2. Lesson Completed", |
||||
|
"title_en": "Lesson Completed", |
||||
|
"body_en": "Congratulations! You completed the lesson 'Introduction to Surah Al-Fatiha'.", |
||||
|
"title_fa": "درس به اتمام رسید", |
||||
|
"body_fa": "تبریک! شما درس «آشنایی با سوره فاتحه» را به پایان رساندید.", |
||||
|
"data": {"type": "lesson_completed", "course_id": "1", "lesson_id": "1"} |
||||
|
}, |
||||
|
{ |
||||
|
"name": "3. Course Completed", |
||||
|
"title_en": "Course Completed", |
||||
|
"body_en": "Well done! You have completed the course 'Quranic Studies'.", |
||||
|
"title_fa": "دوره به اتمام رسید", |
||||
|
"body_fa": "آفرین! شما دوره «مطالعات قرآنی» را با موفقیت به پایان رساندید.", |
||||
|
"data": {"type": "course_completed", "course_id": "1"} |
||||
|
}, |
||||
|
# 2. Quizzes and Progress |
||||
|
{ |
||||
|
"name": "4. Low Quiz Score Suggestion", |
||||
|
"title_en": "Quiz Retake Suggestion", |
||||
|
"body_en": "You scored below the passing threshold on quiz 'Final Exam'. We suggest taking it again to improve your score.", |
||||
|
"title_fa": "پیشنهاد شرکت مجدد در آزمون", |
||||
|
"body_fa": "امتیاز شما در آزمون «امتحان نهایی» کمتر از حد نصاب شده است. پیشنهاد میکنیم مجدداً در این آزمون شرکت کنید تا نمره خود را بهبود ببخشید.", |
||||
|
"data": {"type": "low_quiz_result", "quiz_id": "1", "days_since_failure": "2"} |
||||
|
}, |
||||
|
# 3. Transactions and Finance |
||||
|
{ |
||||
|
"name": "5. Payment Successful", |
||||
|
"title_en": "Payment Successful", |
||||
|
"body_en": "Your payment for course 'Quranic Studies' was successful. Thank you for your purchase!", |
||||
|
"title_fa": "پرداخت موفقیتآمیز", |
||||
|
"body_fa": "پرداخت شما برای دوره «مطالعات قرآنی» موفقیتآمیز بود. از خرید شما سپاسگزاریم!", |
||||
|
"data": {"type": "payment_successful", "course_id": "1"} |
||||
|
}, |
||||
|
{ |
||||
|
"name": "6. Payment Failed", |
||||
|
"title_en": "Payment Failed", |
||||
|
"body_en": "We encountered an issue processing your payment for 'Quranic Studies'. Please try again.", |
||||
|
"title_fa": "خطا در پرداخت", |
||||
|
"body_fa": "در پردازش پرداخت شما برای دوره «مطالعات قرآنی» مشکلی رخ داد. لطفاً دوباره تلاش کنید.", |
||||
|
"data": {"type": "payment_failed", "course_id": "1"} |
||||
|
}, |
||||
|
{ |
||||
|
"name": "7. Refund Completed", |
||||
|
"title_en": "Refund Completed", |
||||
|
"body_en": "A refund has been successfully completed for the course 'Quranic Studies'.", |
||||
|
"title_fa": "بازگشت وجه انجام شد", |
||||
|
"body_fa": "بازگشت وجه برای دوره «مطالعات قرآنی» با موفقیت انجام شد.", |
||||
|
"data": {"type": "refund_completed", "course_id": "1"} |
||||
|
}, |
||||
|
# 4. Retention and Motivation |
||||
|
{ |
||||
|
"name": "8. Student Inactivity", |
||||
|
"title_en": "We Miss You!", |
||||
|
"body_en": "You haven't taken any lessons in the last week. Come back and continue your learning journey!", |
||||
|
"title_fa": "دلمان برایتان تنگ شده!", |
||||
|
"body_fa": "در یک هفته گذشته هیچ درسی را مطالعه نکردهاید. منتظرتان هستیم تا مسیر یادگیری خود را ادامه دهید!", |
||||
|
"data": {"type": "student_inactivity"} |
||||
|
} |
||||
|
] |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS(f"Sending {len(notifications_to_send)} notifications (with a 2-second interval)...")) |
||||
|
|
||||
|
for index, item in enumerate(notifications_to_send, 1): |
||||
|
self.stdout.write(f"[{index}/{len(notifications_to_send)}] Sending notification: '{item['name']}'...") |
||||
|
try: |
||||
|
create_and_send_notification( |
||||
|
user=user, |
||||
|
title_en=item['title_en'], |
||||
|
body_en=item['body_en'], |
||||
|
title_fa=item['title_fa'], |
||||
|
body_fa=item['body_fa'], |
||||
|
service='imam-javad', |
||||
|
data=item['data'] |
||||
|
) |
||||
|
self.stdout.write(self.style.SUCCESS(f" Sent successfully!")) |
||||
|
except Exception as e: |
||||
|
self.stdout.write(self.style.ERROR(f" Failed to send: {e}")) |
||||
|
|
||||
|
if index < len(notifications_to_send): |
||||
|
time.sleep(2) |
||||
|
|
||||
|
self.stdout.write(self.style.SUCCESS("🎉 All test notifications processed successfully!")) |
||||
@ -0,0 +1,55 @@ |
|||||
|
import secrets |
||||
|
from django.core.management.base import BaseCommand |
||||
|
from django.db.models import Q |
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
help = 'Sets a new plain/hashed password and encrypts it for active users who lack an encrypted password.' |
||||
|
|
||||
|
def add_arguments(self, parser): |
||||
|
parser.add_argument( |
||||
|
'--password', |
||||
|
type=str, |
||||
|
help='Specific password to set for all matched users. If not provided, a random 8-character password will be generated for each user.' |
||||
|
) |
||||
|
parser.add_argument( |
||||
|
'--dry-run', |
||||
|
action='store_true', |
||||
|
help='Run the command without saving changes to the database.' |
||||
|
) |
||||
|
|
||||
|
def handle(self, *args, **options): |
||||
|
password_input = options['password'] |
||||
|
dry_run = options['dry_run'] |
||||
|
|
||||
|
# Find active users without an encrypted password |
||||
|
users = User.objects.filter( |
||||
|
Q(password_enc='') | Q(password_enc__isnull=True), |
||||
|
is_active=True |
||||
|
) |
||||
|
|
||||
|
if not users.exists(): |
||||
|
self.stdout.write(self.style.SUCCESS("No active users found without an encrypted password.")) |
||||
|
return |
||||
|
|
||||
|
self.stdout.write(f"Found {users.count()} active users without an encrypted password.") |
||||
|
|
||||
|
updated_count = 0 |
||||
|
for user in users: |
||||
|
# Generate or use the provided password |
||||
|
new_password = password_input if password_input else secrets.token_urlsafe(6)[:8] |
||||
|
|
||||
|
self.stdout.write(f"User: {user.email or user.fullname} (ID: {user.id}) -> Password: {new_password}") |
||||
|
|
||||
|
if not dry_run: |
||||
|
user.set_password(new_password) |
||||
|
user.set_plain_password(new_password) |
||||
|
user.save() |
||||
|
|
||||
|
updated_count += 1 |
||||
|
|
||||
|
if dry_run: |
||||
|
self.stdout.write(self.style.WARNING(f"[DRY-RUN] Would have updated {updated_count} users.")) |
||||
|
else: |
||||
|
self.stdout.write(self.style.SUCCESS(f"Successfully updated {updated_count} users.")) |
||||
@ -0,0 +1,37 @@ |
|||||
|
from django.core.management.base import BaseCommand |
||||
|
from rest_framework.test import APIClient |
||||
|
from apps.account.models import User # Your user model |
||||
|
import json |
||||
|
|
||||
|
class Command(BaseCommand): |
||||
|
def handle(self, *args, **options): |
||||
|
client = APIClient() |
||||
|
|
||||
|
# Step 1: Create guest token |
||||
|
print("\n📝 Step 1: Creating guest token...") |
||||
|
response = client.post('/api/account/web/guest/', |
||||
|
data=json.dumps({"timezone": "UTC", "user_agent": "test"}), |
||||
|
content_type='application/json' |
||||
|
) |
||||
|
print(f"Status: {response.status_code}") |
||||
|
if response.status_code == 200: |
||||
|
print(f"Response: {response.json()}") |
||||
|
token = response.json()['token'] |
||||
|
else: |
||||
|
print(f"Error Response: {response.content.decode('utf-8')}") |
||||
|
print("❌ Failed to create token!") |
||||
|
return |
||||
|
print(f"✅ Token created: {token[:20]}...") |
||||
|
|
||||
|
# Step 2: Test authentication with token |
||||
|
print("\n🔐 Step 2: Testing token authentication...") |
||||
|
client.credentials(HTTP_AUTHORIZATION=f'Token {token}') |
||||
|
|
||||
|
response = client.get('/api/library/books/') |
||||
|
print(f"Status: {response.status_code}") |
||||
|
if response.status_code == 200: |
||||
|
print(f"Response: {response.json()}") |
||||
|
print("✅ Token authentication works!") |
||||
|
else: |
||||
|
print(f"Error Response: {response.content.decode('utf-8')}") |
||||
|
print("❌ Token authentication failed!") |
||||
@ -0,0 +1,41 @@ |
|||||
|
from django.contrib.auth.models import BaseUserManager, Group |
||||
|
from django.db.models import Manager |
||||
|
|
||||
|
|
||||
|
class UserManager(BaseUserManager): |
||||
|
def create_user(self, email: str = None, password: str = None, **extra_fields): |
||||
|
if not email: |
||||
|
raise ValueError("The Email field must be set") |
||||
|
email = self.normalize_email(email) |
||||
|
user = self.model(email=email, **extra_fields) |
||||
|
user.set_password(password) |
||||
|
user.save(using=self._db) |
||||
|
return user |
||||
|
|
||||
|
def create_superuser(self, email, password, **extra_fields): |
||||
|
extra_fields.setdefault('is_staff', True) |
||||
|
extra_fields.setdefault('is_superuser', True) |
||||
|
extra_fields.setdefault('is_active', True) |
||||
|
extra_fields.setdefault('user_type', 'super_admin') |
||||
|
|
||||
|
if extra_fields.get('is_staff') is not True: |
||||
|
raise ValueError('Superuser must have is_staff=True.') |
||||
|
if extra_fields.get('is_superuser') is not True: |
||||
|
raise ValueError('Superuser must have is_superuser=True.') |
||||
|
|
||||
|
return self.create_user(email=email, password=password, **extra_fields) |
||||
|
|
||||
|
|
||||
|
class ClientUserManager(UserManager): |
||||
|
def get_queryset(self): |
||||
|
return super().get_queryset().filter(user_type="client") |
||||
|
|
||||
|
|
||||
|
class AdminUserManager(UserManager): |
||||
|
def get_queryset(self): |
||||
|
return super().get_queryset().filter(user_type="admin") |
||||
|
|
||||
|
|
||||
|
class SuperAdminUserManager(UserManager): |
||||
|
def get_queryset(self): |
||||
|
return super().get_queryset().filter(user_type="super_admin") |
||||
@ -0,0 +1,176 @@ |
|||||
|
import django.contrib.auth.models |
||||
|
from django.conf import settings |
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
import django.utils.timezone |
||||
|
import phonenumber_field.modelfields |
||||
|
import utils.validators |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
('auth', '0012_alter_user_first_name_max_length'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='User', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('password', models.CharField(max_length=128, verbose_name='password')), |
||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), |
||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), |
||||
|
('username', models.CharField(blank=True, error_messages={'unique': 'A user with that username already exists.'}, max_length=150, null=True, unique=True, verbose_name='Username')), |
||||
|
('email', models.EmailField(blank=True, error_messages={'unique': 'A user with that email already exists.'}, help_text='User primary email address.', max_length=254, null=True, unique=True, verbose_name='Email Address')), |
||||
|
('fullname', models.CharField(blank=True, help_text='Full name of the user.', max_length=255, null=True, verbose_name='Full Name')), |
||||
|
('birthdate', models.DateField(blank=True, null=True, verbose_name='Birthdate')), |
||||
|
('avatar', models.ImageField(blank=True, max_length=512, null=True, upload_to='users/avatars/%Y/%m/', verbose_name='Avatar')), |
||||
|
('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='e.g., +1 555 1234567', max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='Phone Number')), |
||||
|
('language', models.CharField(blank=True, default='en', max_length=10, null=True, verbose_name='Language')), |
||||
|
('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], max_length=20, null=True, verbose_name='Gender')), |
||||
|
('user_type', models.CharField(choices=[('client', 'Client'), ('admin', 'Admin'), ('super_admin', 'Super Admin')], default='client', max_length=20, verbose_name='User Type')), |
||||
|
('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='Date Joined')), |
||||
|
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')), |
||||
|
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='Country')), |
||||
|
('device_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Device ID')), |
||||
|
('device_os', models.CharField(blank=True, choices=[('android', 'Android'), ('apple', 'Apple iOS'), ('web', 'Web')], max_length=16, null=True, verbose_name='Device OS')), |
||||
|
('user_agent', models.TextField(blank=True, null=True, verbose_name='User Agent')), |
||||
|
('client_ip', models.CharField(blank=True, max_length=64, null=True, verbose_name='Client IP')), |
||||
|
('fcm', models.CharField(blank=True, max_length=512, null=True, verbose_name='FCM Token')), |
||||
|
('slug', models.SlugField(blank=True, max_length=255, null=True, unique=True, verbose_name='Slug')), |
||||
|
('is_staff', models.BooleanField(default=False, verbose_name='Is Staff')), |
||||
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active.', verbose_name='Active')), |
||||
|
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), |
||||
|
('info', models.TextField(blank=True, null=True, verbose_name='Bio / Info')), |
||||
|
('skill', models.CharField(blank=True, max_length=512, null=True, verbose_name='Skill / Role')), |
||||
|
('password_enc', models.CharField(blank=True, default='', help_text='Encrypted copy of password for displaying in the admin panel if required.', max_length=512, verbose_name='Encrypted Password')), |
||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), |
||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'User', |
||||
|
'verbose_name_plural': 'Users', |
||||
|
'ordering': ('-id',), |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='NotificationTemplate', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('notification_type', models.CharField(max_length=50, unique=True, verbose_name='Notification Type')), |
||||
|
('name', models.CharField(max_length=100, verbose_name='Name')), |
||||
|
('is_active', models.BooleanField(default=True, verbose_name='Is Active')), |
||||
|
('title', models.CharField(default='', max_length=255, verbose_name='Title')), |
||||
|
('body', models.TextField(default='', max_length=1024, verbose_name='Body')), |
||||
|
('placeholders_info', models.CharField(blank=True, max_length=255, null=True, verbose_name='Allowed Placeholders Description')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Notification Template', |
||||
|
'verbose_name_plural': 'Notification Templates', |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='Notification', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('title', models.CharField(max_length=255, verbose_name='Title')), |
||||
|
('message', models.TextField(max_length=1024, verbose_name='Message')), |
||||
|
('is_read', models.BooleanField(default=False, verbose_name='Is Read')), |
||||
|
('notification_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='Notification Type')), |
||||
|
('action', models.CharField(default='navigate', max_length=50, verbose_name='Action')), |
||||
|
('navigate_to', models.CharField(blank=True, max_length=255, null=True, verbose_name='Navigate To')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Notification', |
||||
|
'verbose_name_plural': 'Notifications', |
||||
|
'ordering': ('-created_at',), |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='LoginHistory', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('lat', models.FloatField(blank=True, null=True, verbose_name='Latitude')), |
||||
|
('lon', models.FloatField(blank=True, null=True, verbose_name='Longitude')), |
||||
|
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='Country')), |
||||
|
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')), |
||||
|
('ip', models.CharField(blank=True, max_length=255, null=True, verbose_name='IP Address')), |
||||
|
('timezone', models.CharField(blank=True, max_length=100, null=True, verbose_name='Timezone')), |
||||
|
('user_agent', models.TextField(blank=True, null=True, verbose_name='User Agent')), |
||||
|
('device_os', models.CharField(blank=True, max_length=16, null=True, verbose_name='Device OS')), |
||||
|
('at_time', models.DateTimeField(auto_now_add=True, verbose_name='Timestamp')), |
||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_history', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Login History', |
||||
|
'verbose_name_plural': 'Login Histories', |
||||
|
'ordering': ('-at_time',), |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='LocationHistory', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('lat', models.FloatField(blank=True, null=True, verbose_name='Latitude')), |
||||
|
('lon', models.FloatField(blank=True, null=True, verbose_name='Longitude')), |
||||
|
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='Country')), |
||||
|
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')), |
||||
|
('selected_manually', models.BooleanField(default=False, verbose_name='Selected Manually')), |
||||
|
('ip', models.CharField(blank=True, max_length=255, null=True, verbose_name='IP Address')), |
||||
|
('timezone', models.CharField(blank=True, max_length=60, null=True, verbose_name='Timezone')), |
||||
|
('at_time', models.DateTimeField(auto_now_add=True, verbose_name='Timestamp')), |
||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='location_history', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Location History', |
||||
|
'verbose_name_plural': 'Location History', |
||||
|
'ordering': ('-at_time',), |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='ClientUser', |
||||
|
fields=[ |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Client User', |
||||
|
'verbose_name_plural': 'Client Users', |
||||
|
'ordering': ('-id',), |
||||
|
'proxy': True, |
||||
|
'indexes': [], |
||||
|
'constraints': [], |
||||
|
}, |
||||
|
bases=('account.user',), |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='AdminUser', |
||||
|
fields=[ |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Admin User', |
||||
|
'verbose_name_plural': 'Admin Users', |
||||
|
'proxy': True, |
||||
|
'indexes': [], |
||||
|
'constraints': [], |
||||
|
}, |
||||
|
bases=('account.user',), |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='SuperAdminUser', |
||||
|
fields=[ |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Super Admin User', |
||||
|
'verbose_name_plural': 'Super Admin Users', |
||||
|
'proxy': True, |
||||
|
'indexes': [], |
||||
|
'constraints': [], |
||||
|
}, |
||||
|
bases=('account.user',), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,3 @@ |
|||||
|
from .user import * |
||||
|
from .groups import * |
||||
|
from .notification import * |
||||
@ -0,0 +1,51 @@ |
|||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from apps.account.models.user import User |
||||
|
from apps.account.manager import ClientUserManager, AdminUserManager, SuperAdminUserManager |
||||
|
from django.contrib.auth.models import Group |
||||
|
|
||||
|
|
||||
|
class ClientUser(User): |
||||
|
objects = ClientUserManager() |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
self.user_type = User.UserType.CLIENT |
||||
|
super().save(*args, **kwargs) |
||||
|
group, _ = Group.objects.get_or_create(name="Client Group") |
||||
|
self.groups.add(group) |
||||
|
|
||||
|
class Meta: |
||||
|
proxy = True |
||||
|
verbose_name = _('Client User') |
||||
|
verbose_name_plural = _('Client Users') |
||||
|
ordering = ('-id',) |
||||
|
|
||||
|
|
||||
|
class AdminUser(User): |
||||
|
objects = AdminUserManager() |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
self.user_type = User.UserType.ADMIN |
||||
|
self.is_staff = True |
||||
|
super().save(*args, **kwargs) |
||||
|
group, _ = Group.objects.get_or_create(name="Admin Group") |
||||
|
self.groups.add(group) |
||||
|
|
||||
|
class Meta: |
||||
|
proxy = True |
||||
|
verbose_name = _("Admin User") |
||||
|
verbose_name_plural = _("Admin Users") |
||||
|
|
||||
|
|
||||
|
class SuperAdminUser(User): |
||||
|
objects = SuperAdminUserManager() |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
self.user_type = User.UserType.SUPER_ADMIN |
||||
|
self.is_staff = True |
||||
|
self.is_superuser = True |
||||
|
super().save(*args, **kwargs) |
||||
|
|
||||
|
class Meta: |
||||
|
proxy = True |
||||
|
verbose_name = _("Super Admin User") |
||||
|
verbose_name_plural = _("Super Admin Users") |
||||
@ -0,0 +1,53 @@ |
|||||
|
from django.db import models |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
|
||||
|
class Notification(models.Model): |
||||
|
title = models.CharField(max_length=255, verbose_name=_('Title')) |
||||
|
message = models.TextField(max_length=1024, verbose_name=_('Message')) |
||||
|
user = models.ForeignKey( |
||||
|
"account.User", |
||||
|
on_delete=models.CASCADE, |
||||
|
verbose_name=_('User'), |
||||
|
related_name='notifications' |
||||
|
) |
||||
|
is_read = models.BooleanField(default=False, verbose_name=_('Is Read')) |
||||
|
notification_type = models.CharField( |
||||
|
max_length=50, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('Notification Type') |
||||
|
) |
||||
|
action = models.CharField(max_length=50, default='navigate', verbose_name=_('Action')) |
||||
|
navigate_to = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Navigate To')) |
||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) |
||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Notification') |
||||
|
verbose_name_plural = _('Notifications') |
||||
|
ordering = ('-created_at',) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.user} - {self.title}" |
||||
|
|
||||
|
|
||||
|
class NotificationTemplate(models.Model): |
||||
|
notification_type = models.CharField(max_length=50, unique=True, verbose_name=_('Notification Type')) |
||||
|
name = models.CharField(max_length=100, verbose_name=_('Name')) |
||||
|
is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) |
||||
|
title = models.CharField(max_length=255, default="", verbose_name=_('Title')) |
||||
|
body = models.TextField(max_length=1024, default="", verbose_name=_('Body')) |
||||
|
placeholders_info = models.CharField( |
||||
|
max_length=255, |
||||
|
blank=True, |
||||
|
null=True, |
||||
|
verbose_name=_('Allowed Placeholders Description') |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Notification Template') |
||||
|
verbose_name_plural = _('Notification Templates') |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.name} ({self.notification_type})" |
||||
@ -0,0 +1,238 @@ |
|||||
|
import random |
||||
|
import base64 |
||||
|
import hashlib |
||||
|
from cryptography.fernet import Fernet |
||||
|
from django.conf import settings |
||||
|
from django.contrib.auth.models import AbstractUser |
||||
|
from django.db import models |
||||
|
from django.utils.text import slugify |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from django.utils import timezone |
||||
|
from phonenumber_field.modelfields import PhoneNumberField |
||||
|
from utils.validators import validate_possible_number |
||||
|
from apps.account.manager import UserManager |
||||
|
|
||||
|
|
||||
|
class User(AbstractUser): |
||||
|
class DeviceOs(models.TextChoices): |
||||
|
android = 'android', _('Android') |
||||
|
apple = 'apple', _('Apple iOS') |
||||
|
web = 'web', _('Web') |
||||
|
|
||||
|
class UserType(models.TextChoices): |
||||
|
CLIENT = 'client', _('Client') |
||||
|
ADMIN = 'admin', _('Admin') |
||||
|
SUPER_ADMIN = 'super_admin', _('Super Admin') |
||||
|
|
||||
|
class GenderChoices(models.TextChoices): |
||||
|
MALE = 'male', _('Male') |
||||
|
FEMALE = 'female', _('Female') |
||||
|
OTHER = 'other', _('Other') |
||||
|
|
||||
|
last_name = None |
||||
|
first_name = None |
||||
|
username = models.CharField( |
||||
|
unique=True, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
max_length=150, |
||||
|
verbose_name=_("Username"), |
||||
|
error_messages={'unique': _("A user with that username already exists.")} |
||||
|
) |
||||
|
email = models.EmailField( |
||||
|
unique=True, |
||||
|
verbose_name=_("Email Address"), |
||||
|
help_text=_("User primary email address."), |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
error_messages={'unique': _("A user with that email already exists.")} |
||||
|
) |
||||
|
fullname = models.CharField( |
||||
|
max_length=255, |
||||
|
verbose_name=_("Full Name"), |
||||
|
help_text=_("Full name of the user."), |
||||
|
null=True, |
||||
|
blank=True |
||||
|
) |
||||
|
birthdate = models.DateField(verbose_name=_('Birthdate'), null=True, blank=True) |
||||
|
avatar = models.ImageField( |
||||
|
max_length=512, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
upload_to='users/avatars/%Y/%m/', |
||||
|
verbose_name=_('Avatar') |
||||
|
) |
||||
|
phone_number = PhoneNumberField( |
||||
|
validators=[validate_possible_number], |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('Phone Number'), |
||||
|
help_text=_("e.g., +1 555 1234567") |
||||
|
) |
||||
|
language = models.CharField( |
||||
|
max_length=10, |
||||
|
default='en', |
||||
|
choices=settings.LANGUAGES, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('Language') |
||||
|
) |
||||
|
gender = models.CharField( |
||||
|
max_length=20, |
||||
|
choices=GenderChoices.choices, |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
verbose_name=_('Gender') |
||||
|
) |
||||
|
user_type = models.CharField( |
||||
|
max_length=20, |
||||
|
choices=UserType.choices, |
||||
|
default=UserType.CLIENT, |
||||
|
verbose_name=_("User Type") |
||||
|
) |
||||
|
date_joined = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_("Date Joined") |
||||
|
) |
||||
|
|
||||
|
city = models.CharField(verbose_name=_('City'), max_length=255, null=True, blank=True) |
||||
|
country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) |
||||
|
|
||||
|
device_id = models.CharField(verbose_name=_('Device ID'), max_length=255, null=True, blank=True) |
||||
|
device_os = models.CharField(choices=DeviceOs.choices, null=True, blank=True, max_length=16, verbose_name=_('Device OS')) |
||||
|
user_agent = models.TextField(verbose_name=_('User Agent'), null=True, blank=True) |
||||
|
client_ip = models.CharField(max_length=64, verbose_name=_('Client IP'), null=True, blank=True) |
||||
|
|
||||
|
fcm = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('FCM Token')) |
||||
|
slug = models.SlugField(max_length=255, unique=True, null=True, blank=True, verbose_name=_('Slug')) |
||||
|
is_staff = models.BooleanField(default=False, verbose_name=_('Is Staff')) |
||||
|
is_active = models.BooleanField( |
||||
|
default=True, |
||||
|
verbose_name=_("Active"), |
||||
|
help_text=_("Designates whether this user should be treated as active.") |
||||
|
) |
||||
|
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name=_('Deleted At')) |
||||
|
info = models.TextField(verbose_name=_("Bio / Info"), null=True, blank=True) |
||||
|
skill = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('Skill / Role')) |
||||
|
password_enc = models.CharField( |
||||
|
max_length=512, |
||||
|
blank=True, |
||||
|
default='', |
||||
|
verbose_name=_("Encrypted Password"), |
||||
|
help_text=_("Encrypted copy of password for displaying in the admin panel if required.") |
||||
|
) |
||||
|
|
||||
|
objects = UserManager() |
||||
|
|
||||
|
EMAIL_FIELD = "email" |
||||
|
USERNAME_FIELD = "email" |
||||
|
REQUIRED_FIELDS = [] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return self.email or self.fullname or self.username or f"User #{self.id}" |
||||
|
|
||||
|
def soft_delete(self): |
||||
|
self.deleted_at = timezone.now() |
||||
|
self.is_active = False |
||||
|
self.fullname = f'{self.fullname}:deleted' if self.fullname else 'deleted_user' |
||||
|
number = str(random.randint(1000000000, 9999999999)) |
||||
|
if self.phone_number: |
||||
|
self.phone_number = f'{self.phone_number}:deleted{number}' |
||||
|
if self.email: |
||||
|
self.email = f'{self.email}:deleted{number}' |
||||
|
if self.device_id: |
||||
|
self.device_id = f'{self.device_id}:deleted{number}' |
||||
|
self.save() |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
if not self.username and self.email: |
||||
|
self.username = self.email |
||||
|
elif self.email and User.objects.filter(username=self.email).exclude(pk=self.pk).exists(): |
||||
|
self.username = f'{self.email}:{self.id or random.randint(1000, 9999)}' |
||||
|
|
||||
|
return super().save(*args, **kwargs) |
||||
|
|
||||
|
def get_full_name(self): |
||||
|
return self.fullname or self.email or "" |
||||
|
|
||||
|
@property |
||||
|
def is_guest(self): |
||||
|
return self.email is None |
||||
|
|
||||
|
def has_role(self, role_name): |
||||
|
return self.groups.filter(name__iexact=f"{role_name} Group").exists() or self.user_type == role_name |
||||
|
|
||||
|
def _get_fernet(self): |
||||
|
key_bytes = settings.SECRET_KEY.encode('utf-8') |
||||
|
hashed_key = hashlib.sha256(key_bytes).digest() |
||||
|
fernet_key = base64.urlsafe_b64encode(hashed_key) |
||||
|
return Fernet(fernet_key) |
||||
|
|
||||
|
def set_plain_password(self, plain_password): |
||||
|
if not plain_password: |
||||
|
self.password_enc = '' |
||||
|
return |
||||
|
try: |
||||
|
password_bytes = plain_password.encode('utf-8') |
||||
|
fernet = self._get_fernet() |
||||
|
encrypted_token = fernet.encrypt(password_bytes) |
||||
|
self.password_enc = encrypted_token.decode('utf-8') |
||||
|
except Exception: |
||||
|
self.password_enc = '' |
||||
|
|
||||
|
def get_plain_password(self): |
||||
|
if not self.password_enc: |
||||
|
return '' |
||||
|
try: |
||||
|
encrypted_bytes = self.password_enc.encode('utf-8') |
||||
|
fernet = self._get_fernet() |
||||
|
decrypted_bytes = fernet.decrypt(encrypted_bytes) |
||||
|
return decrypted_bytes.decode('utf-8') |
||||
|
except Exception: |
||||
|
return '' |
||||
|
|
||||
|
class Meta: |
||||
|
ordering = ("-id",) |
||||
|
verbose_name = _("User") |
||||
|
verbose_name_plural = _("Users") |
||||
|
|
||||
|
|
||||
|
class LoginHistory(models.Model): |
||||
|
user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='login_history', verbose_name=_('User')) |
||||
|
lat = models.FloatField(verbose_name=_('Latitude'), null=True, blank=True) |
||||
|
lon = models.FloatField(verbose_name=_('Longitude'), null=True, blank=True) |
||||
|
country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) |
||||
|
city = models.CharField(max_length=255, verbose_name=_('City'), null=True, blank=True) |
||||
|
ip = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('IP Address')) |
||||
|
timezone = models.CharField(max_length=100, null=True, blank=True, verbose_name=_('Timezone')) |
||||
|
user_agent = models.TextField(verbose_name=_('User Agent'), null=True, blank=True) |
||||
|
device_os = models.CharField(max_length=16, null=True, blank=True, verbose_name=_('Device OS')) |
||||
|
at_time = models.DateTimeField(auto_now_add=True, verbose_name=_('Timestamp')) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Login History') |
||||
|
verbose_name_plural = _('Login Histories') |
||||
|
ordering = ('-at_time',) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.user} - {self.ip} ({self.at_time})" |
||||
|
|
||||
|
|
||||
|
class LocationHistory(models.Model): |
||||
|
user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='location_history', verbose_name=_('User')) |
||||
|
lat = models.FloatField(verbose_name=_('Latitude'), null=True, blank=True) |
||||
|
lon = models.FloatField(verbose_name=_('Longitude'), null=True, blank=True) |
||||
|
country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) |
||||
|
city = models.CharField(max_length=255, verbose_name=_('City'), null=True, blank=True) |
||||
|
selected_manually = models.BooleanField(default=False, verbose_name=_('Selected Manually')) |
||||
|
ip = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('IP Address')) |
||||
|
timezone = models.CharField(null=True, blank=True, max_length=60, verbose_name=_('Timezone')) |
||||
|
at_time = models.DateTimeField(auto_now_add=True, verbose_name=_('Timestamp')) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Location History') |
||||
|
verbose_name_plural = _('Location History') |
||||
|
ordering = ('-at_time',) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f"{self.user} - {self.city}, {self.country}" |
||||
@ -0,0 +1,119 @@ |
|||||
|
import asyncio |
||||
|
import logging |
||||
|
import string |
||||
|
from apps.account.models import Notification, NotificationTemplate |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
class SafeFormatter(string.Formatter): |
||||
|
def get_value(self, key, args, kwargs): |
||||
|
if isinstance(key, str): |
||||
|
if key not in kwargs: |
||||
|
return f"{{{key}}}" |
||||
|
return kwargs[key] |
||||
|
return super().get_value(key, args, kwargs) |
||||
|
|
||||
|
def get_template_context(user, notification_type, data): |
||||
|
""" |
||||
|
Builds a dynamic context dictionary containing user info to format templates. |
||||
|
""" |
||||
|
context = { |
||||
|
'student_name': getattr(user, 'fullname', user.username) or user.username, |
||||
|
'fullname': getattr(user, 'fullname', user.username) or user.username, |
||||
|
'username': user.username, |
||||
|
} |
||||
|
return context |
||||
|
|
||||
|
def resolve_notification_route(notification_type, data): |
||||
|
""" |
||||
|
Resolves standard GoRouter paths based on notification type and payload data. |
||||
|
""" |
||||
|
return None |
||||
|
|
||||
|
def create_and_send_notification(user, title_en, body_en, title_fa=None, body_fa=None, service='imam-javad', |
||||
|
data=None, notification_type=None, action='navigate', navigate_to=None, |
||||
|
title_ru=None, body_ru=None): |
||||
|
""" |
||||
|
Creates a Notification record in the database and sends a push notification to FCM. |
||||
|
Loads and renders the NotificationTemplate from the database if present. |
||||
|
If the template is inactive, the notification is discarded. |
||||
|
""" |
||||
|
# Map Persian arguments to Russian to preserve backward compatibility with existing signals/tasks |
||||
|
title_ru = title_ru or title_fa |
||||
|
body_ru = body_ru or body_fa |
||||
|
|
||||
|
# Auto-resolve notification type from data payload if not provided |
||||
|
if not notification_type and isinstance(data, dict): |
||||
|
notification_type = data.get('type') |
||||
|
|
||||
|
# 1. Check for template settings in the database |
||||
|
if notification_type: |
||||
|
template = NotificationTemplate.objects.filter(notification_type=notification_type).first() |
||||
|
if template: |
||||
|
if not template.is_active: |
||||
|
logger.info(f"Notification type '{notification_type}' is disabled via database template settings.") |
||||
|
return None |
||||
|
# Override templates with database values |
||||
|
title_ru = template.title_ru |
||||
|
title_en = template.title_en |
||||
|
body_ru = template.body_ru |
||||
|
body_en = template.body_en |
||||
|
|
||||
|
# 2. Render templates safely with dynamic context |
||||
|
context = get_template_context(user, notification_type, data) |
||||
|
formatter = SafeFormatter() |
||||
|
|
||||
|
try: |
||||
|
title_ru = formatter.format(title_ru, **context) if title_ru else "" |
||||
|
title_en = formatter.format(title_en, **context) if title_en else "" |
||||
|
body_ru = formatter.format(body_ru, **context) if body_ru else "" |
||||
|
body_en = formatter.format(body_en, **context) if body_en else "" |
||||
|
except Exception as e: |
||||
|
logger.error(f"Error formatting templates: {e}") |
||||
|
|
||||
|
# Determine user localized text |
||||
|
lang = getattr(user, 'language', None) |
||||
|
lang_code = lang.code if lang and hasattr(lang, 'code') else 'ru' |
||||
|
|
||||
|
title = title_ru if lang_code == 'ru' else title_en |
||||
|
message = body_ru if lang_code == 'ru' else body_en |
||||
|
|
||||
|
# Auto-resolve GoRouter navigation route if not provided |
||||
|
if not navigate_to and notification_type: |
||||
|
navigate_to = resolve_notification_route(notification_type, data) |
||||
|
|
||||
|
# Save to database |
||||
|
from apps.account.tasks import send_notification |
||||
|
notif = Notification.objects.create( |
||||
|
user=user, |
||||
|
title=title, |
||||
|
message=message, |
||||
|
service=service, |
||||
|
notification_type=notification_type, |
||||
|
action=action, |
||||
|
navigate_to=navigate_to |
||||
|
) |
||||
|
|
||||
|
fcm_token = getattr(user, 'fcm', None) |
||||
|
if fcm_token: |
||||
|
# Prepare the payload including the new routing properties |
||||
|
fcm_data = dict(data or {}) |
||||
|
fcm_data.setdefault('type', notification_type or '') |
||||
|
fcm_data.setdefault('action', action or '') |
||||
|
fcm_data.setdefault('navigate_to', navigate_to or '') |
||||
|
|
||||
|
try: |
||||
|
try: |
||||
|
loop = asyncio.get_event_loop() |
||||
|
except RuntimeError: |
||||
|
loop = asyncio.new_event_loop() |
||||
|
asyncio.set_event_loop(loop) |
||||
|
|
||||
|
if loop.is_running(): |
||||
|
loop.create_task(send_notification([fcm_token], title, message, fcm_data)) |
||||
|
else: |
||||
|
loop.run_until_complete(send_notification([fcm_token], title, message, fcm_data)) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Failed to send push notification via FCM: {e}") |
||||
|
|
||||
|
return notif |
||||
@ -0,0 +1,56 @@ |
|||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
from rest_framework.permissions import BasePermission, SAFE_METHODS |
||||
|
|
||||
|
|
||||
|
class IsActiveUser(BasePermission): |
||||
|
|
||||
|
def has_permission(self, request, view): |
||||
|
return request.user and request.user.is_active |
||||
|
|
||||
|
|
||||
|
class IsSuperAdmin(BasePermission): |
||||
|
""" |
||||
|
Allows access to super admins and admins with full panel privileges. |
||||
|
""" |
||||
|
def has_permission(self, request, view): |
||||
|
return ( |
||||
|
request.user and |
||||
|
request.user.is_authenticated and |
||||
|
(request.user.is_super_admin_panel_user() or request.user.is_admin_panel_user()) |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class IsPanelUser(BasePermission): |
||||
|
""" |
||||
|
Allows access to super admins, admins, and professors. |
||||
|
""" |
||||
|
def has_permission(self, request, view): |
||||
|
return ( |
||||
|
request.user and |
||||
|
request.user.is_authenticated and |
||||
|
request.user.can_access_admin_panel() |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class IsSuperAdminOrReadOnlyForProfessor(BasePermission): |
||||
|
""" |
||||
|
Allows full read-write access to super admins and admins, |
||||
|
but only read-only (GET, HEAD, OPTIONS) access to professors. |
||||
|
""" |
||||
|
def has_permission(self, request, view): |
||||
|
if not request.user or not request.user.is_authenticated or not request.user.is_active: |
||||
|
return False |
||||
|
|
||||
|
# Super admin / admin can do everything |
||||
|
if request.user.is_super_admin_panel_user() or request.user.is_admin_panel_user(): |
||||
|
return True |
||||
|
|
||||
|
# Professor has read-only access |
||||
|
if request.user.is_professor_panel_user(): |
||||
|
return request.method in SAFE_METHODS |
||||
|
|
||||
|
return False |
||||
@ -0,0 +1,4 @@ |
|||||
|
from .user import * |
||||
|
from .notification import * |
||||
|
from .auth import * |
||||
|
from .location_history import * |
||||
@ -0,0 +1,11 @@ |
|||||
|
from rest_framework import serializers |
||||
|
|
||||
|
|
||||
|
class ExchangeTokenSerializer(serializers.Serializer): |
||||
|
temp_token = serializers.CharField(max_length=128) |
||||
|
|
||||
|
def validate_temp_token(self, value: str) -> str: |
||||
|
value = value.strip() |
||||
|
if not value: |
||||
|
raise serializers.ValidationError("temp_token is required.") |
||||
|
return value |
||||
@ -0,0 +1,37 @@ |
|||||
|
from rest_framework import serializers |
||||
|
|
||||
|
from apps.account.models import LocationHistory |
||||
|
|
||||
|
|
||||
|
class LocationHistorySerializer(serializers.ModelSerializer): |
||||
|
user = serializers.HiddenField(default=serializers.CurrentUserDefault()) |
||||
|
class Meta: |
||||
|
model = LocationHistory |
||||
|
exclude = ('at_time',) |
||||
|
|
||||
|
class ReverseGeolocationSerializer(serializers.Serializer): |
||||
|
"""Serializer for reverse geolocation request query parameters""" |
||||
|
lat = serializers.FloatField( |
||||
|
required=True, |
||||
|
min_value=-90.0, |
||||
|
max_value=90.0, |
||||
|
help_text="Latitude coordinate (-90 to 90)" |
||||
|
) |
||||
|
lon = serializers.FloatField( |
||||
|
required=True, |
||||
|
min_value=-180.0, |
||||
|
max_value=180.0, |
||||
|
help_text="Longitude coordinate (-180 to 180)" |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class ReverseGeolocationResponseSerializer(serializers.Serializer): |
||||
|
"""Serializer for reverse geolocation response""" |
||||
|
latitude = serializers.FloatField(read_only=True) |
||||
|
longitude = serializers.FloatField(read_only=True) |
||||
|
city = serializers.CharField(max_length=100, allow_null=True, read_only=True) |
||||
|
country = serializers.CharField(max_length=100, allow_null=True, read_only=True) |
||||
|
country_code = serializers.CharField(max_length=10, allow_null=True, read_only=True) |
||||
|
accuracy_radius = serializers.IntegerField(allow_null=True, read_only=True, required=False) |
||||
|
time_zone = serializers.CharField(max_length=100, allow_null=True, allow_blank=True, read_only=True, required=False) |
||||
|
postal_code = serializers.CharField(max_length=20, allow_null=True, allow_blank=True, read_only=True, required=False) |
||||
@ -0,0 +1,36 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from apps.account.models import Notification, NotificationTemplate |
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class NotificationSerializer(serializers.ModelSerializer): |
||||
|
class Meta: |
||||
|
model = Notification |
||||
|
fields = ['id', 'title', 'message', 'is_read', 'notification_type', 'action', 'navigate_to', 'created_at', 'updated_at'] |
||||
|
|
||||
|
|
||||
|
class NotificationSendSerializer(serializers.Serializer): |
||||
|
title = serializers.CharField() |
||||
|
body = serializers.CharField() |
||||
|
data = serializers.DictField(required=False) |
||||
|
user_id = serializers.IntegerField(required=True) |
||||
|
|
||||
|
|
||||
|
class AdminNotificationSerializer(serializers.ModelSerializer): |
||||
|
user_fullname = serializers.CharField(source='user.fullname', read_only=True) |
||||
|
user_email = serializers.CharField(source='user.email', read_only=True) |
||||
|
user_id = serializers.IntegerField(source='user.id', read_only=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = Notification |
||||
|
fields = [ |
||||
|
'id', 'title', 'message', 'is_read', 'notification_type', |
||||
|
'action', 'navigate_to', 'created_at', 'updated_at', |
||||
|
'user_fullname', 'user_email', 'user_id' |
||||
|
] |
||||
|
|
||||
|
|
||||
|
class NotificationTemplateSerializer(serializers.ModelSerializer): |
||||
|
class Meta: |
||||
|
model = NotificationTemplate |
||||
|
fields = ['id', 'notification_type', 'name', 'is_active', 'title', 'body', 'placeholders_info'] |
||||
@ -0,0 +1,377 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
from django.contrib.auth.models import Group |
||||
|
from django.contrib.auth.password_validation import validate_password |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from apps.account.models import User |
||||
|
from utils import FileFieldSerializer, absolute_url |
||||
|
from utils.validators import validate_type_code |
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserProfileSerializer(serializers.ModelSerializer): |
||||
|
avatar = FileFieldSerializer(required=False, allow_null=True) |
||||
|
password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) |
||||
|
fullname = serializers.CharField(required=False) |
||||
|
gender = serializers.ChoiceField( |
||||
|
choices=User.GenderChoices.choices, |
||||
|
required=False, |
||||
|
help_text="Select the user's gender." |
||||
|
) |
||||
|
fcm = serializers.CharField(required=False, help_text="Firebase Cloud Messaging token.") |
||||
|
saved_location = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = ['id', 'device_id', 'fcm', 'fullname', 'slug', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender', 'saved_location'] |
||||
|
read_only_fields = ['email', 'info', 'skill', 'device_id', 'slug', 'saved_location'] |
||||
|
|
||||
|
def get_saved_location(self, obj): |
||||
|
# Check if user is authenticated and has location_history attribute |
||||
|
if not obj.is_authenticated or not hasattr(obj, 'location_history'): |
||||
|
return None |
||||
|
|
||||
|
last_location = obj.location_history.order_by('-at_time').first() |
||||
|
if last_location: |
||||
|
return { |
||||
|
'lat': last_location.lat, |
||||
|
'lon': last_location.lon, |
||||
|
'city': last_location.city, |
||||
|
'country': last_location.country, |
||||
|
'timezone': last_location.timezone, |
||||
|
'selected_manually': last_location.selected_manually, |
||||
|
'at_time': last_location.at_time, |
||||
|
} |
||||
|
return None |
||||
|
|
||||
|
# def validate_email(self, value): |
||||
|
# if User.objects.filter(email=value).exists(): |
||||
|
# raise serializers.ValidationError("This email is already registered.") |
||||
|
# return value |
||||
|
|
||||
|
def update(self, instance, validated_data): |
||||
|
# Pop the password from the data to handle it separately |
||||
|
password = validated_data.pop('password', None) |
||||
|
|
||||
|
# Use the default update logic for all other fields |
||||
|
for attr, value in validated_data.items(): |
||||
|
if value is not None: |
||||
|
setattr(instance, attr, value) |
||||
|
|
||||
|
# If a new password was provided, hash and set it correctly |
||||
|
if password: |
||||
|
instance.set_password(password) |
||||
|
|
||||
|
instance.save() |
||||
|
return instance |
||||
|
|
||||
|
|
||||
|
class UserRegisterSerializer(serializers.ModelSerializer): |
||||
|
password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) |
||||
|
fcm = serializers.CharField(required=False, allow_blank=True, allow_null=True) |
||||
|
device_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, write_only=True) |
||||
|
email = serializers.EmailField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = ['id', 'fullname', 'email', 'password', 'fcm', 'device_id'] |
||||
|
extra_kwargs = { |
||||
|
'fullname': {'required': True}, |
||||
|
'email': {'required': True}, |
||||
|
} |
||||
|
|
||||
|
def create(self, validated_data): |
||||
|
device_id = validated_data.pop('device_id', None) |
||||
|
password = validated_data.pop('password', None) |
||||
|
user = super().create(validated_data) |
||||
|
if password: |
||||
|
user.set_password(password) |
||||
|
if device_id: |
||||
|
user.device_id = device_id |
||||
|
user.save() |
||||
|
return user |
||||
|
|
||||
|
def validate_email(self, value): |
||||
|
normalized_email = User.objects.normalize_email(value) |
||||
|
if User.objects.filter(email=normalized_email).exists(): |
||||
|
raise serializers.ValidationError("This email is already registered.") |
||||
|
return normalized_email |
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserVerifySerializer(serializers.Serializer): |
||||
|
code = serializers.CharField(max_length=6, validators=[validate_type_code]) |
||||
|
email = serializers.EmailField() |
||||
|
device_id = serializers.CharField(max_length=255, required=False) |
||||
|
|
||||
|
def validate_email(self, value): |
||||
|
""" |
||||
|
Normalize the email to ensure the Redis key matches correctly. |
||||
|
""" |
||||
|
return User.objects.normalize_email(value) |
||||
|
|
||||
|
|
||||
|
class UserLoginSerializer(serializers.Serializer): |
||||
|
password = serializers.CharField(write_only=True) |
||||
|
token = serializers.CharField(allow_null=True, read_only=True, required=False) |
||||
|
fullname = serializers.CharField(allow_null=True, read_only=True, required=False) |
||||
|
avatar = serializers.CharField(allow_null=True, read_only=True, required=False) |
||||
|
email = serializers.EmailField(write_only=True) |
||||
|
password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False) |
||||
|
fcm = serializers.CharField(required=False) |
||||
|
device_id = serializers.CharField(required=False) |
||||
|
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
||||
|
|
||||
|
def validate(self, data): |
||||
|
# Custom validation logic can be added here if needed |
||||
|
# data.pop('fcm', None) |
||||
|
# data.pop('device_id', None) |
||||
|
return data |
||||
|
def validate_email(self, value): |
||||
|
""" |
||||
|
Normalize email for case-insensitive login. |
||||
|
""" |
||||
|
return User.objects.normalize_email(value) |
||||
|
|
||||
|
|
||||
|
# class UserLoginSerializer(serializers.Serializer): |
||||
|
# password = serializers.CharField(write_only=True) |
||||
|
# token = serializers.CharField(allow_null=True, read_only=True, required=False) |
||||
|
# fullname = serializers.CharField(allow_null=True, read_only=True, required=False) |
||||
|
# avatar = serializers.CharField(allow_null=True, read_only=True, required=False) |
||||
|
# email = serializers.EmailField(write_only=True) |
||||
|
# password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False) |
||||
|
# fcm = serializers.CharField(required=False) |
||||
|
# device_id = serializers.CharField(required=False) |
||||
|
# timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
# class UserRecoverPasswordSerializer(serializers.ModelSerializer): |
||||
|
# email = serializers.EmailField() |
||||
|
|
||||
|
# class Meta: |
||||
|
# model = User |
||||
|
# fields = ['email',] |
||||
|
# extra_kwargs = { |
||||
|
# 'email': {'required': True,}, |
||||
|
# } |
||||
|
|
||||
|
class UserRecoverPasswordSerializer(serializers.Serializer): |
||||
|
""" |
||||
|
Validates that an email is provided and is in a valid format |
||||
|
without checking for database uniqueness. |
||||
|
""" |
||||
|
email = serializers.EmailField(required=True) |
||||
|
|
||||
|
def validate_email(self, value): |
||||
|
""" |
||||
|
Normalize the email address to ensure case-insensitive lookups. |
||||
|
""" |
||||
|
return User.objects.normalize_email(value) |
||||
|
|
||||
|
class UserResetPasswordSerializer(serializers.ModelSerializer): |
||||
|
password = serializers.CharField(write_only=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = ['password', ] |
||||
|
extra_kwargs = { |
||||
|
'password': {'required': True,}, |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserGuestSerializer(serializers.ModelSerializer): |
||||
|
lat = serializers.CharField(max_length=255, allow_null=True, allow_blank=True, required=False) |
||||
|
lon = serializers.CharField(max_length=255, allow_null=True, allow_blank=True, required=False) |
||||
|
fcm = serializers.CharField(required=False) |
||||
|
device_id = serializers.CharField(required=False) |
||||
|
device_os = serializers.ChoiceField(choices=User.DeviceOs.choices, required=False) |
||||
|
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = ['device_id', 'fcm', 'device_os', 'lat', 'lon', 'timezone'] |
||||
|
|
||||
|
def validate(self, data): |
||||
|
# Make sure at least device_id is provided |
||||
|
if not data.get('device_id'): |
||||
|
raise serializers.ValidationError({"device_id": "Device ID is required for guest users."}) |
||||
|
|
||||
|
return data |
||||
|
|
||||
|
class WebUserGuestSerializer(serializers.ModelSerializer): |
||||
|
|
||||
|
user_agent = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
||||
|
client_ip = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
||||
|
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = ['user_agent', 'client_ip', 'timezone', 'device_id', 'device_os'] |
||||
|
|
||||
|
def validate(self, data): |
||||
|
# Ensure device_id is provided (generated by view) |
||||
|
if not data.get('device_id'): |
||||
|
raise serializers.ValidationError({"device_id": "Device ID is required for web guest users."}) |
||||
|
return data |
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserFCMSerializer(serializers.ModelSerializer): |
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = ['fcm'] |
||||
|
|
||||
|
|
||||
|
class AdminUserSerializer(serializers.ModelSerializer): |
||||
|
avatar = FileFieldSerializer(required=False, allow_null=True) |
||||
|
password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) |
||||
|
email = serializers.EmailField(required=True) |
||||
|
auth_token = serializers.SerializerMethodField() |
||||
|
plain_password = serializers.SerializerMethodField() |
||||
|
client_ip = serializers.SerializerMethodField() |
||||
|
device_os = serializers.SerializerMethodField() |
||||
|
user_agent = serializers.SerializerMethodField() |
||||
|
device_id = serializers.SerializerMethodField() |
||||
|
|
||||
|
def get_auth_token(self, obj): |
||||
|
token = Token.objects.filter(user=obj).first() |
||||
|
return token.key if token else None |
||||
|
|
||||
|
def get_plain_password(self, obj): |
||||
|
request = self.context.get('request') |
||||
|
if request and request.user and (request.user.is_superuser or request.user.user_type in ['super_admin', 'admin']): |
||||
|
return obj.get_plain_password() |
||||
|
return None |
||||
|
|
||||
|
def get_client_ip(self, obj): |
||||
|
if obj.client_ip: |
||||
|
return obj.client_ip |
||||
|
history = obj.login_history.filter(ip__isnull=False).exclude(ip='').order_by('-at_time', '-id').first() |
||||
|
if history and history.ip: |
||||
|
return history.ip |
||||
|
loc_history = obj.location_history.filter(ip__isnull=False).exclude(ip='').order_by('-at_time', '-id').first() |
||||
|
if loc_history and loc_history.ip: |
||||
|
return loc_history.ip |
||||
|
return None |
||||
|
|
||||
|
def get_device_os(self, obj): |
||||
|
if obj.device_os: |
||||
|
return obj.device_os |
||||
|
history = obj.login_history.filter(device_os__isnull=False).exclude(device_os='').order_by('-at_time', '-id').first() |
||||
|
if history and history.device_os: |
||||
|
return history.device_os |
||||
|
return None |
||||
|
|
||||
|
def get_user_agent(self, obj): |
||||
|
if obj.user_agent: |
||||
|
return obj.user_agent |
||||
|
history = obj.login_history.filter(user_agent__isnull=False).exclude(user_agent='').order_by('-at_time', '-id').first() |
||||
|
if history and history.user_agent: |
||||
|
return history.user_agent |
||||
|
return None |
||||
|
|
||||
|
def get_device_id(self, obj): |
||||
|
return obj.device_id or None |
||||
|
|
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = [ |
||||
|
'id', 'fullname', 'email', 'phone_number', 'password', 'avatar', |
||||
|
'gender', 'birthdate', 'info', 'skill', 'city', 'country', |
||||
|
'device_id', 'device_os', 'user_agent', 'client_ip', 'fcm', |
||||
|
'user_type', 'is_active', 'is_staff', 'is_superuser', |
||||
|
'date_joined', 'last_login', 'auth_token', 'plain_password' |
||||
|
] |
||||
|
read_only_fields = ['id', 'date_joined', 'last_login', 'auth_token', 'device_id', 'device_os', 'user_agent', 'client_ip'] |
||||
|
|
||||
|
ROLE_GROUP_MAP = { |
||||
|
User.UserType.STUDENT: "Student Group", |
||||
|
User.UserType.PROFESSOR: "Professor Group", |
||||
|
User.UserType.ADMIN: "Admin Group", |
||||
|
User.UserType.SUPER_ADMIN: "Super Admin Group", |
||||
|
User.UserType.CONSULTANT: "Consultant Group", |
||||
|
} |
||||
|
PANEL_GROUPS = {"Professor Group", "Admin Group", "Super admin Group", "Super Admin Group"} |
||||
|
|
||||
|
def _ensure_group(self, instance, group_name): |
||||
|
group, _ = Group.objects.get_or_create(name=group_name) |
||||
|
instance.groups.add(group) |
||||
|
|
||||
|
def _remove_groups(self, instance, group_names): |
||||
|
if not group_names: |
||||
|
return |
||||
|
instance.groups.remove(*Group.objects.filter(name__in=group_names)) |
||||
|
|
||||
|
def _normalize_role_access(self, instance, requested_user_type): |
||||
|
if not requested_user_type: |
||||
|
return |
||||
|
|
||||
|
requested_user_type = str(requested_user_type) |
||||
|
|
||||
|
if requested_user_type == User.UserType.SUPER_ADMIN: |
||||
|
instance.is_staff = True |
||||
|
instance.is_superuser = True |
||||
|
self._ensure_group(instance, "Super Admin Group") |
||||
|
self._remove_groups(instance, {"Professor Group", "Admin Group", "Super admin Group"}) |
||||
|
return |
||||
|
|
||||
|
if requested_user_type == User.UserType.PROFESSOR: |
||||
|
instance.is_staff = False |
||||
|
instance.is_superuser = False |
||||
|
self._ensure_group(instance, "Professor Group") |
||||
|
self._remove_groups(instance, {"Admin Group", "Super admin Group", "Super Admin Group"}) |
||||
|
return |
||||
|
|
||||
|
if requested_user_type == User.UserType.ADMIN: |
||||
|
instance.is_staff = False |
||||
|
instance.is_superuser = False |
||||
|
self._ensure_group(instance, "Admin Group") |
||||
|
self._remove_groups(instance, {"Professor Group", "Super admin Group", "Super Admin Group"}) |
||||
|
return |
||||
|
|
||||
|
if requested_user_type == User.UserType.STUDENT: |
||||
|
instance.is_staff = False |
||||
|
instance.is_superuser = False |
||||
|
self._ensure_group(instance, "Student Group") |
||||
|
self._remove_groups(instance, self.PANEL_GROUPS) |
||||
|
return |
||||
|
|
||||
|
if requested_user_type == User.UserType.CONSULTANT: |
||||
|
instance.is_staff = False |
||||
|
instance.is_superuser = False |
||||
|
self._ensure_group(instance, "Consultant Group") |
||||
|
self._remove_groups(instance, self.PANEL_GROUPS) |
||||
|
return |
||||
|
|
||||
|
if requested_user_type == User.UserType.CLIENT: |
||||
|
instance.is_staff = False |
||||
|
instance.is_superuser = False |
||||
|
self._remove_groups(instance, self.PANEL_GROUPS) |
||||
|
|
||||
|
def create(self, validated_data): |
||||
|
password = validated_data.pop('password', None) |
||||
|
user = User(**validated_data) |
||||
|
if password: |
||||
|
user.set_password(password) |
||||
|
user.set_plain_password(password) |
||||
|
else: |
||||
|
user.set_unusable_password() |
||||
|
user.save() |
||||
|
self._normalize_role_access(user, validated_data.get('user_type', user.user_type)) |
||||
|
user.save() |
||||
|
return user |
||||
|
|
||||
|
def update(self, instance, validated_data): |
||||
|
password = validated_data.pop('password', None) |
||||
|
user = super().update(instance, validated_data) |
||||
|
self._normalize_role_access(user, validated_data.get('user_type', user.user_type)) |
||||
|
if password: |
||||
|
user.set_password(password) |
||||
|
user.set_plain_password(password) |
||||
|
user.save() |
||||
|
return user |
||||
@ -0,0 +1,29 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from django.contrib.auth.password_validation import validate_password |
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class WebUserRegisterSerializer(serializers.ModelSerializer): |
||||
|
password = serializers.CharField(write_only=True, validators=[validate_password]) |
||||
|
fcm = serializers.CharField(required=False, allow_blank=True, allow_null=True) |
||||
|
email = serializers.EmailField() |
||||
|
|
||||
|
class Meta: |
||||
|
model = User |
||||
|
fields = ['id', 'fullname', 'email', 'password', 'fcm'] |
||||
|
extra_kwargs = { |
||||
|
'fullname': {'required': True}, |
||||
|
'email': {'required': True}, |
||||
|
} |
||||
|
|
||||
|
def validate_email(self, value): |
||||
|
normalized_email = User.objects.normalize_email(value) |
||||
|
if User.objects.filter(email=normalized_email).exists(): |
||||
|
raise serializers.ValidationError("This email is already registered.") |
||||
|
return normalized_email |
||||
|
|
||||
|
def create(self, validated_data): |
||||
|
user = super().create(validated_data) |
||||
|
return user |
||||
|
|
||||
|
|
||||
@ -0,0 +1,39 @@ |
|||||
|
import logging |
||||
|
from celery import shared_task |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
@shared_task(name="send_notification_task") |
||||
|
def send_notification_task(user_id: int, title: str, body: str, data: dict = None): |
||||
|
""" |
||||
|
Celery background task to dispatch notifications. |
||||
|
Can be connected to Firebase Cloud Messaging (FCM), APNS, or email. |
||||
|
""" |
||||
|
try: |
||||
|
from apps.account.models import User, Notification |
||||
|
user = User.objects.get(id=user_id) |
||||
|
|
||||
|
# Save in-app notification record |
||||
|
Notification.objects.create( |
||||
|
user=user, |
||||
|
title=title, |
||||
|
message=body, |
||||
|
notification_type=data.get('type') if data else 'general', |
||||
|
action=data.get('action', 'navigate') if data else 'navigate', |
||||
|
navigate_to=data.get('navigate_to', '') if data else '', |
||||
|
) |
||||
|
logger.info(f"Notification recorded for user #{user_id}: {title}") |
||||
|
return True |
||||
|
except Exception as e: |
||||
|
logger.error(f"Error sending notification to user #{user_id}: {e}") |
||||
|
return False |
||||
|
|
||||
|
|
||||
|
@shared_task(name="cleanup_expired_tokens_task") |
||||
|
def cleanup_expired_tokens_task(): |
||||
|
""" |
||||
|
Periodic task to clean up old or expired auth tokens/sessions. |
||||
|
""" |
||||
|
logger.info("Executing cleanup_expired_tokens_task...") |
||||
|
return True |
||||
@ -0,0 +1,40 @@ |
|||||
|
|
||||
|
{% load unfold i18n %} |
||||
|
|
||||
|
<div class="border border-base-300 border-dashed mb-4 p-3 rounded dark:border-base-700"> |
||||
|
{% trans "Driver before template" %} |
||||
|
</div> |
||||
|
|
||||
|
<div class="grid gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4 "> |
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Active drivers" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="DriverActiveComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Inactive drivers" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="DriverInactiveComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total points" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="DriverTotalPointsComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total races" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="DriverRacesComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
</div> |
||||
@ -0,0 +1,839 @@ |
|||||
|
{% load i18n %} |
||||
|
<div class="json-editor-container"> |
||||
|
<textarea style="display: none" name="{{ widget.name }}" id="{{ widget.attrs.id }}" {% |
||||
|
include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</textarea> |
||||
|
<div class="json-view-editor" id='date-view-editor-{{ widget.attrs.id }}'></div> |
||||
|
</div> |
||||
|
|
||||
|
<script defer="defer"> |
||||
|
document.addEventListener('DOMContentLoaded', function () { |
||||
|
function initJsonEditor() { |
||||
|
let editor_ = document.getElementById("{{ widget.attrs.id }}"); |
||||
|
if (!editor_) { |
||||
|
console.error("Editor element not found"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
let startValue; |
||||
|
try { |
||||
|
startValue = editor_.value && editor_.value.trim() !== '' ? JSON.parse(editor_.value) : []; |
||||
|
} catch (e) { |
||||
|
console.error("Error parsing JSON value:", e); |
||||
|
startValue = []; |
||||
|
} |
||||
|
|
||||
|
let jsonViewerDiv = document.getElementById('date-view-editor-{{ widget.attrs.id }}'); |
||||
|
|
||||
|
if (typeof JSONEditor === 'undefined') { |
||||
|
console.error("JSONEditor is not defined. Make sure the library is loaded."); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Custom template for add button |
||||
|
JSONEditor.defaults.templates.button = function (text, icon, title) { |
||||
|
let el = document.createElement('button'); |
||||
|
el.type = 'button'; |
||||
|
el.classList.add('json-editor-btn-modern'); |
||||
|
|
||||
|
if (icon) { |
||||
|
let iconEl = document.createElement('span'); |
||||
|
iconEl.classList.add('json-editor-btn-icon'); |
||||
|
iconEl.innerHTML = icon; |
||||
|
el.appendChild(iconEl); |
||||
|
} |
||||
|
|
||||
|
if (text) { |
||||
|
let textEl = document.createElement('span'); |
||||
|
textEl.classList.add('json-editor-btn-text'); |
||||
|
textEl.textContent = text; |
||||
|
el.appendChild(textEl); |
||||
|
} |
||||
|
|
||||
|
if (title) el.title = title; |
||||
|
|
||||
|
return el; |
||||
|
}; |
||||
|
|
||||
|
// Custom icons |
||||
|
JSONEditor.defaults.iconlib = { |
||||
|
getIcon: function (key) { |
||||
|
switch (key) { |
||||
|
case 'add': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/></svg>'; |
||||
|
case 'delete': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>'; |
||||
|
case 'edit': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>'; |
||||
|
case 'moveup': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>'; |
||||
|
case 'movedown': |
||||
|
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>'; |
||||
|
default: |
||||
|
return ''; |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
try { |
||||
|
let jsonEditor = new JSONEditor( |
||||
|
jsonViewerDiv, { |
||||
|
theme: 'bootstrap4', |
||||
|
schema: {{ widget.attrs.schema | safe }}, |
||||
|
disable_edit_json: true, |
||||
|
disable_properties: true, |
||||
|
disable_array_delete_all_rows: false, |
||||
|
disable_array_delete_last_row: true, // Disable delete last row button |
||||
|
disable_array_reorder: true, // Disable array reordering |
||||
|
grid_columns: 12, |
||||
|
prompt_before_delete: true, |
||||
|
disable_collapse: false, // Enable collapse to show button sections |
||||
|
show_errors: 'always', |
||||
|
startval: startValue, |
||||
|
iconlib: 'custom', |
||||
|
object_layout: 'normal', // Changed from grid to normal for better layout |
||||
|
enable_array_copy: false, // Disable copy functionality |
||||
|
show_opt_in: false, |
||||
|
compact: false, |
||||
|
array_controls_top: false, // Move array controls to bottom |
||||
|
show_button_bar: true, // Show button bar |
||||
|
form_name_root: 'root' // Add a root name for better structure |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
// Store the editor instance on the textarea |
||||
|
editor_.editor = jsonEditor; |
||||
|
|
||||
|
// Update the textarea when the editor changes |
||||
|
jsonEditor.on('change', function () { |
||||
|
editor_.value = JSON.stringify(jsonEditor.getValue()); |
||||
|
|
||||
|
// Trigger a change event on the textarea |
||||
|
let event = new Event('change', { bubbles: true }); |
||||
|
editor_.dispatchEvent(event); |
||||
|
|
||||
|
// Apply styling to newly added elements |
||||
|
applyCustomStyling(); |
||||
|
}); |
||||
|
|
||||
|
// Function to apply custom styling to all elements |
||||
|
function applyCustomStyling() { |
||||
|
// Add modern styling to buttons |
||||
|
const allButtons = jsonViewerDiv.querySelectorAll('button'); |
||||
|
allButtons.forEach(button => { |
||||
|
if (!button.classList.contains('json-editor-btn-modern')) { |
||||
|
button.classList.add('json-editor-btn-modern'); |
||||
|
|
||||
|
// Add specific styling based on button type |
||||
|
if (button.classList.contains('json-editor-btntype-add')) { |
||||
|
button.classList.add('json-editor-btn-add'); |
||||
|
} else if (button.classList.contains('json-editor-btntype-delete') || |
||||
|
button.classList.contains('json-editor-btntype-deleteall') || |
||||
|
button.classList.contains('json-editor-btntype-deletelast')) { |
||||
|
button.classList.add('json-editor-btn-delete'); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Style form controls |
||||
|
const formControls = jsonViewerDiv.querySelectorAll('input, select, textarea'); |
||||
|
formControls.forEach(control => { |
||||
|
control.classList.add('modern-form-control'); |
||||
|
}); |
||||
|
|
||||
|
// Style table headers |
||||
|
const tableHeaders = jsonViewerDiv.querySelectorAll('th'); |
||||
|
tableHeaders.forEach(header => { |
||||
|
header.classList.add('modern-table-header'); |
||||
|
}); |
||||
|
|
||||
|
// Style table rows |
||||
|
const tableRows = jsonViewerDiv.querySelectorAll('tr'); |
||||
|
tableRows.forEach(row => { |
||||
|
row.classList.add('modern-table-row'); |
||||
|
}); |
||||
|
|
||||
|
// Make table full width |
||||
|
const tables = jsonViewerDiv.querySelectorAll('table'); |
||||
|
tables.forEach(table => { |
||||
|
table.classList.add('full-width-table'); |
||||
|
}); |
||||
|
|
||||
|
// Make table cells take equal space |
||||
|
const tableCells = jsonViewerDiv.querySelectorAll('td'); |
||||
|
tableCells.forEach(cell => { |
||||
|
if (!cell.classList.contains('table-controls-cell')) { |
||||
|
cell.classList.add('equal-width-cell'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Add special styling to control cells |
||||
|
const controlCells = jsonViewerDiv.querySelectorAll('td:last-child'); |
||||
|
controlCells.forEach(cell => { |
||||
|
cell.classList.add('table-controls-cell'); |
||||
|
}); |
||||
|
|
||||
|
// Fix button group styling |
||||
|
const buttonGroups = jsonViewerDiv.querySelectorAll('.btn-group, .json-editor-btngroup'); |
||||
|
buttonGroups.forEach(group => { |
||||
|
group.classList.add('modern-btn-group'); |
||||
|
}); |
||||
|
|
||||
|
// Fix card styling |
||||
|
const cards = jsonViewerDiv.querySelectorAll('.card'); |
||||
|
cards.forEach(card => { |
||||
|
card.classList.add('modern-card'); |
||||
|
}); |
||||
|
|
||||
|
// Ensure button sections are visible |
||||
|
const buttonSections = jsonViewerDiv.querySelectorAll('.json-editor-btngroup'); |
||||
|
buttonSections.forEach(section => { |
||||
|
section.style.display = 'flex'; |
||||
|
section.style.visibility = 'visible'; |
||||
|
}); |
||||
|
|
||||
|
// Style button bars |
||||
|
const buttonBars = jsonViewerDiv.querySelectorAll('.json-editor-btn-bar'); |
||||
|
buttonBars.forEach(bar => { |
||||
|
bar.style.display = 'flex'; |
||||
|
bar.style.flexWrap = 'wrap'; |
||||
|
bar.style.gap = '0.5rem'; |
||||
|
bar.style.marginTop = '1rem'; |
||||
|
bar.style.marginBottom = '0'; |
||||
|
bar.style.padding = '0.75rem'; |
||||
|
bar.style.backgroundColor = 'rgba(1, 53, 59, 0.05)'; |
||||
|
bar.style.borderRadius = '0.5rem'; |
||||
|
bar.style.width = '100%'; |
||||
|
bar.style.justifyContent = 'flex-end'; |
||||
|
|
||||
|
// Move button bar to the end of its parent container |
||||
|
const parent = bar.parentElement; |
||||
|
if (parent) { |
||||
|
parent.style.display = 'flex'; |
||||
|
parent.style.flexDirection = 'column'; |
||||
|
parent.appendChild(bar); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Hide specific buttons (Copy, Move up, Move down, Delete Last) |
||||
|
const buttonsToHide = jsonViewerDiv.querySelectorAll('.json-editor-btntype-copy, .json-editor-btntype-move, .json-editor-btntype-deletelast'); |
||||
|
buttonsToHide.forEach(button => { |
||||
|
button.style.display = 'none'; |
||||
|
}); |
||||
|
|
||||
|
// Ensure form fields take full width |
||||
|
const formRows = jsonViewerDiv.querySelectorAll('.row'); |
||||
|
formRows.forEach(row => { |
||||
|
row.style.width = '100%'; |
||||
|
const cols = row.querySelectorAll('[class*="col-"]'); |
||||
|
cols.forEach(col => { |
||||
|
col.style.width = '100%'; |
||||
|
col.style.maxWidth = '100%'; |
||||
|
col.style.flex = '0 0 100%'; |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Apply styling immediately after initialization |
||||
|
setTimeout(applyCustomStyling, 100); |
||||
|
|
||||
|
// Process error messages to make them HTML5-like |
||||
|
function processErrorMessages() { |
||||
|
const errorElements = jsonViewerDiv.querySelectorAll('.je-error'); |
||||
|
errorElements.forEach(error => { |
||||
|
// Get the error message text |
||||
|
const errorText = error.textContent.trim(); |
||||
|
|
||||
|
// Set the data-content attribute for the tooltip |
||||
|
error.setAttribute('data-content', errorText); |
||||
|
|
||||
|
// Find the parent form group |
||||
|
const formGroup = error.closest('.form-group'); |
||||
|
if (formGroup) { |
||||
|
formGroup.classList.add('has-error'); |
||||
|
|
||||
|
// Find the input element |
||||
|
const input = formGroup.querySelector('input, select, textarea'); |
||||
|
if (input) { |
||||
|
// Add error class to the input |
||||
|
input.classList.add('is-invalid'); |
||||
|
|
||||
|
// Add title attribute for native tooltip |
||||
|
input.setAttribute('title', errorText); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Remove "Delete Last Course Features" button if it exists |
||||
|
function removeDeleteLastButton() { |
||||
|
const deleteLastButtons = jsonViewerDiv.querySelectorAll('button.json-editor-btntype-deletelast'); |
||||
|
deleteLastButtons.forEach(button => { |
||||
|
const buttonText = button.textContent.trim(); |
||||
|
if (buttonText.includes('Delete Last') && buttonText.includes('Course Features')) { |
||||
|
button.style.display = 'none'; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Add mutation observer to apply styling to dynamically added elements |
||||
|
const observer = new MutationObserver(function (mutations) { |
||||
|
applyCustomStyling(); |
||||
|
processErrorMessages(); |
||||
|
removeDeleteLastButton(); |
||||
|
}); |
||||
|
|
||||
|
observer.observe(jsonViewerDiv, { |
||||
|
childList: true, |
||||
|
subtree: true |
||||
|
}); |
||||
|
|
||||
|
// Initial processing |
||||
|
setTimeout(() => { |
||||
|
processErrorMessages(); |
||||
|
removeDeleteLastButton(); |
||||
|
}, 200); |
||||
|
|
||||
|
} catch (e) { |
||||
|
console.error("Error initializing JSONEditor:", e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Initialize the editor |
||||
|
if (document.readyState === 'complete') { |
||||
|
initJsonEditor(); |
||||
|
} else { |
||||
|
window.addEventListener('load', initJsonEditor); |
||||
|
} |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<style> |
||||
|
/* Modern JSON Editor Container - Unfold theme */ |
||||
|
.json-editor-container { |
||||
|
margin-bottom: 1.5rem; |
||||
|
background-color: #0C0B1D; |
||||
|
border-radius: 0.5rem; |
||||
|
overflow: hidden; |
||||
|
width: 100%; |
||||
|
box-shadow: 0 1px 3px rgba(1, 53, 59, 0.08); |
||||
|
border: 1px solid rgba(1, 53, 59, 0.05); |
||||
|
} |
||||
|
|
||||
|
/* Editor container */ |
||||
|
.json-view-editor { |
||||
|
width: 100%; |
||||
|
border: none; |
||||
|
padding: 1.25rem; |
||||
|
} |
||||
|
|
||||
|
/* Card styling */ |
||||
|
.card { |
||||
|
border: none !important; |
||||
|
margin-bottom: 1.25rem !important; |
||||
|
background-color: transparent !important; |
||||
|
} |
||||
|
|
||||
|
.card-body { |
||||
|
padding: 0.75rem 0 !important; |
||||
|
} |
||||
|
|
||||
|
/* Hide unnecessary elements */ |
||||
|
.json-view-editor .card-title { |
||||
|
display: none !important; |
||||
|
} |
||||
|
|
||||
|
/* Modern JSON Editor styling */ |
||||
|
.json-editor-modern { |
||||
|
border: none !important; |
||||
|
box-shadow: none !important; |
||||
|
} |
||||
|
|
||||
|
.jsoneditor-menu { |
||||
|
display: none !important; |
||||
|
} |
||||
|
|
||||
|
/* Modern card styling */ |
||||
|
.modern-card { |
||||
|
border: none !important; |
||||
|
box-shadow: none !important; |
||||
|
background: transparent !important; |
||||
|
margin-bottom: 1rem !important; |
||||
|
padding: 0 !important; |
||||
|
width: 100% !important; |
||||
|
} |
||||
|
|
||||
|
.card-body { |
||||
|
padding: 0 !important; |
||||
|
width: 100% !important; |
||||
|
} |
||||
|
|
||||
|
/* Table styling */ |
||||
|
.full-width-table { |
||||
|
width: 100% !important; |
||||
|
margin-bottom: 1rem !important; |
||||
|
border-collapse: separate !important; |
||||
|
border-spacing: 0 !important; |
||||
|
} |
||||
|
|
||||
|
.table-responsive { |
||||
|
border: 1px solid rgba(1, 53, 59, 0.1); |
||||
|
border-radius: 0.5rem; |
||||
|
overflow: hidden; |
||||
|
margin-bottom: 1.25rem; |
||||
|
background-color: white; |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); |
||||
|
} |
||||
|
|
||||
|
.equal-width-cell { |
||||
|
width: 45% !important; |
||||
|
} |
||||
|
|
||||
|
.table-controls-cell { |
||||
|
width: 10% !important; |
||||
|
text-align: right !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-header { |
||||
|
background-color: rgba(1, 53, 59, 0.03) !important; |
||||
|
color: rgb(1, 53, 59) !important; |
||||
|
font-weight: 600 !important; |
||||
|
text-transform: uppercase !important; |
||||
|
font-size: 0.6875rem !important; |
||||
|
letter-spacing: 0.05em !important; |
||||
|
padding: 0.625rem 0.875rem !important; |
||||
|
border-bottom: 1px solid rgba(1, 53, 59, 0.08) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row { |
||||
|
border-bottom: 1px solid rgba(1, 53, 59, 0.06) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row:last-child { |
||||
|
border-bottom: none !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row:hover { |
||||
|
background-color: rgba(37, 208, 118, 0.03) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row td { |
||||
|
padding: 0.625rem 0.875rem !important; |
||||
|
vertical-align: middle !important; |
||||
|
font-size: 0.875rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Modern buttons - Using Unfold color scheme */ |
||||
|
.json-editor-btn-modern { |
||||
|
display: inline-flex !important; |
||||
|
align-items: center !important; |
||||
|
justify-content: center !important; |
||||
|
gap: 0.375rem !important; |
||||
|
background-color: rgb(37, 208, 118) !important; |
||||
|
/* Unfold primary-500 */ |
||||
|
color: white !important; |
||||
|
border: none !important; |
||||
|
border-radius: 0.375rem !important; |
||||
|
padding: 0.5rem 0.875rem !important; |
||||
|
/* Smaller padding */ |
||||
|
font-weight: 500 !important; |
||||
|
cursor: pointer !important; |
||||
|
transition: all 0.2s ease !important; |
||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important; |
||||
|
margin: 0 0.25rem !important; |
||||
|
font-size: 0.8125rem !important; |
||||
|
/* Smaller font */ |
||||
|
line-height: 1.4 !important; |
||||
|
text-transform: none !important; |
||||
|
letter-spacing: 0.01em !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-modern:hover { |
||||
|
background-color: rgb(29, 166, 94) !important; |
||||
|
/* Unfold primary-600 */ |
||||
|
transform: translateY(-1px) !important; |
||||
|
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08) !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-modern:active { |
||||
|
transform: translateY(0) !important; |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08) !important; |
||||
|
} |
||||
|
|
||||
|
/* Button sections styling */ |
||||
|
.json-editor-btngroup, |
||||
|
.json-editor-btn-bar { |
||||
|
display: flex !important; |
||||
|
flex-wrap: wrap !important; |
||||
|
gap: 0.5rem !important; |
||||
|
margin-top: 1rem !important; |
||||
|
margin-bottom: 0 !important; |
||||
|
padding: 0.75rem !important; |
||||
|
background-color: rgba(1, 53, 59, 0.05) !important; |
||||
|
border-radius: 0.5rem !important; |
||||
|
visibility: visible !important; |
||||
|
width: 100% !important; |
||||
|
justify-content: flex-end !important; |
||||
|
} |
||||
|
|
||||
|
/* Button icons */ |
||||
|
.json-editor-btn-icon { |
||||
|
display: flex !important; |
||||
|
align-items: center !important; |
||||
|
justify-content: center !important; |
||||
|
} |
||||
|
|
||||
|
/* Add button styling */ |
||||
|
.json-editor-btn-add { |
||||
|
background-color: rgb(37, 208, 118) !important; |
||||
|
/* Unfold primary-500 */ |
||||
|
padding: 0.625rem 1rem !important; |
||||
|
/* Smaller padding */ |
||||
|
font-size: 0.875rem !important; |
||||
|
/* Smaller font */ |
||||
|
font-weight: 600 !important; |
||||
|
letter-spacing: 0.01em !important; |
||||
|
border-radius: 0.375rem !important; |
||||
|
width: auto !important; |
||||
|
/* Not full width */ |
||||
|
margin-bottom: 0.75rem !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-add:hover { |
||||
|
background-color: rgb(29, 166, 94) !important; |
||||
|
/* Unfold primary-600 */ |
||||
|
} |
||||
|
|
||||
|
/* Modern HTML5-like error styling */ |
||||
|
.je-error-container { |
||||
|
display: none !important; |
||||
|
/* Hide the default error container */ |
||||
|
} |
||||
|
|
||||
|
/* Style for invalid inputs */ |
||||
|
.je-error+input, |
||||
|
.je-error+select, |
||||
|
.je-error+textarea, |
||||
|
.has-error .modern-form-control { |
||||
|
border-color: rgb(239, 68, 68) !important; |
||||
|
padding-right: 2.5rem !important; |
||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ef4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); |
||||
|
background-repeat: no-repeat; |
||||
|
background-position: right 0.75rem center; |
||||
|
background-size: 1.25rem; |
||||
|
} |
||||
|
|
||||
|
/* Custom tooltip for errors */ |
||||
|
.je-error { |
||||
|
position: relative !important; |
||||
|
display: inline-block !important; |
||||
|
color: transparent !important; |
||||
|
font-size: 0 !important; |
||||
|
width: 0 !important; |
||||
|
height: 0 !important; |
||||
|
overflow: visible !important; |
||||
|
} |
||||
|
|
||||
|
.je-error::after { |
||||
|
content: attr(data-content) !important; |
||||
|
position: absolute !important; |
||||
|
bottom: 125% !important; |
||||
|
right: 0 !important; |
||||
|
visibility: hidden !important; |
||||
|
width: 200px !important; |
||||
|
background-color: rgb(239, 68, 68) !important; |
||||
|
color: white !important; |
||||
|
text-align: center !important; |
||||
|
border-radius: 0.375rem !important; |
||||
|
padding: 0.5rem 0.75rem !important; |
||||
|
font-size: 0.8125rem !important; |
||||
|
font-weight: 500 !important; |
||||
|
opacity: 0 !important; |
||||
|
transition: opacity 0.3s !important; |
||||
|
z-index: 100 !important; |
||||
|
pointer-events: none !important; |
||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important; |
||||
|
} |
||||
|
|
||||
|
/* Show tooltip on hover over the input */ |
||||
|
.je-error+input:hover+.je-error::after, |
||||
|
.je-error+select:hover+.je-error::after, |
||||
|
.je-error+textarea:hover+.je-error::after, |
||||
|
.has-error .modern-form-control:hover+.je-error::after { |
||||
|
visibility: visible !important; |
||||
|
opacity: 1 !important; |
||||
|
} |
||||
|
|
||||
|
/* Arrow for tooltip */ |
||||
|
.je-error::before { |
||||
|
content: "" !important; |
||||
|
position: absolute !important; |
||||
|
bottom: 125% !important; |
||||
|
right: 10px !important; |
||||
|
visibility: hidden !important; |
||||
|
border-width: 5px !important; |
||||
|
border-style: solid !important; |
||||
|
border-color: rgb(239, 68, 68) transparent transparent transparent !important; |
||||
|
opacity: 0 !important; |
||||
|
transition: opacity 0.3s !important; |
||||
|
} |
||||
|
|
||||
|
.je-error+input:hover+.je-error::before, |
||||
|
.je-error+select:hover+.je-error::before, |
||||
|
.je-error+textarea:hover+.je-error::before, |
||||
|
.has-error .modern-form-control:hover+.je-error::before { |
||||
|
visibility: visible !important; |
||||
|
opacity: 1 !important; |
||||
|
} |
||||
|
|
||||
|
/* Delete button styling */ |
||||
|
.json-editor-btn-delete { |
||||
|
background-color: rgb(1, 53, 59) !important; |
||||
|
/* Unfold secondary-500 */ |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-delete:hover { |
||||
|
background-color: rgb(1, 43, 48) !important; |
||||
|
/* Unfold secondary-600 */ |
||||
|
} |
||||
|
|
||||
|
/* Hide Delete Last buttons */ |
||||
|
.json-editor-btntype-deletelast { |
||||
|
display: none !important; |
||||
|
} |
||||
|
|
||||
|
/* Move up/down buttons */ |
||||
|
.json-editor-btntype-moveup, |
||||
|
.json-editor-btntype-movedown { |
||||
|
background-color: rgba(1, 53, 59, 0.8) !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btntype-moveup:hover, |
||||
|
.json-editor-btntype-movedown:hover { |
||||
|
background-color: rgb(1, 53, 59) !important; |
||||
|
} |
||||
|
|
||||
|
/* Button sections styling */ |
||||
|
.json-editor-btngroup, |
||||
|
.json-editor-btn-bar { |
||||
|
display: flex !important; |
||||
|
flex-wrap: wrap !important; |
||||
|
gap: 0.375rem !important; |
||||
|
margin-top: 1rem !important; |
||||
|
padding: 0.625rem !important; |
||||
|
background-color: rgba(1, 53, 59, 0.03) !important; |
||||
|
/* More subtle background */ |
||||
|
border: 1px solid rgba(1, 53, 59, 0.08) !important; |
||||
|
/* Subtle border */ |
||||
|
border-radius: 0.5rem !important; |
||||
|
visibility: visible !important; |
||||
|
width: 100% !important; |
||||
|
justify-content: flex-end !important; |
||||
|
order: 999 !important; |
||||
|
/* Ensure it appears at the bottom */ |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important; |
||||
|
/* Subtle shadow */ |
||||
|
} |
||||
|
|
||||
|
/* Modern button group styling */ |
||||
|
.modern-btn-group { |
||||
|
display: inline-flex !important; |
||||
|
gap: 0.25rem !important; |
||||
|
margin: 0.125rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Form controls - Unfold theme */ |
||||
|
.modern-form-control { |
||||
|
width: 100% !important; |
||||
|
border: 1px solid rgba(1, 53, 59, 0.15) !important; |
||||
|
border-radius: 0.375rem !important; |
||||
|
padding: 0.625rem 0.875rem !important; |
||||
|
font-size: 0.875rem !important; |
||||
|
line-height: 1.5 !important; |
||||
|
color: rgb(1, 53, 59) !important; |
||||
|
background-color: #fff !important; |
||||
|
transition: all 0.2s ease !important; |
||||
|
appearance: none !important; |
||||
|
margin-bottom: 0.875rem !important; |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-form-control:focus { |
||||
|
border-color: rgb(37, 208, 118) !important; |
||||
|
box-shadow: 0 0 0 3px rgba(37, 208, 118, 0.15) !important; |
||||
|
outline: none !important; |
||||
|
} |
||||
|
|
||||
|
.modern-form-control::placeholder { |
||||
|
color: rgba(1, 53, 59, 0.4) !important; |
||||
|
} |
||||
|
|
||||
|
/* Form labels */ |
||||
|
label, |
||||
|
.je-label { |
||||
|
font-size: 0.875rem !important; |
||||
|
font-weight: 500 !important; |
||||
|
color: rgb(1, 53, 59) !important; |
||||
|
margin-bottom: 0.375rem !important; |
||||
|
display: block !important; |
||||
|
} |
||||
|
|
||||
|
/* Button group styling */ |
||||
|
.modern-btn-group { |
||||
|
display: flex !important; |
||||
|
flex-wrap: nowrap !important; |
||||
|
align-items: center !important; |
||||
|
justify-content: flex-end !important; |
||||
|
gap: 0.5rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Error message styling */ |
||||
|
.invalid-feedback { |
||||
|
color: #ef4444 !important; |
||||
|
font-size: 0.875rem !important; |
||||
|
margin-top: 0.25rem !important; |
||||
|
margin-bottom: 0.5rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Labels */ |
||||
|
label, |
||||
|
.je-label { |
||||
|
display: block !important; |
||||
|
margin-bottom: 0.5rem !important; |
||||
|
font-weight: 500 !important; |
||||
|
color: rgb(1, 53, 59) !important; |
||||
|
font-size: 0.9375rem !important; |
||||
|
} |
||||
|
|
||||
|
/* Form groups */ |
||||
|
.form-group, |
||||
|
.je-object__container { |
||||
|
margin-bottom: 1.5rem !important; |
||||
|
width: 100% !important; |
||||
|
} |
||||
|
|
||||
|
/* Make the JSON editor more responsive */ |
||||
|
@media (max-width: 767px) { |
||||
|
.modern-form-control { |
||||
|
font-size: 0.875rem !important; |
||||
|
padding: 0.5rem 0.625rem !important; |
||||
|
} |
||||
|
|
||||
|
.json-editor-btn-modern { |
||||
|
padding: 0.375rem 0.75rem !important; |
||||
|
font-size: 0.875rem !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-header { |
||||
|
font-size: 0.75rem !important; |
||||
|
padding: 0.625rem 0.75rem !important; |
||||
|
} |
||||
|
|
||||
|
.modern-table-row td { |
||||
|
padding: 0.625rem 0.75rem !important; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* Dark mode support - Using Unfold color scheme */ |
||||
|
@media (prefers-color-scheme: dark) { |
||||
|
.json-editor-container { |
||||
|
background-color: rgb(1, 43, 48) !important; |
||||
|
/* Unfold secondary-600 */ |
||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); |
||||
|
} |
||||
|
|
||||
|
.json-view-editor { |
||||
|
background-color: rgb(1, 43, 48) !important; |
||||
|
/* Unfold secondary-600 */ |
||||
|
} |
||||
|
|
||||
|
.table-responsive { |
||||
|
border-color: rgb(1, 36, 40) !important; |
||||
|
/* Unfold secondary-700 */ |
||||
|
background-color: rgb(1, 43, 48) !important; |
||||
|
/* Unfold secondary-600 */ |
||||
|
} |
||||
|
|
||||
|
.modern-table-header { |
||||
|
background-color: rgb(1, 30, 34) !important; |
||||
|
/* Unfold secondary-800 */ |
||||
|
color: white !important; |
||||
|
border-bottom-color: rgb(0, 26, 29) !important; |
||||
|
/* Unfold secondary-900 */ |
||||
|
} |
||||
|
|
||||
|
.modern-table-row { |
||||
|
border-bottom-color: rgb(1, 30, 34) !important; |
||||
|
/* Unfold secondary-800 */ |
||||
|
} |
||||
|
|
||||
|
.modern-table-row:hover { |
||||
|
background-color: rgb(1, 36, 40) !important; |
||||
|
/* Unfold secondary-700 */ |
||||
|
} |
||||
|
|
||||
|
.modern-table-row td { |
||||
|
color: white !important; |
||||
|
} |
||||
|
|
||||
|
.modern-form-control { |
||||
|
background-color: rgb(1, 36, 40) !important; |
||||
|
/* Unfold secondary-700 */ |
||||
|
border-color: rgb(1, 30, 34) !important; |
||||
|
/* Unfold secondary-800 */ |
||||
|
color: white !important; |
||||
|
} |
||||
|
|
||||
|
label, |
||||
|
.je-label { |
||||
|
color: white !important; |
||||
|
} |
||||
|
|
||||
|
/* Button sections in dark mode */ |
||||
|
.json-editor-btngroup, |
||||
|
.json-editor-btn-bar { |
||||
|
background-color: rgba(1, 30, 34, 0.25) !important; |
||||
|
/* More subtle dark background */ |
||||
|
border: 1px solid rgba(37, 208, 118, 0.1) !important; |
||||
|
/* Subtle primary color border */ |
||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; |
||||
|
} |
||||
|
|
||||
|
.modern-form-control:focus { |
||||
|
border-color: rgb(37, 208, 118) !important; |
||||
|
/* Unfold primary-500 */ |
||||
|
box-shadow: 0 0 0 3px rgba(37, 208, 118, 0.2) !important; |
||||
|
} |
||||
|
|
||||
|
/* Error styling in dark mode */ |
||||
|
.je-error+input, |
||||
|
.je-error+select, |
||||
|
.je-error+textarea, |
||||
|
.has-error .modern-form-control { |
||||
|
border-color: rgb(252, 165, 165) !important; |
||||
|
/* Lighter red for dark mode */ |
||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23fca5a5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); |
||||
|
} |
||||
|
|
||||
|
.je-error::after { |
||||
|
background-color: rgb(185, 28, 28) !important; |
||||
|
/* Darker red background for tooltip */ |
||||
|
color: white !important; |
||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25) !important; |
||||
|
} |
||||
|
|
||||
|
.je-error::before { |
||||
|
border-color: rgb(185, 28, 28) transparent transparent transparent !important; |
||||
|
} |
||||
|
|
||||
|
/* Card styling in dark mode */ |
||||
|
.modern-card { |
||||
|
background-color: rgb(1, 43, 48) !important; |
||||
|
/* Unfold secondary-600 */ |
||||
|
} |
||||
|
|
||||
|
/* Error messages in dark mode */ |
||||
|
.invalid-feedback { |
||||
|
color: #f87171 !important; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,33 @@ |
|||||
|
|
||||
|
{% load unfold i18n %} |
||||
|
|
||||
|
|
||||
|
<div class="grid gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4 "> |
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total Active Users" %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/title.html" with component_class="AllUserComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total Guest Users" %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/title.html" with component_class="GuestUserComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total Students" %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/title.html" with component_class="StudentUserComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/card.html" %} |
||||
|
{% component "unfold/components/text.html" %} |
||||
|
{% trans "Total Professors" %} |
||||
|
{% endcomponent %} |
||||
|
{% component "unfold/components/title.html" with component_class="ProfessorUserComponent" %}{% endcomponent %} |
||||
|
{% endcomponent %} |
||||
|
|
||||
|
</div> |
||||
@ -0,0 +1,47 @@ |
|||||
|
from django.contrib.auth.models import Group |
||||
|
from django.urls import reverse |
||||
|
from rest_framework import status |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
from rest_framework.test import APITestCase |
||||
|
|
||||
|
from apps.account.models import User |
||||
|
|
||||
|
|
||||
|
class UserAuthenticationTests(APITestCase): |
||||
|
def setUp(self): |
||||
|
self.super_admin = User.objects.create_superuser( |
||||
|
email="[email protected]", |
||||
|
password="SuperSecret123!", |
||||
|
fullname="Super Admin", |
||||
|
) |
||||
|
self.regular_user = User.objects.create_user( |
||||
|
email="[email protected]", |
||||
|
password="UserPass123!", |
||||
|
fullname="Regular User", |
||||
|
) |
||||
|
|
||||
|
def test_user_login_success(self): |
||||
|
response = self.client.post( |
||||
|
reverse("user-login"), |
||||
|
{"email": "[email protected]", "password": "UserPass123!"}, |
||||
|
format="json", |
||||
|
) |
||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK) |
||||
|
self.assertIn("token", response.data) |
||||
|
|
||||
|
def test_regular_user_cannot_access_admin_login(self): |
||||
|
response = self.client.post( |
||||
|
reverse("admin-login"), |
||||
|
{"email": "[email protected]", "password": "UserPass123!"}, |
||||
|
format="json", |
||||
|
) |
||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) |
||||
|
|
||||
|
def test_super_admin_can_access_admin_login(self): |
||||
|
response = self.client.post( |
||||
|
reverse("admin-login"), |
||||
|
{"email": "[email protected]", "password": "SuperSecret123!"}, |
||||
|
format="json", |
||||
|
) |
||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK) |
||||
|
self.assertIn("token", response.data) |
||||
@ -0,0 +1,47 @@ |
|||||
|
from django.urls import path, include |
||||
|
from rest_framework.routers import SimpleRouter |
||||
|
from apps.account import views |
||||
|
|
||||
|
admin_router = SimpleRouter() |
||||
|
admin_router.register(r'users', views.AdminUserViewSet, basename='admin-users') |
||||
|
admin_router.register(r'directory-users', views.AdminUserDirectoryViewSet, basename='admin-directory-users') |
||||
|
admin_router.register(r'notifications', views.AdminNotificationViewSet, basename='admin-notifications') |
||||
|
admin_router.register(r'notification-templates', views.AdminNotificationTemplateViewSet, basename='admin-notification-templates') |
||||
|
|
||||
|
# Hide admin viewsets from swagger |
||||
|
for prefix, viewset, basename in admin_router.registry: |
||||
|
viewset.swagger_schema = None |
||||
|
views.AdminLoginView.swagger_schema = None |
||||
|
|
||||
|
urlpatterns = [ |
||||
|
# Auth & Registration |
||||
|
path('register/', views.UserRegisterView.as_view(), name='user-register'), |
||||
|
path('web/register/', views.WebUserRegisterView.as_view(), name='web-user-register'), |
||||
|
path('verify/', views.UserVerifyView.as_view(), name='user-verify'), |
||||
|
path('login/', views.UserLoginView.as_view(), name='user-login'), |
||||
|
path('guest/', views.UserGuestView.as_view(), name='user-guest'), |
||||
|
path('exchange-token/', views.ExchangeTokenAPIView.as_view(), name='exchange-token'), |
||||
|
|
||||
|
# Profile Management |
||||
|
path('profile/', views.UserProfileView.as_view(), name='user-profile'), |
||||
|
path('profile/update/', views.UserUpdateView.as_view(), name='user-update'), |
||||
|
path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'), |
||||
|
path('update-fcm/', views.UpdateFCMView.as_view(), name='update-fcm'), |
||||
|
|
||||
|
# Password Recovery |
||||
|
path('recover/', views.UserRecoverPassword.as_view(), name='user-recover'), |
||||
|
path('reset/', views.UserResetPassword.as_view(), name='user-reset'), |
||||
|
|
||||
|
# Notifications |
||||
|
path('notif/', views.NotificationListView.as_view(), name='user-notif'), |
||||
|
path('notif/read/', views.NotificationReadAllView.as_view(), name='user-notif-read-all'), |
||||
|
path('notif/send/', views.SendNotificationView.as_view(), name='user-send-notif'), |
||||
|
|
||||
|
# Location & Region Info |
||||
|
path('location-update/', views.LocationHistoryView.as_view(), name='user-location-history'), |
||||
|
path('region-info/', views.RegionInfoView.as_view(), name='region-info'), |
||||
|
|
||||
|
# Admin API Endpoints |
||||
|
path('admin/login/', views.AdminLoginView.as_view(), name='admin-login'), |
||||
|
path('admin/', include(admin_router.urls)), |
||||
|
] |
||||
@ -0,0 +1,4 @@ |
|||||
|
from .user import * |
||||
|
from .notification import * |
||||
|
from .auth import * |
||||
|
from .location_history import* |
||||
@ -0,0 +1,163 @@ |
|||||
|
import logging |
||||
|
|
||||
|
from django.contrib.auth import get_user_model |
||||
|
|
||||
|
from drf_yasg import openapi |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from rest_framework import status |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
from rest_framework.generics import GenericAPIView |
||||
|
from rest_framework.permissions import AllowAny |
||||
|
from rest_framework.response import Response |
||||
|
|
||||
|
from apps.account.serializers import ExchangeTokenSerializer |
||||
|
from utils import absolute_url |
||||
|
from utils.redis import OnlineClassTokenManager |
||||
|
|
||||
|
|
||||
|
from django.utils import timezone |
||||
|
from utils.ip_helper import get_client_ip |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
UserModel = get_user_model() |
||||
|
|
||||
|
|
||||
|
def detect_device_os(user_agent): |
||||
|
if not user_agent: |
||||
|
return None |
||||
|
ua = user_agent.lower() |
||||
|
if 'android' in ua: |
||||
|
return 'android' |
||||
|
if 'iphone' in ua or 'ipad' in ua or 'ios' in ua: |
||||
|
return 'apple' |
||||
|
if any(k in ua for k in ['mozilla', 'chrome', 'safari', 'windows', 'macintosh', 'linux']): |
||||
|
return 'web' |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
class ExchangeTokenAPIView(GenericAPIView): |
||||
|
""" |
||||
|
تبدیل temporary token به اطلاعات کاربر برای ورود از اپ موبایل |
||||
|
""" |
||||
|
permission_classes = [AllowAny] |
||||
|
serializer_class = ExchangeTokenSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Exchange temporary token for user information and authentication token.", |
||||
|
request_body=ExchangeTokenSerializer, |
||||
|
responses={ |
||||
|
status.HTTP_200_OK: openapi.Response( |
||||
|
description="Token exchanged successfully.", |
||||
|
examples={ |
||||
|
"application/json": { |
||||
|
"success": True, |
||||
|
"message": "ورود موفق", |
||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", |
||||
|
"user": { |
||||
|
"id": 123, |
||||
|
"fullname": "علی احمدی", |
||||
|
"email": "[email protected]", |
||||
|
"avatar": "https://cdn.example.com/avatar.jpg" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
), |
||||
|
status.HTTP_400_BAD_REQUEST: openapi.Response( |
||||
|
description="Invalid request.", |
||||
|
examples={ |
||||
|
"application/json": { |
||||
|
"success": False, |
||||
|
"message": "توکن ارسال نشده است" |
||||
|
} |
||||
|
} |
||||
|
), |
||||
|
status.HTTP_404_NOT_FOUND: openapi.Response( |
||||
|
description="Token not found or expired.", |
||||
|
examples={ |
||||
|
"application/json": { |
||||
|
"success": False, |
||||
|
"message": "توکن نامعتبر یا منقضی شده است" |
||||
|
} |
||||
|
} |
||||
|
), |
||||
|
} |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
|
||||
|
temp_token = serializer.validated_data['temp_token'] |
||||
|
|
||||
|
# دریافت اطلاعات از Redis/Cache |
||||
|
manager = OnlineClassTokenManager() |
||||
|
try: |
||||
|
token_data = manager.get_payload(temp_token) |
||||
|
except Exception: |
||||
|
return Response({ |
||||
|
'success': False, |
||||
|
'message': 'توکن نامعتبر یا منقضی شده است' |
||||
|
}, status=status.HTTP_404_NOT_FOUND) |
||||
|
|
||||
|
user_id = token_data.get('user_id') |
||||
|
if not user_id: |
||||
|
return Response({ |
||||
|
'success': False, |
||||
|
'message': 'توکن نامعتبر است' |
||||
|
}, status=status.HTTP_400_BAD_REQUEST) |
||||
|
|
||||
|
# دریافت کاربر |
||||
|
try: |
||||
|
user = UserModel.objects.get(id=user_id) |
||||
|
except UserModel.DoesNotExist: |
||||
|
return Response({ |
||||
|
'success': False, |
||||
|
'message': 'کاربر یافت نشد' |
||||
|
}, status=status.HTTP_404_NOT_FOUND) |
||||
|
|
||||
|
# حذف توکن موقت (one-time use) |
||||
|
manager.delete_token(temp_token) |
||||
|
|
||||
|
# دریافت یا تولید Token واقعی کاربر |
||||
|
auth_token, _ = Token.objects.get_or_create(user=user) |
||||
|
|
||||
|
client_ip = get_client_ip(request) |
||||
|
user_agent = request.META.get('HTTP_USER_AGENT', '') |
||||
|
device_os = token_data.get('device_os') |
||||
|
if not device_os and user_agent: |
||||
|
device_os = detect_device_os(user_agent) |
||||
|
|
||||
|
user.last_login = timezone.now() |
||||
|
if client_ip: |
||||
|
user.client_ip = client_ip |
||||
|
if user_agent: |
||||
|
user.user_agent = user_agent |
||||
|
if device_os: |
||||
|
user.device_os = device_os |
||||
|
user.save() |
||||
|
|
||||
|
user.login_history.create( |
||||
|
ip=client_ip, |
||||
|
user_agent=user_agent, |
||||
|
device_os=user.device_os or device_os, |
||||
|
) |
||||
|
|
||||
|
# دریافت avatar URL |
||||
|
avatar_url = None |
||||
|
if hasattr(user, 'avatar') and user.avatar: |
||||
|
try: |
||||
|
avatar_url = absolute_url(user.avatar.url) |
||||
|
except Exception: |
||||
|
avatar_url = None |
||||
|
|
||||
|
# برگرداندن اطلاعات کاربر با token واقعی |
||||
|
return Response({ |
||||
|
'success': True, |
||||
|
'message': 'ورود موفق', |
||||
|
'token': auth_token.key, |
||||
|
'user': { |
||||
|
'id': user.id, |
||||
|
'fullname': user.get_full_name() or user.username or '', |
||||
|
'email': user.email or '', |
||||
|
'avatar': avatar_url |
||||
|
} |
||||
|
}, status=status.HTTP_200_OK) |
||||
@ -0,0 +1,69 @@ |
|||||
|
import logging |
||||
|
import re |
||||
|
from rest_framework.mixins import CreateModelMixin |
||||
|
from rest_framework.permissions import IsAuthenticated |
||||
|
from rest_framework.authentication import TokenAuthentication |
||||
|
from rest_framework.generics import GenericAPIView |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework import status |
||||
|
from apps.account.models import LocationHistory |
||||
|
from apps.account.serializers import LocationHistorySerializer |
||||
|
from utils.ip_helper import get_client_ip |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
def detect_browser_from_user_agent(user_agent): |
||||
|
if not user_agent: |
||||
|
return None |
||||
|
try: |
||||
|
ua = user_agent.lower() |
||||
|
patterns = [ |
||||
|
(r'edg/', 'Edge'), |
||||
|
(r'opr/', 'Opera'), |
||||
|
(r'chrome/', 'Chrome'), |
||||
|
(r'firefox/', 'Firefox'), |
||||
|
(r'safari/', 'Safari'), |
||||
|
] |
||||
|
for pattern, browser in patterns: |
||||
|
if re.search(pattern, ua): |
||||
|
return browser |
||||
|
return 'Unknown' |
||||
|
except Exception: |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
class LocationHistoryView(GenericAPIView, CreateModelMixin): |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
serializer_class = LocationHistorySerializer |
||||
|
|
||||
|
def post(self, request, *args, **kwargs): |
||||
|
ip = get_client_ip(request) |
||||
|
data = request.data.copy() |
||||
|
data['ip'] = ip |
||||
|
serializer = self.get_serializer(data=data) |
||||
|
|
||||
|
if serializer.is_valid(): |
||||
|
serializer.save(user=request.user) |
||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED) |
||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
return LocationHistory.objects.filter(user=self.request.user) |
||||
|
|
||||
|
|
||||
|
class RegionInfoView(GenericAPIView): |
||||
|
""" |
||||
|
Returns basic client region, browser, and network info |
||||
|
""" |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
user_agent = request.META.get('HTTP_USER_AGENT', '') |
||||
|
client_ip = get_client_ip(request) |
||||
|
browser = detect_browser_from_user_agent(user_agent) |
||||
|
|
||||
|
return Response({ |
||||
|
'ip': client_ip, |
||||
|
'browser': browser, |
||||
|
'user_agent': user_agent, |
||||
|
}) |
||||
@ -0,0 +1,103 @@ |
|||||
|
from rest_framework import generics, status |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework.authentication import TokenAuthentication |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from drf_yasg import openapi |
||||
|
from rest_framework.permissions import IsAuthenticated |
||||
|
from rest_framework.viewsets import ModelViewSet |
||||
|
|
||||
|
from apps.account.serializers import ( |
||||
|
NotificationSerializer, |
||||
|
NotificationSendSerializer, |
||||
|
AdminNotificationSerializer, |
||||
|
NotificationTemplateSerializer |
||||
|
) |
||||
|
from apps.account.models import Notification, User, NotificationTemplate |
||||
|
from apps.account.tasks import send_notification_task |
||||
|
from utils.pagination import StandardResultsSetPagination |
||||
|
from apps.account.permissions import IsSuperAdmin |
||||
|
import logging |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class NotificationListView(generics.ListAPIView): |
||||
|
serializer_class = NotificationSerializer |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
pagination_class = StandardResultsSetPagination |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Retrieve a list of notifications for the authenticated user.", |
||||
|
tags=['Notifications'], |
||||
|
) |
||||
|
def get(self, request, *args, **kwargs): |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
return Notification.objects.filter(user=self.request.user).order_by('-created_at') |
||||
|
|
||||
|
|
||||
|
class NotificationReadAllView(generics.GenericAPIView): |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Mark all notifications as read for the authenticated user.", |
||||
|
tags=['Notifications'], |
||||
|
responses={200: "All notifications marked as read"} |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
Notification.objects.filter(user=request.user, is_read=False).update(is_read=True) |
||||
|
return Response({'status': 'all notifications marked as read'}, status=status.HTTP_200_OK) |
||||
|
|
||||
|
|
||||
|
class SendNotificationView(generics.GenericAPIView): |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Dispatch an in-app notification to a specific user.", |
||||
|
tags=['Notifications'], |
||||
|
request_body=openapi.Schema( |
||||
|
type=openapi.TYPE_OBJECT, |
||||
|
required=['user_id', 'title', 'body'], |
||||
|
properties={ |
||||
|
'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='Target User ID'), |
||||
|
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Notification title'), |
||||
|
'body': openapi.Schema(type=openapi.TYPE_STRING, description='Notification body'), |
||||
|
'data': openapi.Schema(type=openapi.TYPE_OBJECT, description='Extra payload data'), |
||||
|
}, |
||||
|
), |
||||
|
responses={ |
||||
|
200: openapi.Response('Notification dispatched.'), |
||||
|
404: openapi.Response('User not found.'), |
||||
|
} |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
user_id = request.data.get('user_id') |
||||
|
title = request.data.get('title') |
||||
|
body = request.data.get('body') |
||||
|
data = request.data.get('data', {}) |
||||
|
|
||||
|
if not User.objects.filter(id=user_id).exists(): |
||||
|
return Response({'error': 'User not found.'}, status=status.HTTP_404_NOT_FOUND) |
||||
|
|
||||
|
send_notification_task.delay(user_id=user_id, title=title, body=body, data=data) |
||||
|
return Response({'status': 'notification queued'}, status=status.HTTP_200_OK) |
||||
|
|
||||
|
|
||||
|
class AdminNotificationViewSet(ModelViewSet): |
||||
|
queryset = Notification.objects.all().select_related('user') |
||||
|
serializer_class = AdminNotificationSerializer |
||||
|
permission_classes = [IsAuthenticated, IsSuperAdmin] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
pagination_class = StandardResultsSetPagination |
||||
|
|
||||
|
|
||||
|
class AdminNotificationTemplateViewSet(ModelViewSet): |
||||
|
queryset = NotificationTemplate.objects.all() |
||||
|
serializer_class = NotificationTemplateSerializer |
||||
|
permission_classes = [IsAuthenticated, IsSuperAdmin] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
pagination_class = StandardResultsSetPagination |
||||
@ -0,0 +1,942 @@ |
|||||
|
import logging |
||||
|
import requests |
||||
|
import json |
||||
|
from rest_framework.generics import CreateAPIView, RetrieveUpdateAPIView, GenericAPIView, RetrieveAPIView, UpdateAPIView, ListAPIView |
||||
|
from rest_framework.views import APIView |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework import status |
||||
|
from django.db.models import Q |
||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
from rest_framework.exceptions import AuthenticationFailed |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from django.shortcuts import get_object_or_404 |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
|
||||
|
from django.utils import timezone |
||||
|
from rest_framework.authentication import TokenAuthentication |
||||
|
from django.contrib.auth import authenticate |
||||
|
from phonenumbers import parse, region_code_for_number |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from drf_yasg import openapi |
||||
|
from rest_framework.exceptions import ValidationError |
||||
|
|
||||
|
from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException |
||||
|
from apps.account.models import User |
||||
|
from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer,UserFCMSerializer,WebUserGuestSerializer, AdminUserSerializer |
||||
|
from apps.account.serializers.user_web import WebUserRegisterSerializer |
||||
|
from utils.redis import RedisManager |
||||
|
from utils.exceptions import AppAPIException |
||||
|
from utils import send_email, is_valid_email, absolute_https_url |
||||
|
from config.settings import base as settings |
||||
|
from apps.account.permissions import IsActiveUser, IsSuperAdminOrReadOnlyForProfessor |
||||
|
from apps.account.doc import * |
||||
|
from utils.ip_helper import get_client_ip |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
def detect_device_os(user_agent): |
||||
|
if not user_agent: |
||||
|
return None |
||||
|
ua = user_agent.lower() |
||||
|
if 'android' in ua: |
||||
|
return 'android' |
||||
|
if 'iphone' in ua or 'ipad' in ua or 'ios' in ua: |
||||
|
return 'apple' |
||||
|
if any(k in ua for k in ['mozilla', 'chrome', 'safari', 'windows', 'macintosh', 'linux']): |
||||
|
return 'web' |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserGuestView(CreateAPIView): |
||||
|
permission_classes = [AllowAny] |
||||
|
authentication_classes = [] |
||||
|
serializer_class = UserGuestSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Create a guest user account with device information", |
||||
|
request_body=openapi.Schema( |
||||
|
type=openapi.TYPE_OBJECT, |
||||
|
properties={ |
||||
|
"device_id": openapi.Schema(type=openapi.TYPE_STRING, default="c9f0c1f4f5cee3d7"), |
||||
|
"fcm": openapi.Schema(type=openapi.TYPE_STRING, default=""), |
||||
|
"device_os": openapi.Schema(type=openapi.TYPE_STRING, default="android"), |
||||
|
"lat": openapi.Schema(type=openapi.TYPE_STRING, default="56"), |
||||
|
"lon": openapi.Schema(type=openapi.TYPE_STRING, default="44"), |
||||
|
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"), |
||||
|
}, |
||||
|
required=["device_id"], |
||||
|
), |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
logger.info(f'GuestAuthView--> {request.data}') |
||||
|
return super().post(request, *args, **kwargs) |
||||
|
|
||||
|
@staticmethod |
||||
|
def generate_login_token(user): |
||||
|
token, created = Token.objects.update_or_create(user=user) |
||||
|
return token.key |
||||
|
|
||||
|
def get_client_ip(self): |
||||
|
request = self.request |
||||
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') |
||||
|
if x_forwarded_for: |
||||
|
ip = x_forwarded_for.split(',')[0] |
||||
|
else: |
||||
|
ip = request.META.get('REMOTE_ADDR') |
||||
|
return ip |
||||
|
|
||||
|
|
||||
|
def create(self, request, *args, **kwargs): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
user = self.perform_create(serializer) |
||||
|
return Response({ |
||||
|
'token': self.generate_login_token(user), |
||||
|
}, status=200) |
||||
|
|
||||
|
|
||||
|
def perform_create(self, serializer): |
||||
|
device_id = serializer.validated_data.get('device_id') |
||||
|
device_os = serializer.validated_data.get('device_os') |
||||
|
fcm = serializer.validated_data.get('fcm') |
||||
|
lat = serializer.validated_data.pop('lat', None) |
||||
|
lon = serializer.validated_data.pop('lon', None) |
||||
|
user_timezone = serializer.validated_data.pop('timezone', None) |
||||
|
client_ip = get_client_ip(self.request) |
||||
|
user_agent = self.request.META.get('HTTP_USER_AGENT', '') |
||||
|
|
||||
|
serializer_data = dict(serializer.validated_data) |
||||
|
|
||||
|
obj = User.objects.select_for_update().filter(Q(device_id=device_id)).first() |
||||
|
if not obj: |
||||
|
obj, created = User.objects.select_for_update().get_or_create( |
||||
|
device_id=device_id, |
||||
|
defaults=serializer_data |
||||
|
) |
||||
|
if created: |
||||
|
logger.info(f'Guest-(created)->: {obj.device_id}') |
||||
|
|
||||
|
obj.last_login = timezone.now() |
||||
|
if client_ip: |
||||
|
obj.client_ip = client_ip |
||||
|
if user_agent: |
||||
|
obj.user_agent = user_agent |
||||
|
if device_os: |
||||
|
obj.device_os = device_os |
||||
|
elif not obj.device_os and user_agent: |
||||
|
obj.device_os = detect_device_os(user_agent) |
||||
|
if fcm: |
||||
|
obj.fcm = fcm |
||||
|
obj.save() |
||||
|
login_history_obj = obj.login_history.create( |
||||
|
lat=lat, |
||||
|
lon=lon, |
||||
|
ip=client_ip, |
||||
|
timezone=user_timezone, |
||||
|
user_agent=user_agent, |
||||
|
device_os=obj.device_os, |
||||
|
) |
||||
|
return obj |
||||
|
|
||||
|
|
||||
|
import hashlib |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
|
||||
|
class WebUserGuestView(CreateAPIView): |
||||
|
permission_classes = [AllowAny] |
||||
|
authentication_classes = [] |
||||
|
serializer_class = WebUserGuestSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Create a guest user account for web users using IP and user agent", |
||||
|
request_body=openapi.Schema( |
||||
|
type=openapi.TYPE_OBJECT, |
||||
|
properties={ |
||||
|
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"), |
||||
|
"user_agent": openapi.Schema(type=openapi.TYPE_STRING, default="Mozilla/5.0..."), |
||||
|
}, |
||||
|
required=[], # No required fields - we'll extract from request |
||||
|
), |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
logger.info(f'WebGuestAuthView--> IP: {self.get_client_ip()}, User-Agent: {self.get_user_agent()}') |
||||
|
return super().post(request, *args, **kwargs) |
||||
|
|
||||
|
@staticmethod |
||||
|
def generate_login_token(user): |
||||
|
# ✅ FIX 2: Prevent token rotation on every login |
||||
|
token, created = Token.objects.get_or_create(user=user) |
||||
|
return token.key |
||||
|
|
||||
|
def get_client_ip(self): |
||||
|
"""Get client IP address from request""" |
||||
|
request = self.request |
||||
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') |
||||
|
if x_forwarded_for: |
||||
|
ip = x_forwarded_for.split(',')[0] |
||||
|
else: |
||||
|
ip = request.META.get('REMOTE_ADDR') |
||||
|
return ip |
||||
|
|
||||
|
def get_user_agent(self): |
||||
|
"""Get user agent from request headers""" |
||||
|
return self.request.META.get('HTTP_USER_AGENT', '') |
||||
|
|
||||
|
def create(self, request, *args, **kwargs): |
||||
|
data = request.data.copy() |
||||
|
client_ip = self.get_client_ip() |
||||
|
user_agent = self.get_user_agent() |
||||
|
|
||||
|
# ✅ FIX 1: Stable Hash (MD5) instead of random hash() |
||||
|
ua_hash = hashlib.md5(user_agent.encode('utf-8')).hexdigest()[:8] |
||||
|
web_user_id = f"{client_ip}_{ua_hash}" |
||||
|
|
||||
|
data.update({ |
||||
|
'device_id': web_user_id, |
||||
|
'device_os': 'web', |
||||
|
'user_agent': user_agent, |
||||
|
'client_ip': client_ip, |
||||
|
}) |
||||
|
|
||||
|
serializer = self.get_serializer(data=data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
user = self.perform_create(serializer) |
||||
|
return Response({ |
||||
|
'token': self.generate_login_token(user), |
||||
|
}, status=200) |
||||
|
|
||||
|
|
||||
|
def perform_create(self, serializer): |
||||
|
# Extract web-specific data |
||||
|
user_timezone = serializer.validated_data.pop('timezone', None) |
||||
|
device_id = serializer.validated_data.get('device_id') |
||||
|
user_agent = serializer.validated_data.get('user_agent') |
||||
|
client_ip = serializer.validated_data.get('client_ip') |
||||
|
|
||||
|
serializer_data = dict(serializer.validated_data) |
||||
|
|
||||
|
# Find or create user based on device_id (which is IP + hashed user agent) |
||||
|
obj = User.objects.select_for_update().filter(Q(device_id=device_id)).first() |
||||
|
if not obj: |
||||
|
obj, created = User.objects.select_for_update().get_or_create( |
||||
|
device_id=device_id, |
||||
|
defaults=serializer_data |
||||
|
) |
||||
|
if created: |
||||
|
logger.info(f'WebGuest-(created)->: {device_id} (IP: {client_ip})') |
||||
|
|
||||
|
# Update user on each login |
||||
|
obj.last_login = timezone.now() |
||||
|
obj.user_agent = user_agent # Update user agent on each login |
||||
|
obj.client_ip = client_ip # Update IP on each login |
||||
|
obj.save() |
||||
|
|
||||
|
# Create login history |
||||
|
login_history_obj = obj.login_history.create( |
||||
|
ip=client_ip, |
||||
|
user_agent=user_agent, |
||||
|
timezone=user_timezone, |
||||
|
device_os='web', |
||||
|
) |
||||
|
return obj |
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserRegisterView(CreateAPIView): |
||||
|
permission_classes = [AllowAny] |
||||
|
authentication_classes = [] |
||||
|
serializer_class = UserRegisterSerializer |
||||
|
|
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description=doc_register(), |
||||
|
request_body=UserRegisterSerializer, |
||||
|
) |
||||
|
def post(self, request): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
data = serializer.validated_data |
||||
|
|
||||
|
code = RedisManager.generate_otp_code() |
||||
|
logger.info(f"phone= {data['email']}") |
||||
|
print(f'send {code}/{data["email"]}') |
||||
|
phone_number = RedisManager().add_to_redis(code, **data) |
||||
|
try: |
||||
|
send_email([data['email']], code) |
||||
|
except Exception as exp: |
||||
|
print(f'-exp-register-->{exp}') |
||||
|
return Response( |
||||
|
data= { |
||||
|
"user": data, |
||||
|
"message": "The otp code was sent to the user's email" |
||||
|
}, |
||||
|
status=status.HTTP_202_ACCEPTED, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserVerifyView(CreateAPIView): |
||||
|
permission_classes = [AllowAny] |
||||
|
authentication_classes = [] |
||||
|
serializer_class = UserVerifySerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description=doc_verify(), |
||||
|
request_body=UserVerifySerializer, |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
print(f'-UserVerifyView-> {request.data}') |
||||
|
return super().post(request, *args, **kwargs) |
||||
|
|
||||
|
def create(self, request, *args, **kwargs): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
data = serializer.data |
||||
|
print(f'--UserVerifyView---1--') |
||||
|
try: |
||||
|
verify_data = RedisManager().get_by_redis(data['email']) |
||||
|
if not verify_data: |
||||
|
raise ValidationError({"code": "Verification data not found or expired."}) |
||||
|
# raise ExpiredCodeException("Verification data not found or expired.") |
||||
|
except (ServiceUnavailableException) as e: |
||||
|
return AppAPIException({"message": str(e)}, status_code=e.status_code) |
||||
|
except ExpiredCodeException: |
||||
|
# raise ExpiredCodeException("The verification code has expired.") |
||||
|
raise ValidationError({"code": "The verification code has expired."}) |
||||
|
|
||||
|
code = self.valied_code(data['code'], verify_data.get('code')) |
||||
|
verify_data.pop('code', None) |
||||
|
email = serializer.data['email'] |
||||
|
device_id = serializer.data.get('device_id') |
||||
|
verify_data.pop('email', None) |
||||
|
verify_data.pop('device_id', None) |
||||
|
user = self.perform_create( |
||||
|
email=email, |
||||
|
device_id=device_id, |
||||
|
client_ip=get_client_ip(request), |
||||
|
user_agent=request.META.get('HTTP_USER_AGENT', ''), |
||||
|
device_os=request.data.get('device_os'), |
||||
|
**verify_data |
||||
|
) |
||||
|
token, _ = Token.objects.get_or_create(user=user) |
||||
|
return Response(data={ |
||||
|
'token': str(token.key), |
||||
|
'user_id': user.id, |
||||
|
'phone_number': str(user.phone_number) if user.phone_number else None, |
||||
|
'email': str(user.email), |
||||
|
'fullname': str(user.fullname), |
||||
|
'avatar': str(user.avatar) if user.avatar else None |
||||
|
}, status=status.HTTP_201_CREATED) |
||||
|
|
||||
|
def valied_code(self, current_code, save_code): |
||||
|
if not current_code or not save_code or str(current_code) != str(save_code): |
||||
|
raise ValidationError({"code": "code notfound"}) |
||||
|
|
||||
|
return current_code |
||||
|
|
||||
|
def perform_create(self, *args, **kwargs): |
||||
|
email = kwargs.get('email') |
||||
|
device_id = kwargs.get('device_id') |
||||
|
password = kwargs.get('password') |
||||
|
client_ip = kwargs.get('client_ip') |
||||
|
user_agent = kwargs.get('user_agent') |
||||
|
device_os = kwargs.get('device_os') |
||||
|
if not device_os and user_agent: |
||||
|
device_os = detect_device_os(user_agent) |
||||
|
|
||||
|
create_kwargs = dict(kwargs) |
||||
|
create_kwargs.pop('password', None) |
||||
|
create_kwargs.pop('client_ip', None) |
||||
|
create_kwargs.pop('user_agent', None) |
||||
|
create_kwargs.pop('device_os', None) |
||||
|
|
||||
|
user = User.objects.filter(email=email).first() |
||||
|
if user: |
||||
|
if password and str(password).lower() != 'none': |
||||
|
user.set_password(password) |
||||
|
user.is_active = True |
||||
|
user.deleted_at = None |
||||
|
if device_id: |
||||
|
user.device_id = device_id |
||||
|
if client_ip: |
||||
|
user.client_ip = client_ip |
||||
|
if user_agent: |
||||
|
user.user_agent = user_agent |
||||
|
if device_os: |
||||
|
user.device_os = device_os |
||||
|
user.last_login = timezone.now() |
||||
|
user.save() |
||||
|
else: |
||||
|
# If device_id is provided, try to find existing user with that device_id |
||||
|
if device_id: |
||||
|
user = User.objects.filter(device_id=device_id, email__isnull=True).first() |
||||
|
else: |
||||
|
user = None |
||||
|
|
||||
|
if not user: |
||||
|
user = User(**create_kwargs) |
||||
|
if password and str(password).lower() != 'none': |
||||
|
user.set_password(password) |
||||
|
else: |
||||
|
user.set_unusable_password() |
||||
|
else: |
||||
|
user.email = email |
||||
|
user.fullname = kwargs.get('fullname') |
||||
|
if password and str(password).lower() != 'none': |
||||
|
user.set_password(password) |
||||
|
|
||||
|
if device_id: |
||||
|
user.device_id = device_id |
||||
|
if client_ip: |
||||
|
user.client_ip = client_ip |
||||
|
if user_agent: |
||||
|
user.user_agent = user_agent |
||||
|
if device_os: |
||||
|
user.device_os = device_os |
||||
|
user.last_login = timezone.now() |
||||
|
user.is_active = True |
||||
|
user.deleted_at = None |
||||
|
user.save() |
||||
|
|
||||
|
user.login_history.create( |
||||
|
ip=client_ip, |
||||
|
user_agent=user_agent, |
||||
|
device_os=user.device_os or device_os, |
||||
|
) |
||||
|
|
||||
|
return user |
||||
|
|
||||
|
|
||||
|
class WebUserRegisterView(CreateAPIView): |
||||
|
permission_classes = [AllowAny] |
||||
|
authentication_classes = [] |
||||
|
serializer_class = WebUserRegisterSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Web registration with password and confirmation", |
||||
|
request_body=WebUserRegisterSerializer, |
||||
|
) |
||||
|
def post(self, request): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
data = serializer.validated_data |
||||
|
|
||||
|
code = RedisManager.generate_otp_code() |
||||
|
logger.info(f"phone= {data['email']}") |
||||
|
print(f'send {code}/{data["email"]}') |
||||
|
# Store all registration data including password in Redis |
||||
|
RedisManager().add_to_redis(code, **data) |
||||
|
try: |
||||
|
send_email([data['email']], code) |
||||
|
except Exception as exp: |
||||
|
print(f'-exp-register-->{exp}') |
||||
|
return Response( |
||||
|
data={ |
||||
|
"user": { |
||||
|
"id": data.get('id'), |
||||
|
"fullname": data.get('fullname'), |
||||
|
"email": data.get('email'), |
||||
|
}, |
||||
|
"message": "The otp code was sent to the user's email" |
||||
|
}, |
||||
|
status=status.HTTP_202_ACCEPTED, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class UserLoginView(CreateAPIView): |
||||
|
permission_classes = [AllowAny] |
||||
|
authentication_classes = [] |
||||
|
serializer_class = UserLoginSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description=doc_login(), |
||||
|
request_body=UserLoginSerializer, |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
return super().post(request, *args, **kwargs) |
||||
|
def get_client_ip(self): |
||||
|
return get_client_ip(self.request) |
||||
|
|
||||
|
def create(self, request, *args, **kwargs): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
data = serializer.validated_data |
||||
|
|
||||
|
# Normalize email |
||||
|
raw_email = data.get('email') or request.data.get('email', '') |
||||
|
email = User.objects.normalize_email(raw_email.strip()) |
||||
|
password = data.get('password') or request.data.get('password', '') |
||||
|
|
||||
|
# Check if user with this email exists |
||||
|
user_obj = User.objects.filter(email__iexact=email, deleted_at__isnull=True).first() |
||||
|
if not user_obj: |
||||
|
raise ValidationError({"email": "user not exists with this email"}) |
||||
|
|
||||
|
# Authenticate with matching user email or normalized email |
||||
|
user = authenticate(request, username=user_obj.email, password=password) |
||||
|
if not user: |
||||
|
user = authenticate(request, username=email, password=password) |
||||
|
|
||||
|
if not user: |
||||
|
raise ValidationError({"password": "password is incorrect"}) |
||||
|
|
||||
|
client_ip = get_client_ip(request) |
||||
|
user_agent = request.META.get('HTTP_USER_AGENT', '') |
||||
|
device_os = request.data.get('device_os') |
||||
|
if not device_os and user_agent: |
||||
|
device_os = detect_device_os(user_agent) |
||||
|
device_id = request.data.get('device_id') |
||||
|
|
||||
|
user_timezone = serializer.validated_data.pop('timezone', None) |
||||
|
user.last_login = timezone.now() |
||||
|
user.is_active = True |
||||
|
if client_ip: |
||||
|
user.client_ip = client_ip |
||||
|
if user_agent: |
||||
|
user.user_agent = user_agent |
||||
|
if device_os: |
||||
|
user.device_os = device_os |
||||
|
if device_id: |
||||
|
user.device_id = device_id |
||||
|
user.save() |
||||
|
token, created = Token.objects.get_or_create(user=user) |
||||
|
|
||||
|
login_history_obj = user.login_history.create( |
||||
|
ip=client_ip, |
||||
|
timezone=user_timezone, |
||||
|
user_agent=user_agent, |
||||
|
device_os=user.device_os or device_os, |
||||
|
) |
||||
|
return Response({ |
||||
|
"id": user.id, |
||||
|
"fullname": user.fullname, |
||||
|
"email": user.email, |
||||
|
"token": token.key, |
||||
|
"user_type": user.user_type, |
||||
|
"avatar": absolute_https_url(user.avatar.url, request) if user.avatar else None, |
||||
|
}, status=status.HTTP_201_CREATED) |
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserProfileView(RetrieveAPIView): |
||||
|
serializer_class = UserProfileSerializer |
||||
|
permission_classes = [IsAuthenticated, IsActiveUser] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
queryset = User.objects.all() |
||||
|
|
||||
|
def get(self, request, *args, **kwargs): |
||||
|
logger.info(f'UserProfileView--> {request.data}') |
||||
|
return super().get(request, *args, **kwargs) |
||||
|
|
||||
|
def get_object(self): |
||||
|
return self.request.user |
||||
|
|
||||
|
|
||||
|
class UserUpdateView(UpdateAPIView): |
||||
|
permission_classes = [IsAuthenticated, IsActiveUser] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
serializer_class = UserProfileSerializer |
||||
|
|
||||
|
def put(self, request, *args, **kwargs): |
||||
|
logger.info(f'UserProfileView--> {request.data}') |
||||
|
return super().put(request, *args, **kwargs) |
||||
|
|
||||
|
def get_object(self): |
||||
|
return self.request.user |
||||
|
|
||||
|
|
||||
|
class UserRecoverPassword(CreateAPIView): |
||||
|
permission_classes = [AllowAny] |
||||
|
authentication_classes = [] |
||||
|
serializer_class = UserRecoverPasswordSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description=doc_recover(), |
||||
|
request_body=UserRecoverPasswordSerializer, |
||||
|
) |
||||
|
def post(self, request): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
data = serializer.data |
||||
|
user = get_object_or_404(User, email=data['email']) |
||||
|
code = RedisManager.generate_otp_code() |
||||
|
print(f' send {code}') |
||||
|
phone_number = RedisManager().add_to_redis(code, fullname=str(user.fullname), password='', email=data['email']) |
||||
|
|
||||
|
try: |
||||
|
send_email([data['email']], code) |
||||
|
except Exception as exp: |
||||
|
print(f'-exp-register-->{exp}') |
||||
|
|
||||
|
return Response( |
||||
|
data= { |
||||
|
"id": user.id, |
||||
|
"fullname": user.fullname, |
||||
|
"phone_number": str(user.phone_number) if user.phone_number else None, |
||||
|
"email": user.email if user.email else None, |
||||
|
"avatar": absolute_https_url(user.avatar.url, request) if user.avatar else None, |
||||
|
"message": "Forgot password code sent" |
||||
|
}, |
||||
|
status=status.HTTP_202_ACCEPTED, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class UserResetPassword(CreateAPIView): |
||||
|
serializer_class = UserResetPasswordSerializer |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description=doc_reset(), |
||||
|
request_body=UserResetPasswordSerializer, |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
# Get the logged-in user |
||||
|
user = request.user |
||||
|
|
||||
|
# Use the serializer to validate data |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
|
||||
|
# Set the new password |
||||
|
user.set_password(serializer.validated_data['password']) |
||||
|
user.save() |
||||
|
|
||||
|
# Return a success response |
||||
|
return Response({"message": "Your password has been changed successfully."}, status=status.HTTP_200_OK) |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
class UserDeleteView(APIView): |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
|
||||
|
def delete(self, request, *args, **kwargs): |
||||
|
try: |
||||
|
user = request.user |
||||
|
if user.email == "[email protected]": |
||||
|
raise AppAPIException({"message": "Unable to log in with provided credentials."}, status_code=status.HTTP_204_NO_CONTENT) |
||||
|
|
||||
|
user.soft_delete() |
||||
|
if t := Token.objects.filter(user=user).first(): |
||||
|
t.delete() |
||||
|
|
||||
|
return Response({"detail": "Your account has been deleted."}, status=status.HTTP_204_NO_CONTENT) |
||||
|
|
||||
|
except Exception: |
||||
|
# پیام خطای ثابت برای سایر خطاهای غیرمنتظره |
||||
|
return Response({"detail": "User does not exist."}, status=status.HTTP_404_NOT_FOUND) |
||||
|
|
||||
|
|
||||
|
class UpdateFCMView(GenericAPIView): |
||||
|
permission_classes = [IsAuthenticated] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
serializer_class = UserFCMSerializer |
||||
|
|
||||
|
def post(self, request, *args, **kwargs): |
||||
|
user = request.user |
||||
|
|
||||
|
fcm_token = request.data.get('fcm') |
||||
|
|
||||
|
if not fcm_token: |
||||
|
return Response({"detail": "FCM token is required."}, status=status.HTTP_200_OK) |
||||
|
|
||||
|
user.fcm = fcm_token |
||||
|
user.save() |
||||
|
|
||||
|
return Response({"detail": "FCM token updated successfully."}, status=status.HTTP_200_OK) |
||||
|
|
||||
|
|
||||
|
class AdminLoginView(CreateAPIView): |
||||
|
permission_classes = [AllowAny] |
||||
|
authentication_classes = [] |
||||
|
serializer_class = UserLoginSerializer |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Login specifically for Admin Panel users", |
||||
|
request_body=UserLoginSerializer, |
||||
|
) |
||||
|
def post(self, request, *args, **kwargs): |
||||
|
return self.create(request, *args, **kwargs) |
||||
|
|
||||
|
def get_client_ip(self): |
||||
|
return get_client_ip(self.request) |
||||
|
|
||||
|
def create(self, request, *args, **kwargs): |
||||
|
serializer = self.get_serializer(data=request.data) |
||||
|
serializer.is_valid(raise_exception=True) |
||||
|
data = serializer.data |
||||
|
|
||||
|
email = request.data['email'].strip().lower() |
||||
|
try: |
||||
|
user_obj = User.objects.get(email__iexact=email) |
||||
|
except User.DoesNotExist: |
||||
|
raise ValidationError({"email": "No admin found with that information."}) |
||||
|
|
||||
|
# Use the actual stored email for authentication (ensures case matches DB) |
||||
|
user = authenticate(request, username=user_obj.email, password=data['password']) |
||||
|
if not user: |
||||
|
raise ValidationError({"password": "Password is incorrect"}) |
||||
|
|
||||
|
if not user.can_access_admin_panel(): |
||||
|
raise AuthenticationFailed("No admin found with that information.") |
||||
|
|
||||
|
client_ip = get_client_ip(request) |
||||
|
user_agent = request.META.get('HTTP_USER_AGENT', '') |
||||
|
user_timezone = serializer.validated_data.pop('timezone', None) |
||||
|
user.last_login = timezone.now() |
||||
|
user.is_active = True |
||||
|
if client_ip: |
||||
|
user.client_ip = client_ip |
||||
|
if user_agent: |
||||
|
user.user_agent = user_agent |
||||
|
user.device_os = 'web' |
||||
|
user.save() |
||||
|
|
||||
|
token, created = Token.objects.get_or_create(user=user) |
||||
|
|
||||
|
# Log the history |
||||
|
user.login_history.create( |
||||
|
ip=client_ip, |
||||
|
timezone=user_timezone, |
||||
|
user_agent=user_agent, |
||||
|
device_os='web_admin' |
||||
|
) |
||||
|
|
||||
|
if user.is_super_admin_panel_user(): |
||||
|
effective_user_type = 'super_admin' |
||||
|
elif user.is_admin_panel_user(): |
||||
|
effective_user_type = 'admin' |
||||
|
else: |
||||
|
effective_user_type = 'professor' |
||||
|
|
||||
|
return Response({ |
||||
|
"id": user.id, |
||||
|
"fullname": user.fullname, |
||||
|
"email": user.email, |
||||
|
"token": token.key, |
||||
|
"user_type": effective_user_type, |
||||
|
"avatar": absolute_https_url(user.avatar.url, request) if user.avatar else None, |
||||
|
}, status=status.HTTP_201_CREATED) |
||||
|
|
||||
|
|
||||
|
from rest_framework.viewsets import ModelViewSet |
||||
|
from rest_framework.decorators import action |
||||
|
from rest_framework.filters import SearchFilter, OrderingFilter |
||||
|
from django_filters.rest_framework import DjangoFilterBackend |
||||
|
from rest_framework.permissions import IsAdminUser |
||||
|
from utils.pagination import StandardResultsSetPagination |
||||
|
from utils.excel_exporter import export_to_excel_response |
||||
|
|
||||
|
class AdminUserViewSet(ModelViewSet): |
||||
|
""" |
||||
|
Admin-only endpoint for full CRUD operations on Users. |
||||
|
Includes searching, filtering, and pagination. |
||||
|
""" |
||||
|
serializer_class = AdminUserSerializer |
||||
|
permission_classes = [IsAuthenticated, IsSuperAdminOrReadOnlyForProfessor] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
pagination_class = StandardResultsSetPagination |
||||
|
filter_backends = [] # Disable default backend filtering to use custom manual filtering |
||||
|
|
||||
|
@action(detail=False, methods=['post', 'get']) |
||||
|
def export_excel(self, request): |
||||
|
""" |
||||
|
Export users to an Excel (.xlsx) file. |
||||
|
Accepts user_ids in POST body for selective bulk export, |
||||
|
or uses current queryset filters if no specific IDs are provided. |
||||
|
""" |
||||
|
queryset = self.get_queryset() |
||||
|
user_ids = request.data.get('user_ids', None) if request.method == 'POST' else None |
||||
|
|
||||
|
if user_ids and isinstance(user_ids, list): |
||||
|
queryset = queryset.filter(id__in=user_ids) |
||||
|
|
||||
|
headers = [ |
||||
|
"ID", |
||||
|
"Full Name", |
||||
|
"Email", |
||||
|
"Phone Number", |
||||
|
"Date Joined", |
||||
|
"Last Login" |
||||
|
] |
||||
|
|
||||
|
rows = [] |
||||
|
for user in queryset: |
||||
|
rows.append([ |
||||
|
user.id, |
||||
|
user.fullname or "Unnamed", |
||||
|
user.email, |
||||
|
user.phone_number or "-", |
||||
|
user.date_joined, |
||||
|
user.last_login or "-" |
||||
|
]) |
||||
|
|
||||
|
timestamp = timezone.now().strftime("%Y%m%d_%H%M") |
||||
|
filename = f"users_export_{timestamp}.xlsx" |
||||
|
return export_to_excel_response(filename=filename, headers=headers, rows=rows, sheet_title="Users") |
||||
|
|
||||
|
def perform_destroy(self, instance): |
||||
|
instance.soft_delete() |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
queryset = User.objects.filter(email__isnull=False, deleted_at__isnull=True).exclude(email='') |
||||
|
|
||||
|
|
||||
|
# Handle Search |
||||
|
search_query = self.request.query_params.get('search', None) |
||||
|
if search_query: |
||||
|
queryset = queryset.filter( |
||||
|
Q(fullname__icontains=search_query) | |
||||
|
Q(email__icontains=search_query) | |
||||
|
Q(phone_number__icontains=search_query) |
||||
|
) |
||||
|
|
||||
|
# Handle is_active filter |
||||
|
is_active_param = self.request.query_params.get('is_active', None) |
||||
|
if is_active_param is not None: |
||||
|
if is_active_param.lower() == 'true': |
||||
|
queryset = queryset.filter(is_active=True) |
||||
|
elif is_active_param.lower() == 'false': |
||||
|
queryset = queryset.filter(is_active=False) |
||||
|
|
||||
|
# Handle gender filter |
||||
|
gender_param = self.request.query_params.get('gender', None) |
||||
|
if gender_param: |
||||
|
queryset = queryset.filter(gender=gender_param) |
||||
|
|
||||
|
# Handle user type filter |
||||
|
user_type_param = self.request.query_params.get('user_type', None) |
||||
|
if user_type_param: |
||||
|
if user_type_param == 'student': |
||||
|
queryset = queryset.filter(Q(user_type='student') | Q(user_type='client')) |
||||
|
else: |
||||
|
queryset = queryset.filter(user_type=user_type_param) |
||||
|
|
||||
|
|
||||
|
# Handle Ordering |
||||
|
ordering_param = self.request.query_params.get('ordering', '-date_joined') |
||||
|
allowed_orderings = [ |
||||
|
'date_joined', '-date_joined', |
||||
|
'last_login', '-last_login', |
||||
|
'fullname', '-fullname' |
||||
|
] |
||||
|
if ordering_param in allowed_orderings: |
||||
|
queryset = queryset.order_by(ordering_param) |
||||
|
else: |
||||
|
queryset = queryset.order_by('-date_joined') |
||||
|
|
||||
|
return queryset.distinct() |
||||
|
|
||||
|
|
||||
|
class AdminUserDirectoryViewSet(ModelViewSet): |
||||
|
""" |
||||
|
Separate, dedicated endpoint for accounts/users list page. |
||||
|
Filters users who have an email, are not deleted (deleted_at is null). |
||||
|
""" |
||||
|
serializer_class = AdminUserSerializer |
||||
|
permission_classes = [IsAuthenticated, IsSuperAdminOrReadOnlyForProfessor] |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
pagination_class = StandardResultsSetPagination |
||||
|
filter_backends = [] |
||||
|
|
||||
|
@action(detail=False, methods=['post', 'get']) |
||||
|
def export_excel(self, request): |
||||
|
""" |
||||
|
Export users to an Excel (.xlsx) file. |
||||
|
Accepts user_ids in POST body for selective bulk export, |
||||
|
or uses current queryset filters if no specific IDs are provided. |
||||
|
""" |
||||
|
queryset = self.get_queryset() |
||||
|
user_ids = request.data.get('user_ids', None) if request.method == 'POST' else None |
||||
|
|
||||
|
if user_ids and isinstance(user_ids, list): |
||||
|
queryset = queryset.filter(id__in=user_ids) |
||||
|
|
||||
|
headers = [ |
||||
|
"ID", |
||||
|
"Full Name", |
||||
|
"Email", |
||||
|
"Phone Number", |
||||
|
"Role", |
||||
|
"Date Joined", |
||||
|
"Last Login" |
||||
|
] |
||||
|
|
||||
|
role_map = { |
||||
|
'professor': 'Professor', |
||||
|
'client': 'Student', |
||||
|
'student': 'Student', |
||||
|
'admin': 'Admin', |
||||
|
'super_admin': 'Super Admin', |
||||
|
'consultant': 'Consultant', |
||||
|
} |
||||
|
|
||||
|
rows = [] |
||||
|
for user in queryset: |
||||
|
role_label = role_map.get(user.user_type, user.user_type or "Student") |
||||
|
rows.append([ |
||||
|
user.id, |
||||
|
user.fullname or "Unnamed", |
||||
|
user.email, |
||||
|
user.phone_number or "-", |
||||
|
role_label, |
||||
|
user.date_joined, |
||||
|
user.last_login or "-" |
||||
|
]) |
||||
|
|
||||
|
timestamp = timezone.now().strftime("%Y%m%d_%H%M") |
||||
|
filename = f"users_export_{timestamp}.xlsx" |
||||
|
return export_to_excel_response(filename=filename, headers=headers, rows=rows, sheet_title="Users") |
||||
|
|
||||
|
def perform_destroy(self, instance): |
||||
|
instance.soft_delete() |
||||
|
|
||||
|
|
||||
|
def get_queryset(self): |
||||
|
# Filter users who have an email and are not soft-deleted |
||||
|
queryset = User.objects.filter(email__isnull=False, deleted_at__isnull=True).exclude(email='') |
||||
|
|
||||
|
|
||||
|
# Handle Search |
||||
|
search_query = self.request.query_params.get('search', None) |
||||
|
if search_query: |
||||
|
queryset = queryset.filter( |
||||
|
Q(fullname__icontains=search_query) | |
||||
|
Q(email__icontains=search_query) | |
||||
|
Q(phone_number__icontains=search_query) |
||||
|
) |
||||
|
|
||||
|
# Handle is_active filter |
||||
|
is_active_param = self.request.query_params.get('is_active', None) |
||||
|
if is_active_param is not None: |
||||
|
if is_active_param.lower() == 'true': |
||||
|
queryset = queryset.filter(is_active=True) |
||||
|
elif is_active_param.lower() == 'false': |
||||
|
queryset = queryset.filter(is_active=False) |
||||
|
|
||||
|
# Handle user type filter |
||||
|
user_type_param = self.request.query_params.get('user_type', None) |
||||
|
if user_type_param: |
||||
|
if user_type_param == 'student': |
||||
|
queryset = queryset.filter(Q(user_type='student') | Q(user_type='client')) |
||||
|
else: |
||||
|
queryset = queryset.filter(user_type=user_type_param) |
||||
|
|
||||
|
# Handle Ordering |
||||
|
ordering_param = self.request.query_params.get('ordering', '-date_joined') |
||||
|
allowed_orderings = [ |
||||
|
'date_joined', '-date_joined', |
||||
|
'last_login', '-last_login', |
||||
|
'fullname', '-fullname' |
||||
|
] |
||||
|
if ordering_param in allowed_orderings: |
||||
|
queryset = queryset.order_by(ordering_param) |
||||
|
else: |
||||
|
queryset = queryset.order_by('-date_joined') |
||||
|
|
||||
|
return queryset.distinct() |
||||
@ -0,0 +1,28 @@ |
|||||
|
from django.contrib import admin |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from unfold.admin import ModelAdmin |
||||
|
from unfold.decorators import display |
||||
|
|
||||
|
from .models import AppVersion, SupportMessage |
||||
|
from utils.admin import project_admin_site |
||||
|
|
||||
|
|
||||
|
@admin.register(AppVersion) |
||||
|
class AppVersionAdmin(ModelAdmin): |
||||
|
list_display = ['version', 'app_type', 'is_active', 'downloads_count', 'created_at'] |
||||
|
list_filter = ['app_type', 'is_active'] |
||||
|
search_fields = ['version', 'description'] |
||||
|
ordering = ['-created_at'] |
||||
|
|
||||
|
|
||||
|
@admin.register(SupportMessage) |
||||
|
class SupportMessageAdmin(ModelAdmin): |
||||
|
list_display = ['sender_name', 'sender_email', 'subject', 'is_resolved', 'created_at'] |
||||
|
list_filter = ['is_resolved', 'created_at'] |
||||
|
search_fields = ['sender_name', 'sender_email', 'subject', 'message'] |
||||
|
ordering = ['-created_at'] |
||||
|
|
||||
|
|
||||
|
# Register to Unfold custom admin site |
||||
|
project_admin_site.register(AppVersion, AppVersionAdmin) |
||||
|
project_admin_site.register(SupportMessage, SupportMessageAdmin) |
||||
@ -0,0 +1,6 @@ |
|||||
|
from django.apps import AppConfig |
||||
|
|
||||
|
|
||||
|
class ApiConfig(AppConfig): |
||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||
|
name = 'apps.api' |
||||
@ -0,0 +1,42 @@ |
|||||
|
from functools import wraps |
||||
|
from django.http import HttpResponseForbidden |
||||
|
from django.contrib.auth.models import AnonymousUser |
||||
|
from django.views.decorators.csrf import csrf_exempt |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
|
||||
|
|
||||
|
def swagger_auth_required(view_func): |
||||
|
""" |
||||
|
Decorator that requires either admin authentication or valid swagger token |
||||
|
""" |
||||
|
@csrf_exempt |
||||
|
@wraps(view_func) |
||||
|
def _wrapped_view(request, *args, **kwargs): |
||||
|
# Check if user is admin |
||||
|
if request.user and request.user.is_authenticated and request.user.is_staff: |
||||
|
return view_func(request, *args, **kwargs) |
||||
|
|
||||
|
# Check swagger token in session |
||||
|
swagger_token = request.session.get('swagger_token') |
||||
|
if swagger_token: |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=swagger_token) |
||||
|
if token_obj.user.is_active: |
||||
|
return view_func(request, *args, **kwargs) |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
# Check Authorization header |
||||
|
auth_header = request.META.get('HTTP_AUTHORIZATION', '') |
||||
|
if auth_header.startswith('Token '): |
||||
|
token = auth_header.split(' ')[1] |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=token) |
||||
|
if token_obj.user.is_active: |
||||
|
return view_func(request, *args, **kwargs) |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
return HttpResponseForbidden("Access denied. Admin authentication or valid token required.") |
||||
|
|
||||
|
return _wrapped_view |
||||
@ -0,0 +1,52 @@ |
|||||
|
import django.core.validators |
||||
|
from django.db import migrations, models |
||||
|
import phonenumber_field.modelfields |
||||
|
import utils.validators |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
initial = True |
||||
|
|
||||
|
dependencies = [ |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.CreateModel( |
||||
|
name='AppVersion', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('version', models.CharField(help_text='Application version in format X.Y.Z (e.g., 1.0.0)', max_length=20, unique=True, validators=[django.core.validators.RegexValidator(message='Version must be in format X.Y.Z (e.g., 1.0.0)', regex='^\\d+\\.\\d+\\.\\d+$')], verbose_name='Version')), |
||||
|
('apk_file', models.FileField(blank=True, help_text='Application APK / binary file', null=True, upload_to='app_versions/', verbose_name='APK File')), |
||||
|
('description', models.TextField(blank=True, help_text='Release notes and changes for this version', verbose_name='Description')), |
||||
|
('app_type', models.CharField(choices=[('google_play', 'Google Play'), ('app_store', 'Apple App Store'), ('direct', 'Direct Download')], default='google_play', max_length=20, verbose_name='App Distribution Platform')), |
||||
|
('downloads_count', models.PositiveBigIntegerField(default=0, verbose_name='Downloads Count')), |
||||
|
('is_active', models.BooleanField(default=True, help_text='Is this version currently active?', verbose_name='Active')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'App Version', |
||||
|
'verbose_name_plural': 'App Versions', |
||||
|
'ordering': ['-created_at'], |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='SupportMessage', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('sender_name', models.CharField(max_length=255, verbose_name='Sender Name')), |
||||
|
('sender_email', models.EmailField(max_length=254, verbose_name='Sender Email')), |
||||
|
('subject', models.CharField(max_length=255, verbose_name='Subject')), |
||||
|
('sender_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='e.g., +1 555 1234567', max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='Sender Phone')), |
||||
|
('message', models.TextField(verbose_name='Message')), |
||||
|
('is_resolved', models.BooleanField(default=False, verbose_name='Resolved')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Support Message', |
||||
|
'verbose_name_plural': 'Support Messages', |
||||
|
'ordering': ['-created_at'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,125 @@ |
|||||
|
from django.db import models |
||||
|
from django.conf import settings |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from django.core.validators import RegexValidator |
||||
|
from phonenumber_field.modelfields import PhoneNumberField |
||||
|
from utils.validators import validate_possible_number |
||||
|
|
||||
|
|
||||
|
class AppVersion(models.Model): |
||||
|
""" |
||||
|
Model for storing mobile application releases and APK downloads |
||||
|
""" |
||||
|
class AppType(models.TextChoices): |
||||
|
GOOGLE_PLAY = 'google_play', _('Google Play') |
||||
|
APP_STORE = 'app_store', _('Apple App Store') |
||||
|
DIRECT = 'direct', _('Direct Download') |
||||
|
|
||||
|
version = models.CharField( |
||||
|
max_length=20, |
||||
|
unique=True, |
||||
|
validators=[ |
||||
|
RegexValidator( |
||||
|
regex=r'^\d+\.\d+\.\d+$', |
||||
|
message='Version must be in format X.Y.Z (e.g., 1.0.0)' |
||||
|
) |
||||
|
], |
||||
|
verbose_name=_('Version'), |
||||
|
help_text=_('Application version in format X.Y.Z (e.g., 1.0.0)') |
||||
|
) |
||||
|
|
||||
|
apk_file = models.FileField( |
||||
|
upload_to='app_versions/', |
||||
|
verbose_name=_('APK File'), |
||||
|
help_text=_('Application APK / binary file'), |
||||
|
null=True, |
||||
|
blank=True, |
||||
|
) |
||||
|
|
||||
|
description = models.TextField( |
||||
|
verbose_name=_('Description'), |
||||
|
help_text=_('Release notes and changes for this version'), |
||||
|
blank=True |
||||
|
) |
||||
|
|
||||
|
app_type = models.CharField( |
||||
|
max_length=20, |
||||
|
choices=AppType.choices, |
||||
|
default=AppType.GOOGLE_PLAY, |
||||
|
verbose_name=_('App Distribution Platform') |
||||
|
) |
||||
|
|
||||
|
downloads_count = models.PositiveBigIntegerField( |
||||
|
default=0, |
||||
|
verbose_name=_('Downloads Count') |
||||
|
) |
||||
|
|
||||
|
is_active = models.BooleanField( |
||||
|
default=True, |
||||
|
verbose_name=_('Active'), |
||||
|
help_text=_('Is this version currently active?') |
||||
|
) |
||||
|
|
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_('Created At') |
||||
|
) |
||||
|
|
||||
|
updated_at = models.DateTimeField( |
||||
|
auto_now=True, |
||||
|
verbose_name=_('Updated At') |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('App Version') |
||||
|
verbose_name_plural = _('App Versions') |
||||
|
ordering = ['-created_at'] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f'Version {self.version} ({self.get_app_type_display()})' |
||||
|
|
||||
|
@classmethod |
||||
|
def get_latest_active(cls): |
||||
|
return cls.objects.filter(is_active=True).order_by('-created_at').first() |
||||
|
|
||||
|
|
||||
|
class SupportMessage(models.Model): |
||||
|
""" |
||||
|
Model for user contact & support inquiries |
||||
|
""" |
||||
|
sender_name = models.CharField( |
||||
|
max_length=255, |
||||
|
verbose_name=_('Sender Name'), |
||||
|
) |
||||
|
sender_email = models.EmailField( |
||||
|
verbose_name=_('Sender Email'), |
||||
|
) |
||||
|
subject = models.CharField( |
||||
|
max_length=255, |
||||
|
verbose_name=_('Subject'), |
||||
|
) |
||||
|
sender_phone = PhoneNumberField( |
||||
|
validators=[validate_possible_number], |
||||
|
blank=True, |
||||
|
null=True, |
||||
|
verbose_name=_('Sender Phone'), |
||||
|
) |
||||
|
message = models.TextField( |
||||
|
verbose_name=_('Message'), |
||||
|
) |
||||
|
is_resolved = models.BooleanField( |
||||
|
default=False, |
||||
|
verbose_name=_('Resolved'), |
||||
|
) |
||||
|
created_at = models.DateTimeField( |
||||
|
auto_now_add=True, |
||||
|
verbose_name=_('Created At'), |
||||
|
) |
||||
|
|
||||
|
class Meta: |
||||
|
verbose_name = _('Support Message') |
||||
|
verbose_name_plural = _('Support Messages') |
||||
|
ordering = ['-created_at'] |
||||
|
|
||||
|
def __str__(self): |
||||
|
return f'{self.sender_name} - {self.subject}' |
||||
@ -0,0 +1,71 @@ |
|||||
|
from rest_framework import permissions |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
from django.contrib.auth.models import AnonymousUser |
||||
|
|
||||
|
|
||||
|
class SwaggerTokenPermission(permissions.BasePermission): |
||||
|
""" |
||||
|
Custom permission for Swagger that allows access to authenticated users via token |
||||
|
or admin users via session authentication |
||||
|
""" |
||||
|
|
||||
|
def has_permission(self, request, view): |
||||
|
# Check if user is admin (for session-based access) |
||||
|
if request.user and request.user.is_authenticated and request.user.is_staff: |
||||
|
return True |
||||
|
|
||||
|
# Check for token in session (from our custom auth system) |
||||
|
swagger_token = request.session.get('swagger_token') |
||||
|
if swagger_token: |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=swagger_token) |
||||
|
if token_obj.user.is_active: |
||||
|
return True |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
# Check for Authorization header |
||||
|
auth_header = request.META.get('HTTP_AUTHORIZATION', '') |
||||
|
if auth_header.startswith('Token '): |
||||
|
token = auth_header.split(' ')[1] |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=token) |
||||
|
if token_obj.user.is_active: |
||||
|
return True |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
return False |
||||
|
|
||||
|
|
||||
|
class IsAdminOrSwaggerToken(permissions.BasePermission): |
||||
|
""" |
||||
|
Permission that allows access to admin users or users with valid swagger token |
||||
|
""" |
||||
|
|
||||
|
def has_permission(self, request, view): |
||||
|
# Allow admin users |
||||
|
if request.user and request.user.is_authenticated and request.user.is_staff: |
||||
|
return True |
||||
|
|
||||
|
# Check swagger token in session |
||||
|
swagger_token = request.session.get('swagger_token') |
||||
|
if swagger_token: |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=swagger_token) |
||||
|
return token_obj.user.is_active |
||||
|
except Token.DoesNotExist: |
||||
|
pass |
||||
|
|
||||
|
return False |
||||
|
|
||||
|
|
||||
|
class IsProfessorUser(permissions.BasePermission): |
||||
|
""" |
||||
|
Permission that allows access only to professors or admin users. |
||||
|
""" |
||||
|
|
||||
|
def has_permission(self, request, view): |
||||
|
if not (request.user and request.user.is_authenticated): |
||||
|
return False |
||||
|
return request.user.user_type == 'professor' or request.user.is_staff |
||||
@ -0,0 +1,36 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from .models import AppVersion, SupportMessage |
||||
|
|
||||
|
|
||||
|
class AppVersionSerializer(serializers.ModelSerializer): |
||||
|
apk_file = serializers.FileField(read_only=True) |
||||
|
|
||||
|
class Meta: |
||||
|
model = AppVersion |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'version', |
||||
|
'apk_file', |
||||
|
'description', |
||||
|
'app_type', |
||||
|
'downloads_count', |
||||
|
'is_active', |
||||
|
'created_at', |
||||
|
'updated_at', |
||||
|
] |
||||
|
read_only_fields = ['id', 'created_at', 'updated_at'] |
||||
|
|
||||
|
|
||||
|
class SupportMessageCreateSerializer(serializers.ModelSerializer): |
||||
|
class Meta: |
||||
|
model = SupportMessage |
||||
|
fields = [ |
||||
|
'id', |
||||
|
'sender_name', |
||||
|
'sender_email', |
||||
|
'subject', |
||||
|
'sender_phone', |
||||
|
'message', |
||||
|
'created_at', |
||||
|
] |
||||
|
read_only_fields = ['id', 'created_at'] |
||||
@ -0,0 +1,3 @@ |
|||||
|
from django.test import TestCase |
||||
|
|
||||
|
# Create your tests here. |
||||
@ -0,0 +1,8 @@ |
|||||
|
from django.urls import path |
||||
|
from .views import HealthCheckView, AppVersionView, SupportMessageCreateView |
||||
|
|
||||
|
urlpatterns = [ |
||||
|
path('health/', HealthCheckView.as_view(), name='health-check'), |
||||
|
path('version/', AppVersionView.as_view(), name='app-version'), |
||||
|
path('contact-us/', SupportMessageCreateView.as_view(), name='contact-us'), |
||||
|
] |
||||
@ -0,0 +1,2 @@ |
|||||
|
# Legacy views - moved to views/api_views.py for better organization |
||||
|
from .views.api_views import HomeView, CountryView, CommentListAPIView |
||||
@ -0,0 +1,14 @@ |
|||||
|
from .api_views import HealthCheckView, HomeView, AppVersionView, SupportMessageCreateView |
||||
|
from .swagger_views import CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth |
||||
|
from .admin_dashboard import AdminDashboardStatsView |
||||
|
|
||||
|
__all__ = [ |
||||
|
'HealthCheckView', |
||||
|
'HomeView', |
||||
|
'AppVersionView', |
||||
|
'SupportMessageCreateView', |
||||
|
'CustomSwaggerView', |
||||
|
'SwaggerTokenAuthView', |
||||
|
'clear_swagger_auth', |
||||
|
'AdminDashboardStatsView', |
||||
|
] |
||||
@ -0,0 +1,61 @@ |
|||||
|
from rest_framework.views import APIView |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework.permissions import IsAuthenticated, IsAdminUser |
||||
|
from django.utils import timezone |
||||
|
from datetime import timedelta |
||||
|
from django.db.models import Count |
||||
|
from django.db.models.functions import TruncDate |
||||
|
from rest_framework.authentication import TokenAuthentication |
||||
|
|
||||
|
from apps.account.models import User, LoginHistory |
||||
|
from apps.api.models import AppVersion, SupportMessage |
||||
|
|
||||
|
|
||||
|
class AdminDashboardStatsView(APIView): |
||||
|
authentication_classes = [TokenAuthentication] |
||||
|
permission_classes = [IsAuthenticated, IsAdminUser] |
||||
|
|
||||
|
def get(self, request): |
||||
|
now = timezone.now() |
||||
|
thirty_days_ago = now - timedelta(days=30) |
||||
|
seven_days_ago = now - timedelta(days=7) |
||||
|
|
||||
|
# 1. User metrics |
||||
|
total_users = User.objects.count() |
||||
|
active_users = User.objects.filter(is_active=True).count() |
||||
|
new_users_30d = User.objects.filter(date_joined__gte=thirty_days_ago).count() |
||||
|
staff_users = User.objects.filter(is_staff=True).count() |
||||
|
|
||||
|
# 2. Login activity |
||||
|
logins_7d = LoginHistory.objects.filter(at_time__gte=seven_days_ago).count() |
||||
|
login_chart_qs = ( |
||||
|
LoginHistory.objects.filter(at_time__gte=seven_days_ago) |
||||
|
.annotate(date=TruncDate('at_time')) |
||||
|
.values('date') |
||||
|
.annotate(count=Count('id')) |
||||
|
.order_by('date') |
||||
|
) |
||||
|
login_chart = [ |
||||
|
{"date": entry['date'].strftime('%Y-%m-%d'), "count": entry['count']} |
||||
|
for entry in login_chart_qs |
||||
|
] |
||||
|
|
||||
|
# 3. System info |
||||
|
latest_version = AppVersion.get_latest_active() |
||||
|
unresolved_support = SupportMessage.objects.filter(is_resolved=False).count() |
||||
|
|
||||
|
data = { |
||||
|
"summary": { |
||||
|
"total_users": total_users, |
||||
|
"active_users": active_users, |
||||
|
"new_users_30d": new_users_30d, |
||||
|
"staff_users": staff_users, |
||||
|
"logins_7d": logins_7d, |
||||
|
"unresolved_support": unresolved_support, |
||||
|
"latest_app_version": latest_version.version if latest_version else None, |
||||
|
}, |
||||
|
"charts": { |
||||
|
"logins_by_day": login_chart, |
||||
|
} |
||||
|
} |
||||
|
return Response(data) |
||||
@ -0,0 +1,74 @@ |
|||||
|
from rest_framework import status |
||||
|
from rest_framework.views import APIView |
||||
|
from rest_framework.generics import CreateAPIView, GenericAPIView |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework.permissions import AllowAny |
||||
|
from django.utils import timezone |
||||
|
from drf_yasg.utils import swagger_auto_schema |
||||
|
from drf_yasg import openapi |
||||
|
|
||||
|
from apps.api.models import AppVersion, SupportMessage |
||||
|
from apps.api.serializers import AppVersionSerializer, SupportMessageCreateSerializer |
||||
|
|
||||
|
|
||||
|
class HealthCheckView(APIView): |
||||
|
""" |
||||
|
Health check endpoint returning system status and current timestamp |
||||
|
""" |
||||
|
permission_classes = [AllowAny] |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Check API server health status", |
||||
|
responses={ |
||||
|
200: openapi.Response( |
||||
|
description="Server is healthy", |
||||
|
examples={ |
||||
|
"application/json": { |
||||
|
"status": "healthy", |
||||
|
"timestamp": "2026-09-13T12:00:00Z", |
||||
|
"version": "1.0.0" |
||||
|
} |
||||
|
} |
||||
|
) |
||||
|
} |
||||
|
) |
||||
|
def get(self, request): |
||||
|
return Response({ |
||||
|
"status": "healthy", |
||||
|
"timestamp": timezone.now().isoformat(), |
||||
|
"version": "1.0.0", |
||||
|
}, status=status.HTTP_200_OK) |
||||
|
|
||||
|
|
||||
|
class AppVersionView(APIView): |
||||
|
""" |
||||
|
Returns latest active application version |
||||
|
""" |
||||
|
permission_classes = [AllowAny] |
||||
|
|
||||
|
@swagger_auto_schema( |
||||
|
operation_description="Get the latest active mobile application version", |
||||
|
responses={ |
||||
|
200: AppVersionSerializer(), |
||||
|
404: openapi.Response("No active version found") |
||||
|
} |
||||
|
) |
||||
|
def get(self, request): |
||||
|
version = AppVersion.get_latest_active() |
||||
|
if not version: |
||||
|
return Response({"detail": "No active version found."}, status=status.HTTP_404_NOT_FOUND) |
||||
|
serializer = AppVersionSerializer(version, context={'request': request}) |
||||
|
return Response(serializer.data) |
||||
|
|
||||
|
|
||||
|
class SupportMessageCreateView(CreateAPIView): |
||||
|
""" |
||||
|
Submit a user contact or support inquiry |
||||
|
""" |
||||
|
permission_classes = [AllowAny] |
||||
|
serializer_class = SupportMessageCreateSerializer |
||||
|
queryset = SupportMessage.objects.all() |
||||
|
|
||||
|
|
||||
|
# HomeView alias for backward compatibility |
||||
|
HomeView = HealthCheckView |
||||
@ -0,0 +1,83 @@ |
|||||
|
from django.shortcuts import render, redirect |
||||
|
from django.views import View |
||||
|
from django.contrib import messages |
||||
|
from django.contrib.admin.views.decorators import staff_member_required |
||||
|
from django.utils.decorators import method_decorator |
||||
|
from django.views.decorators.csrf import csrf_exempt |
||||
|
from django.urls import reverse |
||||
|
from rest_framework.authtoken.models import Token |
||||
|
|
||||
|
@method_decorator([staff_member_required, csrf_exempt], name='dispatch') |
||||
|
class CustomSwaggerView(View): |
||||
|
""" |
||||
|
Custom Swagger UI view with authentication banner |
||||
|
Requires admin login to access |
||||
|
""" |
||||
|
def get(self, request): |
||||
|
# Generate dynamic swagger spec URL based on current language |
||||
|
try: |
||||
|
swagger_spec_url = reverse('schema-json', kwargs={'format': '.json'}) |
||||
|
except: |
||||
|
# Fallback to hardcoded URL if reverse fails |
||||
|
swagger_spec_url = '/en/swagger.json' |
||||
|
|
||||
|
context = { |
||||
|
'swagger_spec_url': swagger_spec_url, |
||||
|
'request': request, |
||||
|
} |
||||
|
return render(request, 'swagger/ui.html', context) |
||||
|
|
||||
|
@method_decorator(staff_member_required, name='dispatch') |
||||
|
class SwaggerTokenAuthView(View): |
||||
|
""" |
||||
|
Token authentication management for Swagger |
||||
|
""" |
||||
|
def get(self, request): |
||||
|
context = { |
||||
|
'current_token': request.session.get('swagger_token'), |
||||
|
'user_info': request.session.get('swagger_user_info'), |
||||
|
} |
||||
|
return render(request, 'swagger/auth.html', context) |
||||
|
|
||||
|
def post(self, request): |
||||
|
token = request.POST.get('token', '').strip() |
||||
|
|
||||
|
if not token or len(token) != 40: |
||||
|
messages.error(request, 'Token must be exactly 40 characters long') |
||||
|
return redirect('swagger-token-auth') |
||||
|
|
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=token) |
||||
|
user = token_obj.user |
||||
|
|
||||
|
if not user.is_active: |
||||
|
messages.error(request, 'User account is not active') |
||||
|
return redirect('swagger-token-auth') |
||||
|
|
||||
|
request.session['swagger_token'] = token |
||||
|
request.session['swagger_user_info'] = { |
||||
|
'id': user.id, |
||||
|
'email': user.email, |
||||
|
'fullname': getattr(user, 'fullname', user.email), |
||||
|
'is_staff': user.is_staff, |
||||
|
'is_superuser': user.is_superuser, |
||||
|
'user_type': 'User' |
||||
|
} |
||||
|
|
||||
|
messages.success(request, f'Successfully authenticated as {user.email}') |
||||
|
return redirect('schema-swagger-ui') |
||||
|
|
||||
|
except Token.DoesNotExist: |
||||
|
messages.error(request, 'Invalid token') |
||||
|
return redirect('swagger-token-auth') |
||||
|
|
||||
|
@staff_member_required |
||||
|
def clear_swagger_auth(request): |
||||
|
"""Clear swagger authentication from session""" |
||||
|
if 'swagger_token' in request.session: |
||||
|
del request.session['swagger_token'] |
||||
|
if 'swagger_user_info' in request.session: |
||||
|
del request.session['swagger_user_info'] |
||||
|
|
||||
|
messages.success(request, 'Successfully logged out from Swagger') |
||||
|
return redirect('swagger-token-auth') |
||||
@ -0,0 +1,29 @@ |
|||||
|
{ |
||||
|
"token_hmac_secret_key": "super_secret_centrifugo_token_key_hmac_256", |
||||
|
"api_key": "centrifugo_internal_api_access_key", |
||||
|
"allowed_origins": [ |
||||
|
"https://shiadeepconnect.org", |
||||
|
"http://localhost:5173", |
||||
|
"http://localhost:3000", |
||||
|
"http://127.0.0.1:5173", |
||||
|
"http://127.0.0.1:3000" |
||||
|
], |
||||
|
"namespaces": [ |
||||
|
{ |
||||
|
"name": "chat", |
||||
|
"presence": true, |
||||
|
"history_size": 100, |
||||
|
"history_ttl": "7d", |
||||
|
"publish": false, |
||||
|
"anonymous": false |
||||
|
}, |
||||
|
{ |
||||
|
"name": "notifications", |
||||
|
"presence": false, |
||||
|
"history_size": 20, |
||||
|
"history_ttl": "24h", |
||||
|
"publish": false, |
||||
|
"anonymous": false |
||||
|
} |
||||
|
] |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
# __init__.py |
||||
|
from __future__ import absolute_import, unicode_literals |
||||
|
|
||||
|
# This will make sure the app is always imported when |
||||
|
# Django starts so that shared_task will use this app. |
||||
|
from .celery import app as celery_app |
||||
|
|
||||
|
__all__ = ('celery_app',) |
||||
@ -0,0 +1,16 @@ |
|||||
|
""" |
||||
|
ASGI config for backend project. |
||||
|
|
||||
|
It exposes the ASGI callable as a module-level variable named ``application``. |
||||
|
|
||||
|
For more information on this file, see |
||||
|
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ |
||||
|
""" |
||||
|
|
||||
|
import os |
||||
|
|
||||
|
from django.core.asgi import get_asgi_application |
||||
|
|
||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') |
||||
|
|
||||
|
application = get_asgi_application() |
||||
@ -0,0 +1,22 @@ |
|||||
|
import os |
||||
|
|
||||
|
import environ |
||||
|
|
||||
|
from celery import Celery |
||||
|
|
||||
|
env = environ.Env() |
||||
|
environ.Env.read_env(os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env')) |
||||
|
|
||||
|
# Set the default Django settings module for the 'celery' program. |
||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') |
||||
|
|
||||
|
app = Celery('config') |
||||
|
# Using a string here means the worker doesn't have to serialize |
||||
|
# the configuration object to child processes. |
||||
|
# - namespace='CELERY' means all celery-related configuration keys |
||||
|
# should have a `CELERY_` prefix. |
||||
|
app.config_from_object('django.conf:settings', namespace='CELERY') |
||||
|
|
||||
|
# Load task modules from all registered Django apps. |
||||
|
app.autodiscover_tasks() |
||||
|
|
||||
@ -0,0 +1,66 @@ |
|||||
|
from rest_framework.authtoken.models import Token |
||||
|
from django.contrib.auth import get_user_model |
||||
|
from django.shortcuts import redirect |
||||
|
from django.urls import reverse |
||||
|
from django.contrib import messages |
||||
|
|
||||
|
User = get_user_model() |
||||
|
|
||||
|
|
||||
|
def enhanced_auth_middleware(get_response): |
||||
|
""" |
||||
|
Enhanced middleware for API authentication with admin restriction |
||||
|
Handles custom documentation system authentication |
||||
|
""" |
||||
|
def middleware(request): |
||||
|
# Define protected paths that require staff access |
||||
|
protected_paths = ["/swagger", "/redoc", "/docs"] |
||||
|
is_protected_path = any(path in request.path for path in protected_paths) |
||||
|
|
||||
|
if is_protected_path: |
||||
|
# Check if user is authenticated and is staff |
||||
|
if request.user.is_authenticated and request.user.is_staff: |
||||
|
# Handle swagger token authentication from session |
||||
|
if 'swagger_token' in request.session: |
||||
|
token = request.session['swagger_token'] |
||||
|
# Validate the token still exists and is valid |
||||
|
try: |
||||
|
token_obj = Token.objects.get(key=token) |
||||
|
if token_obj.user.is_active: |
||||
|
request.META['HTTP_AUTHORIZATION'] = f"Token {token}" |
||||
|
else: |
||||
|
# Token user is inactive, clear session |
||||
|
del request.session['swagger_token'] |
||||
|
if 'swagger_user_info' in request.session: |
||||
|
del request.session['swagger_user_info'] |
||||
|
except Token.DoesNotExist: |
||||
|
# Token doesn't exist, clear session |
||||
|
del request.session['swagger_token'] |
||||
|
if 'swagger_user_info' in request.session: |
||||
|
del request.session['swagger_user_info'] |
||||
|
|
||||
|
# If no swagger token in session, provide default admin token for basic access |
||||
|
elif not request.META.get('HTTP_AUTHORIZATION'): |
||||
|
# Create or get token for the current admin user |
||||
|
token, _ = Token.objects.get_or_create(user=request.user) |
||||
|
request.META['HTTP_AUTHORIZATION'] = f"Token {token.key}" |
||||
|
|
||||
|
else: |
||||
|
# User is not authenticated or not staff |
||||
|
# For swagger-auth paths, allow access (they handle their own auth) |
||||
|
if '/swagger-auth/' not in request.path: |
||||
|
# Redirect to admin login for other protected paths |
||||
|
messages.warning(request, 'You must be logged in as a staff member to access API documentation.') |
||||
|
from django.utils.translation import get_language |
||||
|
language = get_language() or 'en' |
||||
|
return redirect(f"/{language}/admin/login/?next={request.path}") |
||||
|
|
||||
|
# For non-protected API paths, handle normal authentication |
||||
|
elif "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: |
||||
|
if request.user.is_authenticated and request.user.is_staff: |
||||
|
token, _ = Token.objects.get_or_create(user=request.user) |
||||
|
request.META['HTTP_AUTHORIZATION'] = f"Token {token.key}" |
||||
|
|
||||
|
return get_response(request) |
||||
|
|
||||
|
return middleware |
||||
@ -0,0 +1,14 @@ |
|||||
|
def language_middleware(get_response): |
||||
|
""" |
||||
|
Middleware that allows overriding the active request language |
||||
|
via the 'language_code' query parameter. |
||||
|
""" |
||||
|
def middleware(request): |
||||
|
language_code = request.GET.get('language_code') |
||||
|
if language_code: |
||||
|
request.LANGUAGE_CODE = language_code |
||||
|
|
||||
|
response = get_response(request) |
||||
|
return response |
||||
|
|
||||
|
return middleware |
||||
@ -0,0 +1,22 @@ |
|||||
|
""" |
||||
|
Middleware utilities and helpers |
||||
|
""" |
||||
|
|
||||
|
|
||||
|
def get_admin_namespace(request): |
||||
|
""" |
||||
|
Determine the admin namespace based on the request domain. |
||||
|
Returns the appropriate admin namespace for use in reverse() calls. |
||||
|
|
||||
|
Usage: |
||||
|
from config.middleware import get_admin_namespace |
||||
|
admin_ns = get_admin_namespace(request) |
||||
|
url = reverse(f'{admin_ns}:model_changelist') |
||||
|
""" |
||||
|
host = request.get_host() |
||||
|
|
||||
|
# Check if the request is from Dovoodi domain |
||||
|
if 'dovodi' in host or 'dovoodi' in host: |
||||
|
return 'dovoodi_admin' |
||||
|
else: |
||||
|
return 'imam_javad_admin' |
||||
@ -0,0 +1,15 @@ |
|||||
|
from redis import Redis, ConnectionPool |
||||
|
|
||||
|
from config.settings import base as settings |
||||
|
|
||||
|
|
||||
|
|
||||
|
pool = ConnectionPool.from_url(url= settings.REDIS_URL,max_connections=100) |
||||
|
|
||||
|
|
||||
|
class RedisConfig: |
||||
|
|
||||
|
def __init__(self): |
||||
|
self.redis = Redis(connection_pool=pool, decode_responses=True) |
||||
|
|
||||
|
|
||||
@ -0,0 +1,379 @@ |
|||||
|
""" |
||||
|
Django base settings for project template. |
||||
|
Built on Django 5.0+, Django REST Framework, Celery, and Django Unfold. |
||||
|
""" |
||||
|
import os |
||||
|
from pathlib import Path |
||||
|
from django.templatetags.static import static |
||||
|
from django.urls import reverse_lazy |
||||
|
import environ |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
import sentry_sdk |
||||
|
from utils.admin import admin_url_generator |
||||
|
|
||||
|
# Build paths inside the project: BASE_DIR / 'subdir'. |
||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent |
||||
|
|
||||
|
env = environ.Env() |
||||
|
env_file = os.path.join(BASE_DIR, '.env') |
||||
|
if os.path.exists(env_file): |
||||
|
environ.Env.read_env(env_file) |
||||
|
|
||||
|
# Core Security & Debug |
||||
|
SECRET_KEY = env('DJANGO_SECRET_KEY', default='django-insecure-template-secret-key-replace-in-production') |
||||
|
DEBUG = env.bool('DJANGO_DEBUG', default=True) |
||||
|
|
||||
|
ALLOWED_HOSTS = [host.strip() for host in env('DJANGO_ALLOWED_HOSTS', default='127.0.0.1,localhost,*').split(',') if host.strip()] |
||||
|
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in env('DJANGO_TRUSTED_ORIGINS', default='http://localhost:8000,http://127.0.0.1:8000').split(',') if origin.strip()] |
||||
|
|
||||
|
X_FRAME_OPTIONS = 'SAMEORIGIN' |
||||
|
|
||||
|
# Application definition |
||||
|
LOCAL_APPS = [ |
||||
|
'apps.account.apps.AccountConfig', |
||||
|
'apps.api.apps.ApiConfig', |
||||
|
'dynamic_preferences', |
||||
|
] |
||||
|
|
||||
|
THIRD_PARTY_APPS = [ |
||||
|
'rest_framework', |
||||
|
'rest_framework.authtoken', |
||||
|
'rest_framework_simplejwt', |
||||
|
'drf_spectacular', |
||||
|
'drf_spectacular_sidecar', |
||||
|
'drf_yasg', |
||||
|
'phonenumber_field', |
||||
|
'corsheaders', |
||||
|
'django_filters', |
||||
|
'django_celery_beat', |
||||
|
] |
||||
|
|
||||
|
INSTALLED_APPS = [ |
||||
|
"unfold", |
||||
|
"unfold.contrib.filters", |
||||
|
"unfold.contrib.forms", |
||||
|
"unfold.contrib.inlines", |
||||
|
"whitenoise.runserver_nostatic", |
||||
|
'django.contrib.admin', |
||||
|
'django.contrib.auth', |
||||
|
'django.contrib.contenttypes', |
||||
|
'django.contrib.sessions', |
||||
|
'django.contrib.messages', |
||||
|
'django.contrib.staticfiles', |
||||
|
'django.contrib.humanize', |
||||
|
'django.contrib.sitemaps', |
||||
|
*THIRD_PARTY_APPS, |
||||
|
*LOCAL_APPS, |
||||
|
] |
||||
|
|
||||
|
AUTHENTICATION_BACKENDS = [ |
||||
|
'django.contrib.auth.backends.ModelBackend', |
||||
|
'apps.account.custom_user_login.CustomLoginBackend', |
||||
|
] |
||||
|
|
||||
|
AUTH_USER_MODEL = "account.User" |
||||
|
|
||||
|
MIDDLEWARE = [ |
||||
|
'django.middleware.security.SecurityMiddleware', |
||||
|
'django.middleware.gzip.GZipMiddleware', |
||||
|
"whitenoise.middleware.WhiteNoiseMiddleware", |
||||
|
'django.contrib.sessions.middleware.SessionMiddleware', |
||||
|
'corsheaders.middleware.CorsMiddleware', |
||||
|
'django.middleware.locale.LocaleMiddleware', |
||||
|
'django.middleware.common.CommonMiddleware', |
||||
|
'django.middleware.csrf.CsrfViewMiddleware', |
||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware', |
||||
|
'django.contrib.messages.middleware.MessageMiddleware', |
||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware', |
||||
|
'config.language_code_middleware.language_middleware', |
||||
|
'config.enhanced_auth_middleware.enhanced_auth_middleware', |
||||
|
] |
||||
|
|
||||
|
ROOT_URLCONF = 'config.urls' |
||||
|
APPEND_SLASH = True |
||||
|
|
||||
|
TEMPLATES = [ |
||||
|
{ |
||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates', |
||||
|
'DIRS': [ |
||||
|
BASE_DIR / 'templates', |
||||
|
], |
||||
|
'APP_DIRS': True, |
||||
|
'OPTIONS': { |
||||
|
'context_processors': [ |
||||
|
'django.template.context_processors.debug', |
||||
|
'django.template.context_processors.request', |
||||
|
'django.contrib.auth.context_processors.auth', |
||||
|
'django.contrib.messages.context_processors.messages', |
||||
|
'django.template.context_processors.i18n', |
||||
|
"utils.admin.variables", |
||||
|
], |
||||
|
}, |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
WSGI_APPLICATION = 'config.wsgi.application' |
||||
|
ASGI_APPLICATION = 'config.asgi.application' |
||||
|
|
||||
|
# Database configuration |
||||
|
DATABASES = { |
||||
|
'default': { |
||||
|
'ENGINE': 'django.db.backends.postgresql', |
||||
|
'NAME': env('POSTGRES_DB', default='app_db'), |
||||
|
'USER': env('POSTGRES_USER', default='postgres'), |
||||
|
'PASSWORD': env('POSTGRES_PASSWORD', default='postgres'), |
||||
|
'HOST': env('POSTGRES_HOST', default='postgres'), |
||||
|
'PORT': env('POSTGRES_PORT', default='5432'), |
||||
|
'ATOMIC_REQUESTS': True, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
# Redis & Caching |
||||
|
REDIS_URL = env('REDIS_URL', default='redis://redis:6379/0') |
||||
|
CACHES = { |
||||
|
'default': { |
||||
|
"BACKEND": "django_redis.cache.RedisCache", |
||||
|
"LOCATION": REDIS_URL, |
||||
|
"OPTIONS": { |
||||
|
"CLIENT_CLASS": "django_redis.client.DefaultClient", |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# Celery Task Queue |
||||
|
CELERY_BROKER_URL = REDIS_URL |
||||
|
CELERY_RESULT_BACKEND = REDIS_URL |
||||
|
CELERY_ACCEPT_CONTENT = ['application/json'] |
||||
|
CELERY_TIMEZONE = 'UTC' |
||||
|
CELERY_BROKER_TRANSPORT = 'redis' |
||||
|
CELERY_TASK_SERIALIZER = 'json' |
||||
|
CELERY_RESULT_SERIALIZER = 'json' |
||||
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True |
||||
|
|
||||
|
from celery.schedules import crontab |
||||
|
CELERY_BEAT_SCHEDULE = { |
||||
|
'cleanup_expired_tokens_daily': { |
||||
|
'task': 'cleanup_expired_tokens_task', |
||||
|
'schedule': crontab(minute=0, hour=3), |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
# Password validation |
||||
|
AUTH_PASSWORD_VALIDATORS = [ |
||||
|
{ |
||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', |
||||
|
'OPTIONS': { |
||||
|
'min_length': 6, |
||||
|
} |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
# Django REST Framework |
||||
|
REST_FRAMEWORK = { |
||||
|
'DEFAULT_PAGINATION_CLASS': 'utils.pagination.StandardResultsSetPagination', |
||||
|
'PAGE_SIZE': 20, |
||||
|
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], |
||||
|
'DEFAULT_AUTHENTICATION_CLASSES': [ |
||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication', |
||||
|
'rest_framework.authentication.TokenAuthentication', |
||||
|
'rest_framework.authentication.SessionAuthentication', |
||||
|
], |
||||
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', |
||||
|
'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', |
||||
|
} |
||||
|
|
||||
|
# SimpleJWT Authentication Settings |
||||
|
from datetime import timedelta |
||||
|
SIMPLE_JWT = { |
||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(days=1), |
||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), |
||||
|
'ROTATE_REFRESH_TOKENS': True, |
||||
|
'BLACKLIST_AFTER_ROTATION': False, |
||||
|
'UPDATE_LAST_LOGIN': True, |
||||
|
'ALGORITHM': 'HS256', |
||||
|
'SIGNING_KEY': SECRET_KEY, |
||||
|
'AUTH_HEADER_TYPES': ('Bearer',), |
||||
|
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', |
||||
|
'USER_ID_FIELD': 'id', |
||||
|
'USER_ID_CLAIM': 'user_id', |
||||
|
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), |
||||
|
} |
||||
|
|
||||
|
# drf-spectacular OpenAPI 3.0 Documentation Settings |
||||
|
SPECTACULAR_SETTINGS = { |
||||
|
'TITLE': 'API سامانه تعاملات بینالملل', |
||||
|
'DESCRIPTION': 'مستندات نقاط پایانی سامانه تعاملات بینالملل آستان قدس رضوی (فاز دوم)', |
||||
|
'VERSION': '2.0.0', |
||||
|
'SERVE_INCLUDE_SCHEMA': False, |
||||
|
'COMPONENT_SPLIT_REQUEST': True, |
||||
|
'SWAGGER_UI_DIST': 'SIDECAR', |
||||
|
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', |
||||
|
'REDOC_DIST': 'SIDECAR', |
||||
|
'SECURITY': [{ |
||||
|
'jwtAuth': [] |
||||
|
}], |
||||
|
'SECURITY_SCHEMES': { |
||||
|
'jwtAuth': { |
||||
|
'type': 'apiKey', |
||||
|
'in': 'header', |
||||
|
'name': 'Authorization', |
||||
|
'description': 'توکن JWT را به این صورت وارد کنید: Bearer <JWT_TOKEN>' |
||||
|
} |
||||
|
}, |
||||
|
'SWAGGER_UI_SETTINGS': { |
||||
|
'deepLinking': True, |
||||
|
'persistAuthorization': True, |
||||
|
'displayOperationId': True, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
# Centrifugo Real-Time Messaging Settings |
||||
|
CENTRIFUGO_API_URL = env('CENTRIFUGO_API_URL', default='http://centrifugo:8000/api') |
||||
|
CENTRIFUGO_SECRET = env('CENTRIFUGO_SECRET', default='super_secret_centrifugo_token_key_hmac_256') |
||||
|
CENTRIFUGO_API_KEY = env('CENTRIFUGO_API_KEY', default='centrifugo_internal_api_access_key') |
||||
|
|
||||
|
CORS_ALLOW_ALL_ORIGINS = True |
||||
|
CORS_ALLOW_CREDENTIALS = True |
||||
|
CORS_ALLOWED_ORIGINS = [ |
||||
|
'http://localhost:5173', |
||||
|
'http://127.0.0.1:5173', |
||||
|
'http://localhost:3000', |
||||
|
'http://127.0.0.1:3000', |
||||
|
'https://shiadeepconnect.org', |
||||
|
] |
||||
|
|
||||
|
# Internationalization |
||||
|
LANGUAGE_CODE = 'en' |
||||
|
TIME_ZONE = 'UTC' |
||||
|
USE_I18N = True |
||||
|
USE_L10N = True |
||||
|
USE_TZ = True |
||||
|
|
||||
|
LANGUAGES = [ |
||||
|
('fa', _('Persian')), |
||||
|
('en', _('English')), |
||||
|
('ar', _('Arabic')), |
||||
|
('ur', _('Urdu')), |
||||
|
('ru', _('Russian')), |
||||
|
] |
||||
|
|
||||
|
LOCALE_PATHS = [ |
||||
|
os.path.join(BASE_DIR, 'locale'), |
||||
|
] |
||||
|
|
||||
|
# Static & Media Files |
||||
|
STATIC_URL = '/static/' |
||||
|
MEDIA_URL = '/media/' |
||||
|
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] |
||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles' |
||||
|
MEDIA_ROOT = BASE_DIR / 'media' |
||||
|
|
||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' |
||||
|
|
||||
|
PHONENUMBER_DEFAULT_REGION = env("PHONENUMBER_DEFAULT_REGION", default="US") |
||||
|
PHONENUMBER_DB_FORMAT = 'INTERNATIONAL' |
||||
|
PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL' |
||||
|
|
||||
|
# Sessions |
||||
|
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" |
||||
|
LOGIN_URL = "admin:login" |
||||
|
LOGIN_REDIRECT_URL = reverse_lazy("home") |
||||
|
|
||||
|
# Unfold Admin Interface Settings |
||||
|
UNFOLD = { |
||||
|
"SITE_TITLE": _("Admin Portal"), |
||||
|
"SITE_HEADER": _("Admin Portal"), |
||||
|
"SITE_SUBHEADER": _("Management Console"), |
||||
|
"SITE_SYMBOL": "speed", |
||||
|
"ALLOW_UNICODE_SLUGS": True, |
||||
|
"SHOW_HISTORY": True, |
||||
|
"SHOW_LANGUAGES": True, |
||||
|
"ENVIRONMENT": "utils.environment_callback", |
||||
|
"DASHBOARD_CALLBACK": "utils.admin.dashboard_callback", |
||||
|
"SHOW_BACK_BUTTON": True, |
||||
|
"THEME": "light", |
||||
|
"STYLES": [ |
||||
|
lambda request: static("css/styles.css"), |
||||
|
], |
||||
|
"TABS": [ |
||||
|
{ |
||||
|
"page": "accounts", |
||||
|
"models": ["account.user", "auth.group"], |
||||
|
"items": [ |
||||
|
{ |
||||
|
"title": _("Users"), |
||||
|
"icon": "people", |
||||
|
"link": lambda request: admin_url_generator(request, "account_user_changelist"), |
||||
|
}, |
||||
|
{ |
||||
|
"title": _("Groups"), |
||||
|
"icon": "shield", |
||||
|
"link": lambda request: admin_url_generator(request, "auth_group_changelist"), |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
], |
||||
|
"SIDEBAR": { |
||||
|
"show_search": True, |
||||
|
"show_all_applications": True, |
||||
|
"navigation": [ |
||||
|
{ |
||||
|
"title": _("Overview"), |
||||
|
"separator": False, |
||||
|
"items": [ |
||||
|
{ |
||||
|
"title": _("Dashboard"), |
||||
|
"icon": "dashboard", |
||||
|
"link": lambda request: admin_url_generator(request, "index"), |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
{ |
||||
|
"title": _("User Management"), |
||||
|
"separator": True, |
||||
|
"items": [ |
||||
|
{ |
||||
|
"title": _("All Users"), |
||||
|
"icon": "people", |
||||
|
"link": lambda request: admin_url_generator(request, "account_user_changelist"), |
||||
|
}, |
||||
|
{ |
||||
|
"title": _("Groups & Permissions"), |
||||
|
"icon": "shield", |
||||
|
"link": lambda request: admin_url_generator(request, "auth_group_changelist"), |
||||
|
"permission": lambda request: request.user.is_staff, |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
{ |
||||
|
"title": _("System Settings"), |
||||
|
"separator": True, |
||||
|
"items": [ |
||||
|
{ |
||||
|
"title": _("Global Preferences"), |
||||
|
"icon": "tune", |
||||
|
"link": lambda request: admin_url_generator(request, "dynamic_preferences_globalpreferencemodel_changelist"), |
||||
|
}, |
||||
|
{ |
||||
|
"title": _("App Versions"), |
||||
|
"icon": "system_update", |
||||
|
"link": lambda request: admin_url_generator(request, "api_appversion_changelist"), |
||||
|
}, |
||||
|
{ |
||||
|
"title": _("Support Inquiries"), |
||||
|
"icon": "mail", |
||||
|
"link": lambda request: admin_url_generator(request, "api_supportmessage_changelist"), |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
# Sentry SDK Error Tracking (Configurable via ENV) |
||||
|
SENTRY_DSN = env('SENTRY_DSN', default='') |
||||
|
if SENTRY_DSN: |
||||
|
sentry_sdk.init( |
||||
|
dsn=SENTRY_DSN, |
||||
|
send_default_pii=True, |
||||
|
) |
||||
@ -0,0 +1,24 @@ |
|||||
|
from .base import * |
||||
|
|
||||
|
# DJANGO_REDIS_IGNORE_EXCEPTIONS = True |
||||
|
DEBUG = True |
||||
|
|
||||
|
CORS_ALLOW_ALL_ORIGINS = True |
||||
|
|
||||
|
# Explicitly enable Unfold Studio in development mode |
||||
|
UNFOLD_STUDIO_ENABLE_SAVE = True |
||||
|
UNFOLD_STUDIO_ENABLE_FILEUPLOAD = True |
||||
|
UNFOLD_STUDIO_ALWAYS_OPEN = True |
||||
|
# Allow all authenticated users to access the studio in development mode |
||||
|
UNFOLD_STUDIO_PERMISSION = lambda request: request.user.is_authenticated |
||||
|
|
||||
|
# CACHES = { |
||||
|
# 'default': { |
||||
|
# "BACKEND": "django.core.cache.backends.dummy.DummyCache", |
||||
|
# }, |
||||
|
# 'memory': { |
||||
|
# 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', |
||||
|
# 'LOCATION': 'unique-snowflake', |
||||
|
# 'TIMEOUT': 5000, |
||||
|
# }, |
||||
|
# } |
||||
@ -0,0 +1,50 @@ |
|||||
|
from .base import * |
||||
|
|
||||
|
DEBUG = False |
||||
|
|
||||
|
CORS_ALLOW_ALL_ORIGINS = False |
||||
|
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in env('CORS_ALLOWED_ORIGINS', default='').split(',') if origin.strip()] |
||||
|
|
||||
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') |
||||
|
SESSION_COOKIE_SECURE = True |
||||
|
CSRF_COOKIE_SECURE = True |
||||
|
SECURE_BROWSER_XSS_FILTER = True |
||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True |
||||
|
|
||||
|
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = [ |
||||
|
'rest_framework.renderers.JSONRenderer', |
||||
|
] |
||||
|
|
||||
|
LOGGING = { |
||||
|
"version": 1, |
||||
|
"disable_existing_loggers": False, |
||||
|
"formatters": { |
||||
|
"django.server": { |
||||
|
"()": "django.utils.log.ServerFormatter", |
||||
|
"format": "[{server_time}] {message}", |
||||
|
"style": "{", |
||||
|
} |
||||
|
}, |
||||
|
"handlers": { |
||||
|
"console": { |
||||
|
"level": "INFO", |
||||
|
"class": "logging.StreamHandler", |
||||
|
}, |
||||
|
"django.server": { |
||||
|
"level": "INFO", |
||||
|
"class": "logging.StreamHandler", |
||||
|
"formatter": "django.server", |
||||
|
}, |
||||
|
}, |
||||
|
"loggers": { |
||||
|
"django": { |
||||
|
"handlers": ["console"], |
||||
|
"level": "INFO", |
||||
|
}, |
||||
|
"django.server": { |
||||
|
"handlers": ["django.server"], |
||||
|
"level": "INFO", |
||||
|
"propagate": False, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
from .base import * # noqa |
||||
|
|
||||
|
DATABASES['default'] = { |
||||
|
'ENGINE': 'django.db.backends.sqlite3', |
||||
|
'NAME': ':memory:', |
||||
|
} |
||||
|
|
||||
|
PASSWORD_HASHERS = [ |
||||
|
'django.contrib.auth.hashers.MD5PasswordHasher', |
||||
|
] |
||||
|
|
||||
|
MEDIA_ROOT = BASE_DIR / 'test_media' |
||||
@ -0,0 +1,118 @@ |
|||||
|
""" |
||||
|
Main URL Configuration for the Django Backend Starter Template. |
||||
|
""" |
||||
|
import os |
||||
|
import re |
||||
|
import mimetypes |
||||
|
from django.contrib import admin |
||||
|
from django.urls import path, include, re_path |
||||
|
from django.conf import settings |
||||
|
from django.conf.urls.i18n import i18n_patterns |
||||
|
from django.http import StreamingHttpResponse, HttpResponse, Http404 |
||||
|
from django.views.static import serve as django_static_serve |
||||
|
|
||||
|
from django.views.generic import RedirectView |
||||
|
from drf_spectacular.views import ( |
||||
|
SpectacularAPIView, |
||||
|
SpectacularRedocView, |
||||
|
SpectacularSwaggerView, |
||||
|
) |
||||
|
from rest_framework_simplejwt.views import ( |
||||
|
TokenObtainPairView, |
||||
|
TokenRefreshView, |
||||
|
TokenVerifyView, |
||||
|
) |
||||
|
from rest_framework import permissions |
||||
|
|
||||
|
from utils.admin import project_admin_site, HomeView |
||||
|
|
||||
|
# API v1 Patterns |
||||
|
api_v1_patterns = [ |
||||
|
# JWT Authentication Endpoints (matching swagger-api-guide.md) |
||||
|
path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), |
||||
|
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), |
||||
|
path('auth/token/verify/', TokenVerifyView.as_view(), name='token_verify'), |
||||
|
|
||||
|
path('', include('apps.api.urls')), |
||||
|
path('account/', include('apps.account.urls')), |
||||
|
path('settings/', include('dynamic_preferences.urls')), |
||||
|
] |
||||
|
|
||||
|
urlpatterns = [ |
||||
|
# Language prefix routing for Admin |
||||
|
path("i18n/", include("django.conf.urls.i18n")), |
||||
|
|
||||
|
# REST API Endpoints |
||||
|
path("api/v1/", include(api_v1_patterns)), |
||||
|
|
||||
|
# OpenAPI 3.0 & Interactive Documentation (matching swagger-api-guide.md) |
||||
|
path("api/schema/", SpectacularAPIView.as_view(), name="schema"), |
||||
|
path("api/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), |
||||
|
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), |
||||
|
|
||||
|
# Backward compatibility redirect |
||||
|
path("swagger/", RedirectView.as_view(url="/api/schema/swagger-ui/", permanent=False)), |
||||
|
] |
||||
|
|
||||
|
# Admin URLs wrapped in i18n |
||||
|
urlpatterns += i18n_patterns( |
||||
|
path("admin/", project_admin_site.urls), |
||||
|
prefix_default_language=True, |
||||
|
) |
||||
|
|
||||
|
# Static & Media streaming for development mode |
||||
|
if settings.DEBUG: |
||||
|
def ranged_static_serve(request, path, document_root=None, **kwargs): |
||||
|
fullpath = os.path.join(document_root, path) |
||||
|
if not os.path.exists(fullpath) or os.path.isdir(fullpath): |
||||
|
raise Http404("File not found") |
||||
|
|
||||
|
range_header = request.META.get('HTTP_RANGE', '').strip() |
||||
|
if not range_header: |
||||
|
return django_static_serve(request, path, document_root=document_root, **kwargs) |
||||
|
|
||||
|
size = os.path.getsize(fullpath) |
||||
|
content_type, encoding = mimetypes.guess_type(fullpath) |
||||
|
content_type = content_type or 'application/octet-stream' |
||||
|
|
||||
|
match = re.match(r'bytes=(\d+)-(\d*)', range_header) |
||||
|
if not match: |
||||
|
return HttpResponse("Invalid Range Header", status=400) |
||||
|
|
||||
|
first_byte, last_byte = match.groups() |
||||
|
first_byte = int(first_byte) if first_byte else 0 |
||||
|
last_byte = int(last_byte) if last_byte else size - 1 |
||||
|
|
||||
|
if first_byte >= size: |
||||
|
return HttpResponse("Requested Range Not Satisfiable", status=416) |
||||
|
|
||||
|
if last_byte >= size: |
||||
|
last_byte = size - 1 |
||||
|
|
||||
|
length = last_byte - first_byte + 1 |
||||
|
|
||||
|
def file_iterator(file_path, offset, bytes_to_read, chunk_size=8192): |
||||
|
with open(file_path, 'rb') as f: |
||||
|
f.seek(offset) |
||||
|
remaining = bytes_to_read |
||||
|
while remaining > 0: |
||||
|
to_read = min(chunk_size, remaining) |
||||
|
data = f.read(to_read) |
||||
|
if not data: |
||||
|
break |
||||
|
yield data |
||||
|
remaining -= len(data) |
||||
|
|
||||
|
response = StreamingHttpResponse( |
||||
|
file_iterator(fullpath, first_byte, length), |
||||
|
status=206, |
||||
|
content_type=content_type |
||||
|
) |
||||
|
response['Content-Range'] = f'bytes {first_byte}-{last_byte}/{size}' |
||||
|
response['Content-Length'] = str(length) |
||||
|
response['Accept-Ranges'] = 'bytes' |
||||
|
return response |
||||
|
|
||||
|
urlpatterns += [ |
||||
|
re_path(r'^%s(?P<path>.*)$' % re.escape(settings.MEDIA_URL.lstrip('/')), ranged_static_serve, {'document_root': settings.MEDIA_ROOT}), |
||||
|
] |
||||
@ -0,0 +1,16 @@ |
|||||
|
""" |
||||
|
WSGI config for backend project. |
||||
|
|
||||
|
It exposes the WSGI callable as a module-level variable named ``application``. |
||||
|
|
||||
|
For more information on this file, see |
||||
|
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ |
||||
|
""" |
||||
|
|
||||
|
import os |
||||
|
|
||||
|
from django.core.wsgi import get_wsgi_application |
||||
|
|
||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') |
||||
|
|
||||
|
application = get_wsgi_application() |
||||
@ -0,0 +1,91 @@ |
|||||
|
version: '3.8' |
||||
|
|
||||
|
services: |
||||
|
web: |
||||
|
container_name: dovodi_web |
||||
|
restart: unless-stopped |
||||
|
build: |
||||
|
context: . |
||||
|
dockerfile: Dockerfile.prod |
||||
|
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=4 --threads=4 --worker-class=gthread --max-requests=1000 --max-requests-jitter=100 --timeout 180 |
||||
|
volumes: |
||||
|
# - static_volume:/usr/src/app/static |
||||
|
- media_volume:/usr/src/app/media |
||||
|
- staticfiles_volume:/usr/src/app/staticfiles |
||||
|
ports: |
||||
|
- "127.0.0.1:8024:8000" |
||||
|
env_file: |
||||
|
- .env.prod |
||||
|
depends_on: |
||||
|
- postgres |
||||
|
links: |
||||
|
- postgres |
||||
|
networks: |
||||
|
- backend_imam-javad |
||||
|
|
||||
|
postgres: |
||||
|
container_name: dovodi_db |
||||
|
ports: |
||||
|
- "5513:5432" |
||||
|
restart: unless-stopped |
||||
|
image: postgres:14.0 |
||||
|
volumes: |
||||
|
- postgres_data:/var/lib/postgresql/data/ |
||||
|
env_file: |
||||
|
- .env.prod |
||||
|
networks: |
||||
|
- backend_imam-javad |
||||
|
# dovodi_redis: |
||||
|
# container_name: dovodi_redis |
||||
|
# image: redis:alpine |
||||
|
# env_file: .env.prod |
||||
|
# volumes: |
||||
|
# - redis_data:/data |
||||
|
# networks: |
||||
|
# - imam-javad |
||||
|
# imam-javad_celery: |
||||
|
# container_name: imam-javad_celery |
||||
|
# build: |
||||
|
# context: . |
||||
|
# dockerfile: Dockerfile.prod |
||||
|
# env_file: .env.prod |
||||
|
# command: celery -A config worker -l info |
||||
|
# volumes: |
||||
|
# # - .:/usr/src/app/ |
||||
|
# - staticfiles_volume:/usr/src/app/staticfiles |
||||
|
# - media_volume:/usr/src/app/media |
||||
|
# - logs_volume:/usr/src/app/logs |
||||
|
|
||||
|
# depends_on: |
||||
|
# - imam-javad_redis |
||||
|
# networks: |
||||
|
# - imam-javad |
||||
|
|
||||
|
|
||||
|
# imam-javad_celery-beat: |
||||
|
# container_name: imam-javad_celery_beat |
||||
|
# build: |
||||
|
# context: . |
||||
|
# dockerfile: Dockerfile.prod |
||||
|
# env_file: .env.prod |
||||
|
# command: celery -A config beat -l info |
||||
|
# volumes: |
||||
|
# # - .:/usr/src/app/ |
||||
|
# - logs_volume:/usr/src/app/logs |
||||
|
# depends_on: |
||||
|
# - imam-javad_redis |
||||
|
# networks: |
||||
|
# - imam-javad |
||||
|
|
||||
|
|
||||
|
|
||||
|
volumes: |
||||
|
staticfiles_volume: # Volume برای فایلهای static |
||||
|
media_volume: |
||||
|
postgres_data: # static_volume: |
||||
|
|
||||
|
# redis_data: |
||||
|
|
||||
|
networks: |
||||
|
backend_imam-javad: |
||||
|
external: true |
||||
@ -0,0 +1,76 @@ |
|||||
|
version: '3.8' |
||||
|
|
||||
|
services: |
||||
|
web: |
||||
|
build: . |
||||
|
command: python manage.py runserver 0.0.0.0:8000 |
||||
|
volumes: |
||||
|
- .:/usr/src/app |
||||
|
- static_volume:/usr/src/app/staticfiles |
||||
|
- media_volume:/usr/src/app/media |
||||
|
ports: |
||||
|
- "8000:8000" |
||||
|
env_file: |
||||
|
- .env.dev |
||||
|
depends_on: |
||||
|
- postgres |
||||
|
- redis |
||||
|
networks: |
||||
|
- app_network |
||||
|
|
||||
|
celery_worker: |
||||
|
build: . |
||||
|
command: celery -A config worker -l info |
||||
|
volumes: |
||||
|
- .:/usr/src/app |
||||
|
env_file: |
||||
|
- .env.dev |
||||
|
depends_on: |
||||
|
- postgres |
||||
|
- redis |
||||
|
networks: |
||||
|
- app_network |
||||
|
|
||||
|
postgres: |
||||
|
image: postgres:15-alpine |
||||
|
ports: |
||||
|
- "5432:5432" |
||||
|
volumes: |
||||
|
- postgres_data:/var/lib/postgresql/data |
||||
|
env_file: |
||||
|
- .env.dev |
||||
|
networks: |
||||
|
- app_network |
||||
|
|
||||
|
redis: |
||||
|
image: redis:7-alpine |
||||
|
ports: |
||||
|
- "6379:6379" |
||||
|
volumes: |
||||
|
- redis_data:/data |
||||
|
networks: |
||||
|
- app_network |
||||
|
|
||||
|
centrifugo: |
||||
|
image: centrifugo/centrifugo:v5 |
||||
|
container_name: sch_centrifugo |
||||
|
restart: always |
||||
|
command: centrifugo --config=/centrifugo/config.json |
||||
|
volumes: |
||||
|
- ./centrifugo/config.json:/centrifugo/config.json |
||||
|
ports: |
||||
|
- "8001:8000" |
||||
|
depends_on: |
||||
|
- redis |
||||
|
networks: |
||||
|
- app_network |
||||
|
|
||||
|
volumes: |
||||
|
postgres_data: |
||||
|
redis_data: |
||||
|
static_volume: |
||||
|
media_volume: |
||||
|
|
||||
|
networks: |
||||
|
app_network: |
||||
|
driver: bridge |
||||
@ -0,0 +1,2 @@ |
|||||
|
__version__ = "1.14.0" |
||||
|
default_app_config = "dynamic_preferences.apps.DynamicPreferencesConfig" |
||||
@ -0,0 +1,139 @@ |
|||||
|
from ajaxdatatable.admin import AjaxDatatable |
||||
|
from django.contrib import admin |
||||
|
from django import forms |
||||
|
|
||||
|
from .settings import preferences_settings |
||||
|
from .registries import global_preferences_registry |
||||
|
from .models import GlobalPreferenceModel |
||||
|
from .forms import GlobalSinglePreferenceForm, SinglePerInstancePreferenceForm |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
from unfold.admin import ModelAdmin, TabularInline |
||||
|
from utils.admin import project_admin_site |
||||
|
|
||||
|
class SectionFilter(admin.AllValuesFieldListFilter): |
||||
|
def __init__(self, field, request, params, model, model_admin, field_path): |
||||
|
super(SectionFilter, self).__init__( |
||||
|
field, request, params, model, model_admin, field_path |
||||
|
) |
||||
|
parent_model, reverse_path = admin.utils.reverse_field_path(model, field_path) |
||||
|
if model == parent_model: |
||||
|
queryset = model_admin.get_queryset |
||||
|
else: |
||||
|
queryset = parent_model._default_manager.all() |
||||
|
self.registries = [] |
||||
|
registry_name_set = set() |
||||
|
for preferenceModel in queryset.distinct(): |
||||
|
l = len(registry_name_set) |
||||
|
registry_name_set.add(preferenceModel.registry.__class__.__name__) |
||||
|
if len(registry_name_set) != l: |
||||
|
self.registries.append(preferenceModel.registry) |
||||
|
|
||||
|
def choices(self, changelist): |
||||
|
choices = super(SectionFilter, self).choices(changelist) |
||||
|
for choice in choices: |
||||
|
display = choice["display"] |
||||
|
try: |
||||
|
for registry in self.registries: |
||||
|
display = registry.section_objects[display].verbose_name |
||||
|
choice["display"] = display |
||||
|
except (KeyError): |
||||
|
pass |
||||
|
yield choice |
||||
|
|
||||
|
|
||||
|
# Change DynamicPreferenceAdmin to inherit from unfold's ModelAdmin |
||||
|
class DynamicPreferenceAdmin(ModelAdmin): |
||||
|
list_display = ( |
||||
|
"verbose_name", |
||||
|
"help_text", |
||||
|
) |
||||
|
fields = ("raw_value", "default_value",) |
||||
|
readonly_fields = ("default_value",) |
||||
|
change_form_template = "dynamic_preferences/dyna_change_form.html" |
||||
|
|
||||
|
# Unfold specific settings |
||||
|
search_fields = ["name", "section"] |
||||
|
list_filter = ["section"] |
||||
|
|
||||
|
@admin.display(description=_('Verbose name')) |
||||
|
def verbose_name(self, obj): |
||||
|
return obj.verbose_name |
||||
|
|
||||
|
@admin.display(description=_('Help text')) |
||||
|
def help_text(self, obj): |
||||
|
return obj.help_text |
||||
|
|
||||
|
def has_add_permission(self, request): |
||||
|
# if "root@admin" in request.user.username: |
||||
|
# return True |
||||
|
return False |
||||
|
|
||||
|
def has_delete_permission(self, request, obj=None): |
||||
|
if "root@admin" in request.user.email: |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
if preferences_settings.ADMIN_ENABLE_CHANGELIST_FORM: |
||||
|
def get_changelist_form(self, request, **kwargs): |
||||
|
return self.changelist_form |
||||
|
|
||||
|
def default_value(self, obj): |
||||
|
return obj.preference.default |
||||
|
|
||||
|
default_value.short_description = _("Default Value") |
||||
|
|
||||
|
def section_name(self, obj): |
||||
|
try: |
||||
|
return obj.registry.section_objects[obj.section].verbose_name |
||||
|
except KeyError: |
||||
|
pass |
||||
|
return obj.section |
||||
|
|
||||
|
section_name.short_description = _("Section Name") |
||||
|
|
||||
|
def save_model(self, request, obj, form, change): |
||||
|
pref = form.instance |
||||
|
manager = pref.registry.manager() |
||||
|
manager.update_db_pref(pref.section, pref.name, form.cleaned_data["raw_value"]) |
||||
|
|
||||
|
|
||||
|
class GlobalPreferenceAdmin(DynamicPreferenceAdmin): |
||||
|
form = GlobalSinglePreferenceForm |
||||
|
changelist_form = GlobalSinglePreferenceForm |
||||
|
|
||||
|
# Unfold specific customizations |
||||
|
list_display_links = ["verbose_name"] |
||||
|
|
||||
|
# You can add unfold specific features like: |
||||
|
show_facets = True # Enable faceted filtering |
||||
|
|
||||
|
# Optional: Add custom actions |
||||
|
actions = ["reset_to_default"] |
||||
|
|
||||
|
def reset_to_default(self, request, queryset): |
||||
|
for pref in queryset: |
||||
|
manager = pref.registry.manager() |
||||
|
manager.update_db_pref(pref.section, pref.name, pref.preference.default) |
||||
|
reset_to_default.short_description = _("Reset selected preferences to default values") |
||||
|
|
||||
|
def get_queryset(self, *args, **kwargs): |
||||
|
# Instanciate default prefs |
||||
|
manager = global_preferences_registry.manager() |
||||
|
manager.all() |
||||
|
return super(GlobalPreferenceAdmin, self).get_queryset(*args, **kwargs) |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
project_admin_site.register(GlobalPreferenceModel, GlobalPreferenceAdmin) |
||||
|
|
||||
|
|
||||
|
class PerInstancePreferenceAdmin(DynamicPreferenceAdmin): |
||||
|
list_display = ("instance",) + DynamicPreferenceAdmin.list_display |
||||
|
fields = ("instance",) + DynamicPreferenceAdmin.fields |
||||
|
raw_id_fields = ("instance",) |
||||
|
form = SinglePerInstancePreferenceForm |
||||
|
changelist_form = SinglePerInstancePreferenceForm |
||||
|
list_select_related = True |
||||
@ -0,0 +1,71 @@ |
|||||
|
from rest_framework import serializers |
||||
|
from dynamic_preferences.models import GlobalPreferenceModel |
||||
|
|
||||
|
|
||||
|
class PreferenceValueField(serializers.Field): |
||||
|
def get_attribute(self, o): |
||||
|
return o |
||||
|
|
||||
|
def to_representation(self, o): |
||||
|
return o.preference.api_repr(o.value) |
||||
|
|
||||
|
def to_internal_value(self, data): |
||||
|
return data |
||||
|
|
||||
|
|
||||
|
class PreferenceSerializer(serializers.Serializer): |
||||
|
|
||||
|
section = serializers.CharField(read_only=True) |
||||
|
name = serializers.CharField(read_only=True) |
||||
|
identifier = serializers.SerializerMethodField() |
||||
|
default = serializers.SerializerMethodField() |
||||
|
value = PreferenceValueField() |
||||
|
verbose_name = serializers.SerializerMethodField() |
||||
|
help_text = serializers.SerializerMethodField() |
||||
|
additional_data = serializers.SerializerMethodField() |
||||
|
field = serializers.SerializerMethodField() |
||||
|
|
||||
|
class Meta: |
||||
|
fields = [ |
||||
|
"default", |
||||
|
"value", |
||||
|
"verbose_name", |
||||
|
"help_text", |
||||
|
] |
||||
|
|
||||
|
def get_default(self, o): |
||||
|
return o.preference.api_repr(o.preference.get("default")) |
||||
|
|
||||
|
def get_verbose_name(self, o): |
||||
|
return o.preference.get("verbose_name") |
||||
|
|
||||
|
def get_identifier(self, o): |
||||
|
return o.preference.identifier() |
||||
|
|
||||
|
def get_help_text(self, o): |
||||
|
return o.preference.get("help_text") |
||||
|
|
||||
|
def get_additional_data(self, o): |
||||
|
return o.preference.get_api_additional_data() |
||||
|
|
||||
|
def get_field(self, o): |
||||
|
return o.preference.get_api_field_data() |
||||
|
|
||||
|
def validate_value(self, value): |
||||
|
""" |
||||
|
We call validation from the underlying form field |
||||
|
""" |
||||
|
field = self.instance.preference.setup_field() |
||||
|
value = field.to_python(value) |
||||
|
field.validate(value) |
||||
|
field.run_validators(value) |
||||
|
return value |
||||
|
|
||||
|
def update(self, instance, validated_data): |
||||
|
instance.value = validated_data["value"] |
||||
|
instance.save() |
||||
|
return instance |
||||
|
|
||||
|
|
||||
|
class GlobalPreferenceSerializer(PreferenceSerializer): |
||||
|
pass |
||||
@ -0,0 +1,179 @@ |
|||||
|
from django.db import transaction |
||||
|
from django.db.models import Q |
||||
|
|
||||
|
from rest_framework import mixins |
||||
|
from rest_framework import viewsets |
||||
|
from rest_framework import permissions |
||||
|
from rest_framework.response import Response |
||||
|
from rest_framework.decorators import action |
||||
|
from rest_framework.generics import get_object_or_404 |
||||
|
|
||||
|
from dynamic_preferences import models |
||||
|
from dynamic_preferences import exceptions |
||||
|
from dynamic_preferences.settings import preferences_settings |
||||
|
|
||||
|
from . import serializers |
||||
|
|
||||
|
|
||||
|
class PreferenceViewSet( |
||||
|
mixins.UpdateModelMixin, |
||||
|
mixins.ListModelMixin, |
||||
|
mixins.RetrieveModelMixin, |
||||
|
viewsets.GenericViewSet, |
||||
|
): |
||||
|
""" |
||||
|
- list preferences |
||||
|
- detail given preference |
||||
|
- batch update preferences |
||||
|
- update a single preference |
||||
|
""" |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
""" |
||||
|
We just ensure preferences are actually populated before fetching |
||||
|
from db |
||||
|
""" |
||||
|
self.init_preferences() |
||||
|
queryset = super(PreferenceViewSet, self).get_queryset() |
||||
|
|
||||
|
section = self.request.query_params.get("section") |
||||
|
if section: |
||||
|
queryset = queryset.filter(section=section) |
||||
|
|
||||
|
return queryset |
||||
|
|
||||
|
def get_manager(self): |
||||
|
return self.queryset.model.registry.manager() |
||||
|
|
||||
|
def init_preferences(self): |
||||
|
manager = self.get_manager() |
||||
|
manager.all() |
||||
|
|
||||
|
def get_object(self): |
||||
|
""" |
||||
|
Returns the object the view is displaying. |
||||
|
You may want to override this if you need to provide non-standard |
||||
|
queryset lookups. Eg if objects are referenced using multiple |
||||
|
keyword arguments in the url conf. |
||||
|
""" |
||||
|
queryset = self.filter_queryset(self.get_queryset()) |
||||
|
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field |
||||
|
identifier = self.kwargs[lookup_url_kwarg] |
||||
|
section, name = self.get_section_and_name(identifier) |
||||
|
filter_kwargs = {"section": section, "name": name} |
||||
|
obj = get_object_or_404(queryset, **filter_kwargs) |
||||
|
|
||||
|
# May raise a permission denied |
||||
|
self.check_object_permissions(self.request, obj) |
||||
|
|
||||
|
return obj |
||||
|
|
||||
|
def get_section_and_name(self, identifier): |
||||
|
try: |
||||
|
section, name = identifier.split(preferences_settings.SECTION_KEY_SEPARATOR) |
||||
|
except ValueError: |
||||
|
# no section given |
||||
|
section, name = None, identifier |
||||
|
|
||||
|
return section, name |
||||
|
|
||||
|
@action(detail=False, methods=["post"]) |
||||
|
@transaction.atomic |
||||
|
def bulk(self, request, *args, **kwargs): |
||||
|
""" |
||||
|
Update multiple preferences at once |
||||
|
|
||||
|
this is a long method because we ensure everything is valid |
||||
|
before actually persisting the changes |
||||
|
""" |
||||
|
manager = self.get_manager() |
||||
|
errors = {} |
||||
|
preferences = [] |
||||
|
payload = request.data |
||||
|
|
||||
|
# first, we check updated preferences actually exists in the registry |
||||
|
try: |
||||
|
for identifier, value in payload.items(): |
||||
|
try: |
||||
|
preferences.append(self.queryset.model.registry.get(identifier)) |
||||
|
except exceptions.NotFoundInRegistry: |
||||
|
errors[identifier] = "invalid preference" |
||||
|
except (TypeError, AttributeError): |
||||
|
return Response("invalid payload", status=400) |
||||
|
|
||||
|
if errors: |
||||
|
return Response(errors, status=400) |
||||
|
|
||||
|
# now, we generate an optimized Q objects to retrieve all matching |
||||
|
# preferences at once from database |
||||
|
queries = [Q(section=p.section.name, name=p.name) for p in preferences] |
||||
|
|
||||
|
query = queries[0] |
||||
|
for q in queries[1:]: |
||||
|
query |= q |
||||
|
preferences_qs = self.get_queryset().filter(query) |
||||
|
|
||||
|
# next, we generate a serializer for each database preference |
||||
|
serializer_objects = [] |
||||
|
for p in preferences_qs: |
||||
|
s = self.get_serializer_class()( |
||||
|
p, data={"value": payload[p.preference.identifier()]} |
||||
|
) |
||||
|
serializer_objects.append(s) |
||||
|
|
||||
|
validation_errors = {} |
||||
|
|
||||
|
# we check if any serializer is invalid |
||||
|
for s in serializer_objects: |
||||
|
if s.is_valid(): |
||||
|
continue |
||||
|
validation_errors[s.instance.preference.identifier()] = s.errors |
||||
|
|
||||
|
if validation_errors: |
||||
|
return Response(validation_errors, status=400) |
||||
|
|
||||
|
for s in serializer_objects: |
||||
|
s.save() |
||||
|
|
||||
|
return Response( |
||||
|
[s.data for s in serializer_objects], |
||||
|
status=200, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class GlobalPreferencePermission(permissions.DjangoModelPermissions): |
||||
|
perms_map = { |
||||
|
"GET": ["%(app_label)s.change_%(model_name)s"], |
||||
|
"OPTIONS": ["%(app_label)s.change_%(model_name)s"], |
||||
|
"HEAD": ["%(app_label)s.change_%(model_name)s"], |
||||
|
"POST": ["%(app_label)s.change_%(model_name)s"], |
||||
|
"PUT": ["%(app_label)s.change_%(model_name)s"], |
||||
|
"PATCH": ["%(app_label)s.change_%(model_name)s"], |
||||
|
"DELETE": ["%(app_label)s.change_%(model_name)s"], |
||||
|
} |
||||
|
|
||||
|
|
||||
|
class GlobalPreferencesViewSet(PreferenceViewSet): |
||||
|
queryset = models.GlobalPreferenceModel.objects.all() |
||||
|
serializer_class = serializers.GlobalPreferenceSerializer |
||||
|
permission_classes = [GlobalPreferencePermission] |
||||
|
|
||||
|
|
||||
|
class PerInstancePreferenceViewSet(PreferenceViewSet): |
||||
|
def get_manager(self): |
||||
|
return self.queryset.model.registry.manager( |
||||
|
instance=self.get_related_instance() |
||||
|
) |
||||
|
|
||||
|
def get_queryset(self): |
||||
|
return ( |
||||
|
super(PerInstancePreferenceViewSet, self) |
||||
|
.get_queryset() |
||||
|
.filter(instance=self.get_related_instance()) |
||||
|
) |
||||
|
|
||||
|
def get_related_instance(self): |
||||
|
""" |
||||
|
Override this to the instance bound to the preferences |
||||
|
""" |
||||
|
raise NotImplementedError |
||||
@ -0,0 +1,25 @@ |
|||||
|
from django.apps import AppConfig, apps |
||||
|
from django.conf import settings |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
from .registries import preference_models, global_preferences_registry |
||||
|
from .settings import preferences_settings |
||||
|
|
||||
|
|
||||
|
class DynamicPreferencesConfig(AppConfig): |
||||
|
name = "dynamic_preferences" |
||||
|
verbose_name = _("Settings") |
||||
|
default_auto_field = "django.db.models.AutoField" |
||||
|
icon = 'mi-settings' |
||||
|
|
||||
|
def ready(self): |
||||
|
if preferences_settings.ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION: |
||||
|
GlobalPreferenceModel = self.get_model("GlobalPreferenceModel") |
||||
|
|
||||
|
preference_models.register( |
||||
|
GlobalPreferenceModel, global_preferences_registry |
||||
|
) |
||||
|
|
||||
|
# This will load all dynamic_preferences_registry.py files under |
||||
|
# installed apps |
||||
|
app_names = [app.name for app in apps.app_configs.values()] |
||||
|
global_preferences_registry.autodiscover(app_names) |
||||
@ -0,0 +1,108 @@ |
|||||
|
import json |
||||
|
from django import forms |
||||
|
from django.utils.translation import gettext_lazy as _ |
||||
|
|
||||
|
from dynamic_preferences.preferences import Section |
||||
|
from dynamic_preferences.registries import global_preferences_registry |
||||
|
from dynamic_preferences.types import ( |
||||
|
BasePreferenceType, |
||||
|
BaseSerializer, |
||||
|
LongStringPreference, |
||||
|
StringPreference, |
||||
|
BooleanPreference, |
||||
|
) |
||||
|
from unfold.contrib.forms.widgets import WysiwygWidget |
||||
|
from unfold.widgets import UnfoldAdminTextareaWidget |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------- |
||||
|
# Preference Types & Custom Serializers |
||||
|
# --------------------------------------------------------- |
||||
|
|
||||
|
class EditorPreferences(LongStringPreference): |
||||
|
widget = WysiwygWidget(attrs={'class': 'editor-field'}) |
||||
|
|
||||
|
|
||||
|
class EditorTextPreferences(LongStringPreference): |
||||
|
widget = UnfoldAdminTextareaWidget(attrs={'class': 'editor-field', 'rows': 15}) |
||||
|
|
||||
|
|
||||
|
class JsonSerializer(BaseSerializer): |
||||
|
@classmethod |
||||
|
def serialize(cls, value, **kwargs): |
||||
|
return json.dumps(value, ensure_ascii=False) |
||||
|
|
||||
|
@classmethod |
||||
|
def to_python(cls, value, **kwargs): |
||||
|
if isinstance(value, str) and len(value.strip()) > 0: |
||||
|
try: |
||||
|
return json.loads(value) |
||||
|
except json.JSONDecodeError: |
||||
|
return {} |
||||
|
return value if isinstance(value, dict) else {} |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------- |
||||
|
# Sections |
||||
|
# --------------------------------------------------------- |
||||
|
|
||||
|
general_section = Section('general', verbose_name=_('General Settings')) |
||||
|
contact_section = Section('contact', verbose_name=_('Contact & Support')) |
||||
|
content_section = Section('content', verbose_name=_('Content Pages')) |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------- |
||||
|
# Registered Preferences |
||||
|
# --------------------------------------------------------- |
||||
|
|
||||
|
@global_preferences_registry.register |
||||
|
class SiteName(StringPreference): |
||||
|
section = general_section |
||||
|
name = 'site_name' |
||||
|
verbose_name = _('Site Name') |
||||
|
default = 'My Application' |
||||
|
required = True |
||||
|
|
||||
|
|
||||
|
@global_preferences_registry.register |
||||
|
class MaintenanceMode(BooleanPreference): |
||||
|
section = general_section |
||||
|
name = 'maintenance_mode' |
||||
|
verbose_name = _('Maintenance Mode') |
||||
|
default = False |
||||
|
|
||||
|
|
||||
|
@global_preferences_registry.register |
||||
|
class ContactEmail(StringPreference): |
||||
|
section = contact_section |
||||
|
name = 'contact_email' |
||||
|
verbose_name = _('Support Email') |
||||
|
default = '[email protected]' |
||||
|
required = False |
||||
|
|
||||
|
|
||||
|
@global_preferences_registry.register |
||||
|
class SupportPhone(StringPreference): |
||||
|
section = contact_section |
||||
|
name = 'support_phone' |
||||
|
verbose_name = _('Support Phone Number') |
||||
|
default = '+1 555 1234567' |
||||
|
required = False |
||||
|
|
||||
|
|
||||
|
@global_preferences_registry.register |
||||
|
class AboutUs(EditorPreferences): |
||||
|
section = content_section |
||||
|
name = 'about_us' |
||||
|
verbose_name = _('About Us Page') |
||||
|
default = '<h2>About Us</h2><p>Welcome to our application.</p>' |
||||
|
required = False |
||||
|
|
||||
|
|
||||
|
@global_preferences_registry.register |
||||
|
class TermsAndConditions(EditorPreferences): |
||||
|
section = content_section |
||||
|
name = 'terms_and_conditions' |
||||
|
verbose_name = _('Terms & Conditions') |
||||
|
default = '<h2>Terms and Conditions</h2><p>Standard terms and conditions.</p>' |
||||
|
required = False |
||||
@ -0,0 +1,32 @@ |
|||||
|
class DynamicPreferencesException(Exception): |
||||
|
detail_default = "An exception occurred with django-dynamic-preferences" |
||||
|
|
||||
|
def __init__(self, detail=None): |
||||
|
if detail is not None: |
||||
|
self.detail = str(detail) |
||||
|
else: |
||||
|
self.detail = str(self.detail_default) |
||||
|
|
||||
|
def __str__(self): |
||||
|
return self.detail |
||||
|
|
||||
|
|
||||
|
class MissingDefault(DynamicPreferencesException): |
||||
|
detail_default = "You must provide a default value for all preferences" |
||||
|
|
||||
|
|
||||
|
class NotFoundInRegistry(DynamicPreferencesException, KeyError): |
||||
|
detail_default = "Preference with this name/section not found in registry" |
||||
|
|
||||
|
|
||||
|
class DoesNotExist(DynamicPreferencesException): |
||||
|
detail_default = "Cannot retrieve preference value, ensure the preference is correctly registered and database is synced" |
||||
|
|
||||
|
|
||||
|
class CachedValueNotFound(DynamicPreferencesException): |
||||
|
detail_default = "Cached value not found" |
||||
|
|
||||
|
|
||||
|
class MissingModel(DynamicPreferencesException): |
||||
|
detail_default = 'You must define a model choice through "model" \ |
||||
|
or "queryset" attribute' |
||||
@ -0,0 +1,152 @@ |
|||||
|
from six import string_types |
||||
|
from django import forms |
||||
|
from django.core.exceptions import ValidationError |
||||
|
from collections import OrderedDict |
||||
|
|
||||
|
from .registries import global_preferences_registry |
||||
|
from .models import GlobalPreferenceModel |
||||
|
from .exceptions import NotFoundInRegistry |
||||
|
|
||||
|
|
||||
|
class AbstractSinglePreferenceForm(forms.ModelForm): |
||||
|
class Meta: |
||||
|
fields = ("section", "name", "raw_value") |
||||
|
|
||||
|
def __init__(self, *args, **kwargs): |
||||
|
|
||||
|
self.instance = kwargs.get("instance") |
||||
|
initial = {} |
||||
|
if self.instance: |
||||
|
initial["raw_value"] = self.instance.value |
||||
|
kwargs["initial"] = initial |
||||
|
super(AbstractSinglePreferenceForm, self).__init__(*args, **kwargs) |
||||
|
|
||||
|
if self.instance.name: |
||||
|
self.fields["raw_value"] = self.instance.preference.setup_field() |
||||
|
|
||||
|
def clean(self): |
||||
|
cleaned_data = super(AbstractSinglePreferenceForm, self).clean() |
||||
|
try: |
||||
|
self.instance.name, self.instance.section = ( |
||||
|
cleaned_data["name"], |
||||
|
cleaned_data["section"], |
||||
|
) |
||||
|
except KeyError: # changelist form |
||||
|
pass |
||||
|
try: |
||||
|
self.instance.preference |
||||
|
except NotFoundInRegistry: |
||||
|
raise ValidationError(NotFoundInRegistry.detail_default) |
||||
|
return self.cleaned_data |
||||
|
|
||||
|
def save(self, *args, **kwargs): |
||||
|
self.instance.value = self.cleaned_data["raw_value"] |
||||
|
return super(AbstractSinglePreferenceForm, self).save(*args, **kwargs) |
||||
|
|
||||
|
|
||||
|
class SinglePerInstancePreferenceForm(AbstractSinglePreferenceForm): |
||||
|
class Meta: |
||||
|
fields = ("instance",) + AbstractSinglePreferenceForm.Meta.fields |
||||
|
|
||||
|
def clean(self): |
||||
|
cleaned_data = super(AbstractSinglePreferenceForm, self).clean() |
||||
|
try: |
||||
|
self.instance.name, self.instance.section = ( |
||||
|
cleaned_data["name"], |
||||
|
cleaned_data["section"], |
||||
|
) |
||||
|
except KeyError: # changelist form |
||||
|
pass |
||||
|
i = cleaned_data.get("instance") |
||||
|
if i: |
||||
|
self.instance.instance = i |
||||
|
try: |
||||
|
self.instance.preference |
||||
|
except NotFoundInRegistry: |
||||
|
raise ValidationError(NotFoundInRegistry.detail_default) |
||||
|
return self.cleaned_data |
||||
|
|
||||
|
|
||||
|
class GlobalSinglePreferenceForm(AbstractSinglePreferenceForm): |
||||
|
class Meta: |
||||
|
model = GlobalPreferenceModel |
||||
|
fields = AbstractSinglePreferenceForm.Meta.fields |
||||
|
|
||||
|
|
||||
|
def preference_form_builder(form_base_class, preferences=[], **kwargs): |
||||
|
""" |
||||
|
Return a form class for updating preferences |
||||
|
:param form_base_class: a Form class used as the base. Must have a ``registry` attribute |
||||
|
:param preferences: a list of :py:class: |
||||
|
:param section: a section where the form builder will load preferences |
||||
|
""" |
||||
|
registry = form_base_class.registry |
||||
|
preferences_obj = [] |
||||
|
if len(preferences) > 0: |
||||
|
# Preferences have been selected explicitly |
||||
|
for pref in preferences: |
||||
|
if isinstance(pref, string_types): |
||||
|
preferences_obj.append(registry.get(name=pref)) |
||||
|
elif type(pref) == tuple: |
||||
|
preferences_obj.append(registry.get(name=pref[0], section=pref[1])) |
||||
|
else: |
||||
|
raise NotImplementedError( |
||||
|
"The data you provide can't be converted to a Preference object" |
||||
|
) |
||||
|
elif kwargs.get("section", None): |
||||
|
# Try to use section param |
||||
|
preferences_obj = registry.preferences(section=kwargs.get("section", None)) |
||||
|
|
||||
|
else: |
||||
|
# display all preferences in the form |
||||
|
preferences_obj = registry.preferences() |
||||
|
|
||||
|
fields = OrderedDict() |
||||
|
instances = [] |
||||
|
if "model" in kwargs: |
||||
|
# backward compat, see #212 |
||||
|
manager_kwargs = kwargs.get("model") |
||||
|
else: |
||||
|
manager_kwargs = {"instance": kwargs.get("instance", None)} |
||||
|
manager = registry.manager(**manager_kwargs) |
||||
|
|
||||
|
for preference in preferences_obj: |
||||
|
f = preference.field |
||||
|
instance = manager.get_db_pref( |
||||
|
section=preference.section.name, name=preference.name |
||||
|
) |
||||
|
f.initial = instance.value |
||||
|
fields[preference.identifier()] = f |
||||
|
instances.append(instance) |
||||
|
|
||||
|
form_class = type("Custom" + form_base_class.__name__, (form_base_class,), {}) |
||||
|
form_class.base_fields = fields |
||||
|
form_class.preferences = preferences_obj |
||||
|
form_class.instances = instances |
||||
|
form_class.manager = manager |
||||
|
return form_class |
||||
|
|
||||
|
|
||||
|
def global_preference_form_builder(preferences=[], **kwargs): |
||||
|
""" |
||||
|
A shortcut :py:func:`preference_form_builder(GlobalPreferenceForm, preferences, **kwargs)` |
||||
|
""" |
||||
|
return preference_form_builder(GlobalPreferenceForm, preferences, **kwargs) |
||||
|
|
||||
|
|
||||
|
class PreferenceForm(forms.Form): |
||||
|
|
||||
|
registry = None |
||||
|
|
||||
|
def update_preferences(self, **kwargs): |
||||
|
for instance in self.instances: |
||||
|
self.manager.update_db_pref( |
||||
|
instance.preference.section.name, |
||||
|
instance.preference.name, |
||||
|
self.cleaned_data[instance.preference.identifier()], |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class GlobalPreferenceForm(PreferenceForm): |
||||
|
|
||||
|
registry = global_preferences_registry |
||||
@ -0,0 +1,95 @@ |
|||||
|
# SOME DESCRIPTIVE TITLE. |
||||
|
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER |
||||
|
# This file is distributed under the same license as the PACKAGE package. |
||||
|
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
||||
|
# |
||||
|
msgid "" |
||||
|
msgstr "" |
||||
|
"Project-Id-Version: \n" |
||||
|
"Report-Msgid-Bugs-To: \n" |
||||
|
"POT-Creation-Date: 2026-05-20 08:25+0330\n" |
||||
|
"PO-Revision-Date: 2018-11-09 17:15+0100\n" |
||||
|
"Last-Translator: \n" |
||||
|
"Language-Team: \n" |
||||
|
"Language: ar\n" |
||||
|
"MIME-Version: 1.0\n" |
||||
|
"Content-Type: text/plain; charset=UTF-8\n" |
||||
|
"Content-Transfer-Encoding: 8bit\n" |
||||
|
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " |
||||
|
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" |
||||
|
"X-Generator: Poedit 2.1.1\n" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:59 |
||||
|
#, fuzzy |
||||
|
#| msgid "Verbose Name" |
||||
|
msgid "Verbose name" |
||||
|
msgstr "اسم مطول" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:63 |
||||
|
#, fuzzy |
||||
|
#| msgid "Help Text" |
||||
|
msgid "Help text" |
||||
|
msgstr "نص المساعدة" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:84 |
||||
|
msgid "Default Value" |
||||
|
msgstr "القيمة الافتراضية" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 |
||||
|
msgid "Section Name" |
||||
|
msgstr "إسم القسم" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:118 |
||||
|
msgid "Reset selected preferences to default values" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\apps.py:10 |
||||
|
msgid "Settings" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:34 |
||||
|
msgid "Name" |
||||
|
msgstr "الاسم" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:37 |
||||
|
msgid "Raw Value" |
||||
|
msgstr "القيمة الأولية" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:51 |
||||
|
msgid "Verbose Name" |
||||
|
msgstr "اسم مطول" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:57 |
||||
|
msgid "Help Text" |
||||
|
msgstr "نص المساعدة" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:94 |
||||
|
msgid "Global preference" |
||||
|
msgstr "التفضيل العام" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:95 |
||||
|
msgid "Global preferences" |
||||
|
msgstr "التفضيل العام" |
||||
|
|
||||
|
#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 |
||||
|
msgid "Submit" |
||||
|
msgstr "إرسال" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\apps.py:11 |
||||
|
msgid "Preferences - Users" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\models.py:14 |
||||
|
#, fuzzy |
||||
|
#| msgid "Global preference" |
||||
|
msgid "user preference" |
||||
|
msgstr "التفضيل العام" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\models.py:15 |
||||
|
#, fuzzy |
||||
|
#| msgid "Global preferences" |
||||
|
msgid "user preferences" |
||||
|
msgstr "التفضيل العام" |
||||
|
|
||||
|
#~ msgid "Dynamic Preferences" |
||||
|
#~ msgstr "التفضيلات الديناميكية" |
||||
@ -0,0 +1,82 @@ |
|||||
|
# SOME DESCRIPTIVE TITLE. |
||||
|
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER |
||||
|
# This file is distributed under the same license as the PACKAGE package. |
||||
|
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
||||
|
# |
||||
|
#, fuzzy |
||||
|
msgid "" |
||||
|
msgstr "" |
||||
|
"Project-Id-Version: PACKAGE VERSION\n" |
||||
|
"Report-Msgid-Bugs-To: \n" |
||||
|
"POT-Creation-Date: 2026-05-20 08:25+0330\n" |
||||
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" |
||||
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" |
||||
|
"Language-Team: LANGUAGE <[email protected]>\n" |
||||
|
"Language: \n" |
||||
|
"MIME-Version: 1.0\n" |
||||
|
"Content-Type: text/plain; charset=UTF-8\n" |
||||
|
"Content-Transfer-Encoding: 8bit\n" |
||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n" |
||||
|
#: .\dynamic_preferences\admin.py:59 |
||||
|
msgid "Verbose name" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:63 |
||||
|
msgid "Help text" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:84 |
||||
|
msgid "Default Value" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 |
||||
|
msgid "Section Name" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:118 |
||||
|
msgid "Reset selected preferences to default values" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\apps.py:10 |
||||
|
msgid "Settings" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:34 |
||||
|
msgid "Name" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:37 |
||||
|
msgid "Raw Value" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:51 |
||||
|
msgid "Verbose Name" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:57 |
||||
|
msgid "Help Text" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:94 |
||||
|
msgid "Global preference" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:95 |
||||
|
msgid "Global preferences" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 |
||||
|
msgid "Submit" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\apps.py:11 |
||||
|
msgid "Preferences - Users" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\models.py:14 |
||||
|
msgid "user preference" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\models.py:15 |
||||
|
msgid "user preferences" |
||||
|
msgstr "" |
||||
@ -0,0 +1,82 @@ |
|||||
|
# SOME DESCRIPTIVE TITLE. |
||||
|
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER |
||||
|
# This file is distributed under the same license as the PACKAGE package. |
||||
|
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
||||
|
# |
||||
|
#, fuzzy |
||||
|
msgid "" |
||||
|
msgstr "" |
||||
|
"Project-Id-Version: PACKAGE VERSION\n" |
||||
|
"Report-Msgid-Bugs-To: \n" |
||||
|
"POT-Creation-Date: 2026-05-20 08:25+0330\n" |
||||
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" |
||||
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" |
||||
|
"Language-Team: LANGUAGE <[email protected]>\n" |
||||
|
"Language: \n" |
||||
|
"MIME-Version: 1.0\n" |
||||
|
"Content-Type: text/plain; charset=UTF-8\n" |
||||
|
"Content-Transfer-Encoding: 8bit\n" |
||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n" |
||||
|
#: .\dynamic_preferences\admin.py:59 |
||||
|
msgid "Verbose name" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:63 |
||||
|
msgid "Help text" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:84 |
||||
|
msgid "Default Value" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 |
||||
|
msgid "Section Name" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:118 |
||||
|
msgid "Reset selected preferences to default values" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\apps.py:10 |
||||
|
msgid "Settings" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:34 |
||||
|
msgid "Name" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:37 |
||||
|
msgid "Raw Value" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:51 |
||||
|
msgid "Verbose Name" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:57 |
||||
|
msgid "Help Text" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:94 |
||||
|
msgid "Global preference" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:95 |
||||
|
msgid "Global preferences" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 |
||||
|
msgid "Submit" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\apps.py:11 |
||||
|
msgid "Preferences - Users" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\models.py:14 |
||||
|
msgid "user preference" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\models.py:15 |
||||
|
msgid "user preferences" |
||||
|
msgstr "" |
||||
@ -0,0 +1,90 @@ |
|||||
|
# SOME DESCRIPTIVE TITLE. |
||||
|
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER |
||||
|
# This file is distributed under the same license as the PACKAGE package. |
||||
|
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
||||
|
# |
||||
|
msgid "" |
||||
|
msgstr "" |
||||
|
"Project-Id-Version: \n" |
||||
|
"Report-Msgid-Bugs-To: \n" |
||||
|
"POT-Creation-Date: 2026-05-20 08:25+0330\n" |
||||
|
"PO-Revision-Date: 2018-11-09 17:14+0100\n" |
||||
|
"Last-Translator: \n" |
||||
|
"Language-Team: \n" |
||||
|
"Language: fr\n" |
||||
|
"MIME-Version: 1.0\n" |
||||
|
"Content-Type: text/plain; charset=UTF-8\n" |
||||
|
"Content-Transfer-Encoding: 8bit\n" |
||||
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n" |
||||
|
"X-Generator: Poedit 2.1.1\n" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:59 |
||||
|
#, fuzzy |
||||
|
#| msgid "Verbose Name" |
||||
|
msgid "Verbose name" |
||||
|
msgstr "Bezeichnung" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:63 |
||||
|
#, fuzzy |
||||
|
#| msgid "Help Text" |
||||
|
msgid "Help text" |
||||
|
msgstr "Hilfetext" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:84 |
||||
|
msgid "Default Value" |
||||
|
msgstr "Standardwert" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 |
||||
|
msgid "Section Name" |
||||
|
msgstr "Abschnitt" |
||||
|
|
||||
|
#: .\dynamic_preferences\admin.py:118 |
||||
|
msgid "Reset selected preferences to default values" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\apps.py:10 |
||||
|
msgid "Settings" |
||||
|
msgstr "" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:34 |
||||
|
msgid "Name" |
||||
|
msgstr "Name" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:37 |
||||
|
msgid "Raw Value" |
||||
|
msgstr "Wert" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:51 |
||||
|
msgid "Verbose Name" |
||||
|
msgstr "Bezeichnung" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:57 |
||||
|
msgid "Help Text" |
||||
|
msgstr "Hilfetext" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:94 |
||||
|
msgid "Global preference" |
||||
|
msgstr "Globale Einstellung" |
||||
|
|
||||
|
#: .\dynamic_preferences\models.py:95 |
||||
|
msgid "Global preferences" |
||||
|
msgstr "Globale Einstellungen" |
||||
|
|
||||
|
#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 |
||||
|
msgid "Submit" |
||||
|
msgstr "Absenden" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\apps.py:11 |
||||
|
msgid "Preferences - Users" |
||||
|
msgstr "Einstellungen - Benutzer" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\models.py:14 |
||||
|
msgid "user preference" |
||||
|
msgstr "Benutzer Einstellung" |
||||
|
|
||||
|
#: .\dynamic_preferences\users\models.py:15 |
||||
|
msgid "user preferences" |
||||
|
msgstr "Benutzer Einstellungen" |
||||
|
|
||||
|
#~ msgid "Dynamic Preferences" |
||||
|
#~ msgstr "Dynamische Einstellungen" |
||||
Some files were not shown because too many files changed in this diff
Write
Preview
Loading…
Cancel
Save
Reference in new issue