Browse Source

feat: initialize Django backend project structure with core apps, API views, and configurations

main
sina_sajjadi 4 weeks ago
commit
3e9f6f994c
  1. 66
      .dockerignore
  2. 24
      .env.dev
  3. 35
      .env.example
  4. 23
      .env.prod
  5. 419
      .gitignore
  6. 32
      Dockerfile
  7. 46
      Dockerfile.prod
  8. 195
      README.md
  9. 100
      apps/account/API_ERRORS.md
  10. 0
      apps/account/__init__.py
  11. 32
      apps/account/admin/__init__.py
  12. 59
      apps/account/admin/location.py
  13. 14
      apps/account/admin/notification.py
  14. 158
      apps/account/admin/user.py
  15. 7
      apps/account/apps.py
  16. 28
      apps/account/custom_user_login.py
  17. 835
      apps/account/doc.py
  18. 0
      apps/account/management/__init__.py
  19. 0
      apps/account/management/commands/__init__,py
  20. 0
      apps/account/management/commands/__init__.py
  21. 28
      apps/account/management/commands/assign_professor_slugs.py
  22. 187
      apps/account/management/commands/audit_admin_panel_access.py
  23. 175
      apps/account/management/commands/create_geonames_table.py
  24. 52
      apps/account/management/commands/create_groups.py
  25. 208
      apps/account/management/commands/seed_notification_templates.py
  26. 115
      apps/account/management/commands/send_test_notifications.py
  27. 55
      apps/account/management/commands/set_default_passwords.py
  28. 37
      apps/account/management/commands/test_guest_token.py
  29. 41
      apps/account/manager.py
  30. 176
      apps/account/migrations/0001_initial.py
  31. 0
      apps/account/migrations/__init__.py
  32. 3
      apps/account/models/__init__.py
  33. 51
      apps/account/models/groups.py
  34. 53
      apps/account/models/notification.py
  35. 238
      apps/account/models/user.py
  36. 119
      apps/account/notification_service.py
  37. 56
      apps/account/permissions.py
  38. 4
      apps/account/serializers/__init__.py
  39. 11
      apps/account/serializers/auth.py
  40. 37
      apps/account/serializers/location_history.py
  41. 36
      apps/account/serializers/notification.py
  42. 377
      apps/account/serializers/user.py
  43. 29
      apps/account/serializers/user_web.py
  44. 39
      apps/account/tasks.py
  45. 40
      apps/account/templates/account/group_help_text.html
  46. 839
      apps/account/templates/account/json_editor_field.html
  47. 33
      apps/account/templates/account/user_list_section.html
  48. 47
      apps/account/tests/test_admin_panel_access.py
  49. 47
      apps/account/urls.py
  50. 4
      apps/account/views/__init__.py
  51. 163
      apps/account/views/auth.py
  52. 69
      apps/account/views/location_history.py
  53. 103
      apps/account/views/notification.py
  54. 942
      apps/account/views/user.py
  55. 0
      apps/api/__init__.py
  56. 28
      apps/api/admin.py
  57. 6
      apps/api/apps.py
  58. 42
      apps/api/decorators.py
  59. 52
      apps/api/migrations/0001_initial.py
  60. 0
      apps/api/migrations/__init__.py
  61. 125
      apps/api/models.py
  62. 71
      apps/api/permissions.py
  63. 36
      apps/api/serializers.py
  64. 3
      apps/api/tests.py
  65. 8
      apps/api/urls.py
  66. 2
      apps/api/views.py
  67. 14
      apps/api/views/__init__.py
  68. 61
      apps/api/views/admin_dashboard.py
  69. 74
      apps/api/views/api_views.py
  70. 83
      apps/api/views/swagger_views.py
  71. 29
      centrifugo/config.json
  72. 8
      config/__init__.py
  73. 16
      config/asgi.py
  74. 22
      config/celery.py
  75. 66
      config/enhanced_auth_middleware.py
  76. 14
      config/language_code_middleware.py
  77. 22
      config/middleware/__init__.py
  78. 15
      config/redis_config.py
  79. 0
      config/settings/__init__.py
  80. 379
      config/settings/base.py
  81. 24
      config/settings/develop.py
  82. 50
      config/settings/production.py
  83. 12
      config/settings/test.py
  84. 118
      config/urls.py
  85. 16
      config/wsgi.py
  86. 91
      docker-compose.prod.yml
  87. 76
      docker-compose.yml
  88. 2
      dynamic_preferences/__init__.py
  89. 139
      dynamic_preferences/admin.py
  90. 0
      dynamic_preferences/api/__init__.py
  91. 71
      dynamic_preferences/api/serializers.py
  92. 179
      dynamic_preferences/api/viewsets.py
  93. 25
      dynamic_preferences/apps.py
  94. 108
      dynamic_preferences/dynamic_preferences_registry.py
  95. 32
      dynamic_preferences/exceptions.py
  96. 152
      dynamic_preferences/forms.py
  97. 95
      dynamic_preferences/locale/ar/LC_MESSAGES/django.po
  98. 82
      dynamic_preferences/locale/az/LC_MESSAGES/django.po
  99. 82
      dynamic_preferences/locale/bn/LC_MESSAGES/django.po
  100. 90
      dynamic_preferences/locale/de/LC_MESSAGES/django.po

66
.dockerignore

@ -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

24
.env.dev

@ -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

35
.env.example

@ -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

23
.env.prod

@ -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=

419
.gitignore

@ -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.*

32
Dockerfile

@ -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"]

46
Dockerfile.prod

@ -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"]

195
README.md

@ -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

100
apps/account/API_ERRORS.md

@ -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
apps/account/__init__.py

32
apps/account/admin/__init__.py

@ -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

59
apps/account/admin/location.py

@ -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)

14
apps/account/admin/notification.py

@ -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',]

158
apps/account/admin/user.py

@ -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

7
apps/account/apps.py

@ -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'

28
apps/account/custom_user_login.py

@ -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

835
apps/account/doc.py

@ -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
apps/account/management/__init__.py

0
apps/account/management/commands/__init__,py

0
apps/account/management/commands/__init__.py

28
apps/account/management/commands/assign_professor_slugs.py

@ -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).")
)

187
apps/account/management/commands/audit_admin_panel_access.py

@ -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

175
apps/account/management/commands/create_geonames_table.py

@ -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)

52
apps/account/management/commands/create_groups.py

@ -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."))

208
apps/account/management/commands/seed_notification_templates.py

@ -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."))

115
apps/account/management/commands/send_test_notifications.py

@ -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!"))

55
apps/account/management/commands/set_default_passwords.py

@ -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."))

37
apps/account/management/commands/test_guest_token.py

@ -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!")

41
apps/account/manager.py

@ -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")

176
apps/account/migrations/0001_initial.py

@ -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
apps/account/migrations/__init__.py

3
apps/account/models/__init__.py

@ -0,0 +1,3 @@
from .user import *
from .groups import *
from .notification import *

51
apps/account/models/groups.py

@ -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")

53
apps/account/models/notification.py

@ -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})"

238
apps/account/models/user.py

@ -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}"

119
apps/account/notification_service.py

@ -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

56
apps/account/permissions.py

@ -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

4
apps/account/serializers/__init__.py

@ -0,0 +1,4 @@
from .user import *
from .notification import *
from .auth import *
from .location_history import *

11
apps/account/serializers/auth.py

@ -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

37
apps/account/serializers/location_history.py

@ -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)

36
apps/account/serializers/notification.py

@ -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']

377
apps/account/serializers/user.py

@ -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

29
apps/account/serializers/user_web.py

@ -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

39
apps/account/tasks.py

@ -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

40
apps/account/templates/account/group_help_text.html

@ -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>

839
apps/account/templates/account/json_editor_field.html

@ -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>

33
apps/account/templates/account/user_list_section.html

@ -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>

47
apps/account/tests/test_admin_panel_access.py

@ -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)

47
apps/account/urls.py

@ -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)),
]

4
apps/account/views/__init__.py

@ -0,0 +1,4 @@
from .user import *
from .notification import *
from .auth import *
from .location_history import*

163
apps/account/views/auth.py

@ -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)

69
apps/account/views/location_history.py

@ -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,
})

103
apps/account/views/notification.py

@ -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

942
apps/account/views/user.py

@ -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
apps/api/__init__.py

28
apps/api/admin.py

@ -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)

6
apps/api/apps.py

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.api'

42
apps/api/decorators.py

@ -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

52
apps/api/migrations/0001_initial.py

@ -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
apps/api/migrations/__init__.py

125
apps/api/models.py

@ -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}'

71
apps/api/permissions.py

@ -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

36
apps/api/serializers.py

@ -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']

3
apps/api/tests.py

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
apps/api/urls.py

@ -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'),
]

2
apps/api/views.py

@ -0,0 +1,2 @@
# Legacy views - moved to views/api_views.py for better organization
from .views.api_views import HomeView, CountryView, CommentListAPIView

14
apps/api/views/__init__.py

@ -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',
]

61
apps/api/views/admin_dashboard.py

@ -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)

74
apps/api/views/api_views.py

@ -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

83
apps/api/views/swagger_views.py

@ -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')

29
centrifugo/config.json

@ -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
}
]
}

8
config/__init__.py

@ -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',)

16
config/asgi.py

@ -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()

22
config/celery.py

@ -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()

66
config/enhanced_auth_middleware.py

@ -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

14
config/language_code_middleware.py

@ -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

22
config/middleware/__init__.py

@ -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'

15
config/redis_config.py

@ -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
config/settings/__init__.py

379
config/settings/base.py

@ -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,
)

24
config/settings/develop.py

@ -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,
# },
# }

50
config/settings/production.py

@ -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,
},
},
}

12
config/settings/test.py

@ -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'

118
config/urls.py

@ -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}),
]

16
config/wsgi.py

@ -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()

91
docker-compose.prod.yml

@ -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

76
docker-compose.yml

@ -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

2
dynamic_preferences/__init__.py

@ -0,0 +1,2 @@
__version__ = "1.14.0"
default_app_config = "dynamic_preferences.apps.DynamicPreferencesConfig"

139
dynamic_preferences/admin.py

@ -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
dynamic_preferences/api/__init__.py

71
dynamic_preferences/api/serializers.py

@ -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

179
dynamic_preferences/api/viewsets.py

@ -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

25
dynamic_preferences/apps.py

@ -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)

108
dynamic_preferences/dynamic_preferences_registry.py

@ -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

32
dynamic_preferences/exceptions.py

@ -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'

152
dynamic_preferences/forms.py

@ -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

95
dynamic_preferences/locale/ar/LC_MESSAGES/django.po

@ -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 "التفضيلات الديناميكية"

82
dynamic_preferences/locale/az/LC_MESSAGES/django.po

@ -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 ""

82
dynamic_preferences/locale/bn/LC_MESSAGES/django.po

@ -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 ""

90
dynamic_preferences/locale/de/LC_MESSAGES/django.po

@ -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

Loading…
Cancel
Save