Browse Source
feat: initialize Django backend project structure with core apps, API views, and configurations
main
feat: initialize Django backend project structure with core apps, API views, and configurations
main
commit
3e9f6f994c
226 changed files with 103855 additions and 0 deletions
-
66.dockerignore
-
24.env.dev
-
35.env.example
-
23.env.prod
-
419.gitignore
-
32Dockerfile
-
46Dockerfile.prod
-
195README.md
-
100apps/account/API_ERRORS.md
-
0apps/account/__init__.py
-
32apps/account/admin/__init__.py
-
59apps/account/admin/location.py
-
14apps/account/admin/notification.py
-
158apps/account/admin/user.py
-
7apps/account/apps.py
-
28apps/account/custom_user_login.py
-
835apps/account/doc.py
-
0apps/account/management/__init__.py
-
0apps/account/management/commands/__init__,py
-
0apps/account/management/commands/__init__.py
-
28apps/account/management/commands/assign_professor_slugs.py
-
187apps/account/management/commands/audit_admin_panel_access.py
-
175apps/account/management/commands/create_geonames_table.py
-
52apps/account/management/commands/create_groups.py
-
208apps/account/management/commands/seed_notification_templates.py
-
115apps/account/management/commands/send_test_notifications.py
-
55apps/account/management/commands/set_default_passwords.py
-
37apps/account/management/commands/test_guest_token.py
-
41apps/account/manager.py
-
176apps/account/migrations/0001_initial.py
-
0apps/account/migrations/__init__.py
-
3apps/account/models/__init__.py
-
51apps/account/models/groups.py
-
53apps/account/models/notification.py
-
238apps/account/models/user.py
-
119apps/account/notification_service.py
-
56apps/account/permissions.py
-
4apps/account/serializers/__init__.py
-
11apps/account/serializers/auth.py
-
37apps/account/serializers/location_history.py
-
36apps/account/serializers/notification.py
-
377apps/account/serializers/user.py
-
29apps/account/serializers/user_web.py
-
39apps/account/tasks.py
-
40apps/account/templates/account/group_help_text.html
-
839apps/account/templates/account/json_editor_field.html
-
33apps/account/templates/account/user_list_section.html
-
47apps/account/tests/test_admin_panel_access.py
-
47apps/account/urls.py
-
4apps/account/views/__init__.py
-
163apps/account/views/auth.py
-
69apps/account/views/location_history.py
-
103apps/account/views/notification.py
-
942apps/account/views/user.py
-
0apps/api/__init__.py
-
28apps/api/admin.py
-
6apps/api/apps.py
-
42apps/api/decorators.py
-
52apps/api/migrations/0001_initial.py
-
0apps/api/migrations/__init__.py
-
125apps/api/models.py
-
71apps/api/permissions.py
-
36apps/api/serializers.py
-
3apps/api/tests.py
-
8apps/api/urls.py
-
2apps/api/views.py
-
14apps/api/views/__init__.py
-
61apps/api/views/admin_dashboard.py
-
74apps/api/views/api_views.py
-
83apps/api/views/swagger_views.py
-
29centrifugo/config.json
-
8config/__init__.py
-
16config/asgi.py
-
22config/celery.py
-
66config/enhanced_auth_middleware.py
-
14config/language_code_middleware.py
-
22config/middleware/__init__.py
-
15config/redis_config.py
-
0config/settings/__init__.py
-
379config/settings/base.py
-
24config/settings/develop.py
-
50config/settings/production.py
-
12config/settings/test.py
-
118config/urls.py
-
16config/wsgi.py
-
91docker-compose.prod.yml
-
76docker-compose.yml
-
2dynamic_preferences/__init__.py
-
139dynamic_preferences/admin.py
-
0dynamic_preferences/api/__init__.py
-
71dynamic_preferences/api/serializers.py
-
179dynamic_preferences/api/viewsets.py
-
25dynamic_preferences/apps.py
-
108dynamic_preferences/dynamic_preferences_registry.py
-
32dynamic_preferences/exceptions.py
-
152dynamic_preferences/forms.py
-
95dynamic_preferences/locale/ar/LC_MESSAGES/django.po
-
82dynamic_preferences/locale/az/LC_MESSAGES/django.po
-
82dynamic_preferences/locale/bn/LC_MESSAGES/django.po
-
90dynamic_preferences/locale/de/LC_MESSAGES/django.po
@ -0,0 +1,66 @@ |
|||
.git |
|||
.gitignore |
|||
.gitattributes |
|||
__pycache__ |
|||
*.py[cod] |
|||
*$py.class |
|||
*.so |
|||
.Python |
|||
*.egg-info |
|||
dist |
|||
build |
|||
*.egg |
|||
venv |
|||
env |
|||
ENV |
|||
env.bak |
|||
venv.bak |
|||
.venv |
|||
node_modules |
|||
.vscode |
|||
.idea |
|||
*.swp |
|||
*.swo |
|||
*~ |
|||
.DS_Store |
|||
Thumbs.db |
|||
docker-compose*.yml |
|||
Dockerfile* |
|||
.dockerignore |
|||
.env |
|||
.env.dev |
|||
.env.development |
|||
.env.local |
|||
.env.test |
|||
*.log |
|||
logs |
|||
*.sqlite3 |
|||
*.sql |
|||
*.tar.gz |
|||
*.zip |
|||
*.bak |
|||
.pytest_cache |
|||
.coverage |
|||
htmlcov |
|||
.tox |
|||
.nox |
|||
.mypy_cache |
|||
.dmypy.json |
|||
dmypy.json |
|||
test_*.py |
|||
tests |
|||
*.md |
|||
docs |
|||
Jenkinsfile |
|||
.jenkins |
|||
.letta |
|||
.claude |
|||
.qodo |
|||
.zencoder |
|||
volumes |
|||
staticfiles |
|||
media |
|||
media_volume |
|||
recordings |
|||
data/*.xlsx |
|||
data/*.csv |
|||
@ -0,0 +1,24 @@ |
|||
# Local Development Environment |
|||
DJANGO_SECRET_KEY=dev-insecure-secret-key-for-local-development-only |
|||
DJANGO_DEBUG=True |
|||
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,* |
|||
DJANGO_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 |
|||
DJANGO_SETTINGS_MODULE=config.settings.base |
|||
|
|||
# Database (PostgreSQL) |
|||
POSTGRES_DB=app_db |
|||
POSTGRES_USER=postgres |
|||
POSTGRES_PASSWORD=postgres |
|||
POSTGRES_HOST=postgres |
|||
POSTGRES_PORT=5432 |
|||
|
|||
# Redis & Celery |
|||
REDIS_URL=redis://redis:6379/0 |
|||
|
|||
# Default Region for phone validation |
|||
PHONENUMBER_DEFAULT_REGION=US |
|||
|
|||
# Centrifugo Real-Time Messaging Engine |
|||
CENTRIFUGO_API_URL=http://centrifugo:8000/api |
|||
CENTRIFUGO_SECRET=super_secret_centrifugo_token_key_hmac_256 |
|||
CENTRIFUGO_API_KEY=centrifugo_internal_api_access_key |
|||
@ -0,0 +1,35 @@ |
|||
# ----------------------------------------------------------------------------- |
|||
# Django Environment Configuration Template |
|||
# Copy this file to .env or .env.dev and fill in your values. |
|||
# ----------------------------------------------------------------------------- |
|||
|
|||
# Django Core |
|||
DJANGO_SECRET_KEY=change-this-secret-key-in-production |
|||
DJANGO_DEBUG=True |
|||
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost |
|||
DJANGO_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 |
|||
DJANGO_SETTINGS_MODULE=config.settings.base |
|||
|
|||
# Database (PostgreSQL) |
|||
POSTGRES_DB=app_db |
|||
POSTGRES_USER=postgres |
|||
POSTGRES_PASSWORD=postgres |
|||
POSTGRES_HOST=postgres |
|||
POSTGRES_PORT=5432 |
|||
|
|||
# Redis & Cache & Celery Broker |
|||
REDIS_URL=redis://redis:6379/0 |
|||
|
|||
# Phone Numbers & Internationalization |
|||
PHONENUMBER_DEFAULT_REGION=US |
|||
|
|||
# Monitoring & Error Tracking (Optional) |
|||
SENTRY_DSN= |
|||
|
|||
# CORS Configuration (Production) |
|||
CORS_ALLOWED_ORIGINS= |
|||
|
|||
# Centrifugo Real-Time Messaging Engine |
|||
CENTRIFUGO_API_URL=http://centrifugo:8000/api |
|||
CENTRIFUGO_SECRET=super_secret_centrifugo_token_key_hmac_256 |
|||
CENTRIFUGO_API_KEY=centrifugo_internal_api_access_key |
|||
@ -0,0 +1,23 @@ |
|||
# Production Environment Template |
|||
DJANGO_SECRET_KEY=generate-a-strong-random-secret-key-here |
|||
DJANGO_DEBUG=False |
|||
DJANGO_ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com |
|||
DJANGO_TRUSTED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com |
|||
DJANGO_SETTINGS_MODULE=config.settings.production |
|||
|
|||
# Database (PostgreSQL) |
|||
POSTGRES_USER=app_user |
|||
POSTGRES_DB=app_db |
|||
POSTGRES_PASSWORD=your-secure-postgres-password |
|||
POSTGRES_PORT=5432 |
|||
POSTGRES_HOST=postgres |
|||
|
|||
# Redis & Celery |
|||
REDIS_URL=redis://redis:6379/0 |
|||
|
|||
# Timezone |
|||
TIMEZONE=UTC |
|||
CELERY_TIMEZONE=UTC |
|||
|
|||
# Monitoring (Optional) |
|||
SENTRY_DSN= |
|||
@ -0,0 +1,419 @@ |
|||
settings.json |
|||
# migrations/ |
|||
.DS_Store |
|||
local-cdn/ |
|||
# .env-dev |
|||
# .env-prod |
|||
# Byte-compiled / optimized / DLL files |
|||
__pycache__/ |
|||
*.py[cod] |
|||
*$py.class |
|||
|
|||
static/ |
|||
# C extensions |
|||
*.so |
|||
|
|||
# Distribution / packaging |
|||
.Python |
|||
build/ |
|||
develop-eggs/ |
|||
dist/ |
|||
downloads/ |
|||
eggs/ |
|||
.eggs/ |
|||
lib/ |
|||
lib64/ |
|||
parts/ |
|||
sdist/ |
|||
var/ |
|||
wheels/ |
|||
pip-wheel-metadata/ |
|||
share/python-wheels/ |
|||
*.egg-info/ |
|||
.installed.cfg |
|||
*.egg |
|||
MANIFEST |
|||
# In the name of Allah |
|||
# Byte-compiled / optimized / DLL files |
|||
__pycache__/ |
|||
*.py[cod] |
|||
*$py.class |
|||
|
|||
# C extensions |
|||
*.so |
|||
|
|||
# Distribution / packaging |
|||
.Python |
|||
env/ |
|||
build/ |
|||
develop-eggs/ |
|||
dist/ |
|||
downloads/ |
|||
eggs/ |
|||
.eggs/ |
|||
lib/ |
|||
lib64/ |
|||
sdist/ |
|||
var/ |
|||
wheels/ |
|||
*.egg-info/ |
|||
.installed.cfg |
|||
*.egg |
|||
|
|||
# PyInstaller |
|||
# Usually these files are written by a python script from a template |
|||
# before PyInstaller builds the exe, so as to inject date/other infos into it. |
|||
*.manifest |
|||
*.spec |
|||
|
|||
# Installer logs |
|||
pip-log.txt |
|||
pip-delete-this-directory.txt |
|||
|
|||
# Unit test / coverage reports |
|||
htmlcov/ |
|||
.tox/ |
|||
.coverage |
|||
.coverage.* |
|||
.cache |
|||
nosetests.xml |
|||
coverage.xml |
|||
*.cover |
|||
.hypothesis/ |
|||
|
|||
# Translations |
|||
*.mo |
|||
*.pot |
|||
|
|||
# Django stuff: |
|||
*.log |
|||
local_settings.py |
|||
|
|||
# Flask stuff: |
|||
instance/ |
|||
.webassets-cache |
|||
|
|||
# Scrapy stuff: |
|||
.scrapy |
|||
|
|||
# Sphinx documentation |
|||
docs/_build/ |
|||
|
|||
# PyBuilder |
|||
target/ |
|||
|
|||
# Jupyter Notebook |
|||
.ipynb_checkpoints |
|||
|
|||
# pyenv |
|||
.python-version |
|||
|
|||
# celery beat schedule file |
|||
celerybeat-schedule |
|||
|
|||
# SageMath parsed files |
|||
*.sage.py |
|||
|
|||
# dotenv |
|||
.env |
|||
|
|||
# virtualenv |
|||
.venv |
|||
venv/ |
|||
ENV/ |
|||
.vscode |
|||
.idea |
|||
|
|||
*.mp4 |
|||
# Spyder project settings |
|||
.spyderproject |
|||
.spyproject |
|||
|
|||
# Rope project settings |
|||
.ropeproject |
|||
|
|||
# mkdocs documentation |
|||
/site |
|||
|
|||
# mypy |
|||
.mypy_cache/ |
|||
|
|||
.DS_Store |
|||
*.sqlite3 |
|||
media/ |
|||
*.pyc |
|||
*.db |
|||
*.pid |
|||
|
|||
# Ignore Django Migrations in Development if you are working on team |
|||
|
|||
#Only for Development only |
|||
#**/migrations/** |
|||
#!**/migrations/__init__.py |
|||
|
|||
#comment migrations ignorance bcz we need it to be exist |
|||
|
|||
|
|||
#server gitignore |
|||
passenger_wsgi.py |
|||
.htaccess |
|||
static/uploads/ |
|||
static/quran_audios |
|||
tmp/ |
|||
Pipfile.lock |
|||
quran-pages-audios/*.zip |
|||
quran.sql |
|||
tafsir.sql |
|||
output_file.sql |
|||
|
|||
src |
|||
calendar.json |
|||
apps/mafatih/data/mafatih_indonesia/*.json |
|||
apps/mafatih/data/mafatih_indonesia/1 |
|||
apps/mafatih/data/Germany Duas/*.xlsx |
|||
!apps/mafatih/data/mafatih_indonesia/final_jun_11.json |
|||
volumes/ |
|||
|
|||
apps/mafatih/data/*.json |
|||
apps/ahkam/data/*.json |
|||
!apps/ahkam/data/makarem_fa_data.json |
|||
|
|||
mediafiles/* |
|||
wabot/ |
|||
Sabeel Media Content/ |
|||
|
|||
|
|||
*.lock |
|||
*.toml |
|||
# PyInstaller |
|||
# Usually these files are written by a python script from a template |
|||
# before PyInstaller builds the exe, so as to inject date/other infos into it. |
|||
*.manifest |
|||
*.spec |
|||
|
|||
# Installer logs |
|||
pip-log.txt |
|||
pip-delete-this-directory.txt |
|||
|
|||
# Unit test / coverage reports |
|||
htmlcov/ |
|||
.tox/ |
|||
.nox/ |
|||
.coverage |
|||
.coverage.* |
|||
.cache |
|||
nosetests.xml |
|||
coverage.xml |
|||
*.cover |
|||
*.py,cover |
|||
.hypothesis/ |
|||
.pytest_cache/ |
|||
|
|||
# Translations |
|||
*.mo |
|||
*.pot |
|||
|
|||
# Django stuff: |
|||
*.log |
|||
local_settings.py |
|||
db.sqlite3 |
|||
db.sqlite3-journal |
|||
|
|||
# Flask stuff: |
|||
instance/ |
|||
.webassets-cache |
|||
|
|||
# Scrapy stuff: |
|||
.scrapy |
|||
|
|||
# Sphinx documentation |
|||
docs/_build/ |
|||
|
|||
# PyBuilder |
|||
target/ |
|||
|
|||
# Jupyter Notebook |
|||
.ipynb_checkpoints |
|||
|
|||
# IPython |
|||
profile_default/ |
|||
ipython_config.py |
|||
|
|||
# pyenv |
|||
.python-version |
|||
|
|||
# pipenv |
|||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. |
|||
# However, in case of collaboration, if having platform-specific dependencies or dependencies |
|||
# having no cross-platform support, pipenv may install dependencies that don't work, or not |
|||
# install all needed dependencies. |
|||
#Pipfile.lock |
|||
|
|||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow |
|||
__pypackages__/ |
|||
|
|||
# Celery stuff |
|||
celerybeat-schedule |
|||
celerybeat.pid |
|||
|
|||
# SageMath parsed files |
|||
*.sage.py |
|||
|
|||
# Environments |
|||
# # .env |
|||
# .venv |
|||
# # env/ |
|||
# venv/ |
|||
# ENV/ |
|||
# env.bak/ |
|||
# venv.bak/ |
|||
|
|||
# Spyder project settings |
|||
.spyderproject |
|||
.spyproject |
|||
|
|||
# Rope project settings |
|||
.ropeproject |
|||
|
|||
# mkdocs documentation |
|||
/site |
|||
|
|||
# mypy |
|||
.mypy_cache/ |
|||
.dmypy.json |
|||
dmypy.json |
|||
|
|||
# Pyre type checker |
|||
.pyre/ |
|||
|
|||
|
|||
# Logs |
|||
logs |
|||
*.log |
|||
npm-debug.log* |
|||
yarn-debug.log* |
|||
yarn-error.log* |
|||
lerna-debug.log* |
|||
.pnpm-debug.log* |
|||
|
|||
# Diagnostic reports (https://nodejs.org/api/report.html) |
|||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json |
|||
|
|||
# Runtime data |
|||
pids |
|||
*.pid |
|||
*.seed |
|||
*.pid.lock |
|||
|
|||
# Directory for instrumented libs generated by jscoverage/JSCover |
|||
lib-cov |
|||
|
|||
# Coverage directory used by tools like istanbul |
|||
coverage |
|||
*.lcov |
|||
|
|||
# nyc test coverage |
|||
.nyc_output |
|||
|
|||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) |
|||
.grunt |
|||
|
|||
# Bower dependency directory (https://bower.io/) |
|||
bower_components |
|||
|
|||
# node-waf configuration |
|||
.lock-wscript |
|||
|
|||
# Compiled binary addons (https://nodejs.org/api/addons.html) |
|||
build/Release |
|||
|
|||
# Dependency directories |
|||
node_modules/ |
|||
jspm_packages/ |
|||
|
|||
# Snowpack dependency directory (https://snowpack.dev/) |
|||
web_modules/ |
|||
|
|||
# TypeScript cache |
|||
*.tsbuildinfo |
|||
|
|||
# Optional npm cache directory |
|||
.npm |
|||
|
|||
# Optional eslint cache |
|||
.eslintcache |
|||
|
|||
# Optional stylelint cache |
|||
.stylelintcache |
|||
|
|||
# Microbundle cache |
|||
.rpt2_cache/ |
|||
.rts2_cache_cjs/ |
|||
.rts2_cache_es/ |
|||
.rts2_cache_umd/ |
|||
|
|||
# Optional REPL history |
|||
.node_repl_history |
|||
|
|||
# Output of 'npm pack' |
|||
*.tgz |
|||
|
|||
# Yarn Integrity file |
|||
.yarn-integrity |
|||
|
|||
# dotenv environment variable files |
|||
# .env |
|||
# .env.development.local |
|||
# .env.test.local |
|||
# .env.production.local |
|||
# .env.local |
|||
|
|||
# parcel-bundler cache (https://parceljs.org/) |
|||
.cache |
|||
.parcel-cache |
|||
|
|||
# Next.js build output |
|||
.next |
|||
out |
|||
|
|||
# Nuxt.js build / generate output |
|||
.nuxt |
|||
dist |
|||
|
|||
# Gatsby files |
|||
.cache/ |
|||
# Comment in the public line in if your project uses Gatsby and not Next.js |
|||
# https://nextjs.org/blog/next-9-1#public-directory-support |
|||
# public |
|||
|
|||
# vuepress build output |
|||
.vuepress/dist |
|||
|
|||
# vuepress v2.x temp and cache directory |
|||
.temp |
|||
.cache |
|||
|
|||
# Docusaurus cache and generated files |
|||
.docusaurus |
|||
|
|||
# Serverless directories |
|||
.serverless/ |
|||
|
|||
# FuseBox cache |
|||
.fusebox/ |
|||
|
|||
# DynamoDB Local files |
|||
.dynamodb/ |
|||
|
|||
# TernJS port file |
|||
.tern-port |
|||
|
|||
# Stores VSCode versions used for testing VSCode extensions |
|||
.vscode-test |
|||
|
|||
# yarn v2 |
|||
.yarn/cache |
|||
.yarn/unplugged |
|||
.yarn/build-state.yml |
|||
.yarn/install-state.gz |
|||
.pnp.* |
|||
@ -0,0 +1,32 @@ |
|||
# pull official base image |
|||
FROM python:3.10 |
|||
|
|||
# set work directory |
|||
WORKDIR /usr/src/app |
|||
|
|||
# set environment variables |
|||
ENV PYTHONDONTWRITEBYTECODE 1 |
|||
ENV PYTHONUNBUFFERED 1 |
|||
|
|||
RUN apt-get update |
|||
# RUN apt-get install -y vim |
|||
# RUN apt-get install -y ffmpeg |
|||
# RUN apt-get install -y cron |
|||
# install dependencies |
|||
RUN pip install --upgrade pip |
|||
|
|||
COPY ./requirements.txt . |
|||
COPY .env.dev .env |
|||
|
|||
RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt |
|||
|
|||
# copy entrypoint.sh |
|||
COPY ./entrypoint.sh . |
|||
RUN sed -i 's/\r$//g' /usr/src/app/entrypoint.sh |
|||
RUN chmod +x /usr/src/app/entrypoint.sh |
|||
|
|||
# copy project |
|||
COPY . . |
|||
|
|||
# run entrypoint.sh |
|||
# ENTRYPOINT ["/usr/src/app/entrypoint.sh"] |
|||
@ -0,0 +1,46 @@ |
|||
# ======================================================= |
|||
# Optimized Production Dockerfile for Django Backend |
|||
# Using Debian-slim for instant pre-compiled binary wheels |
|||
# (scikit-image, numpy, scipy install in seconds vs 10m on Alpine) |
|||
# ======================================================= |
|||
FROM python:3.10-slim |
|||
|
|||
# Set work directory |
|||
WORKDIR /usr/src/app |
|||
|
|||
# Set environment variables |
|||
ENV PYTHONDONTWRITEBYTECODE=1 \ |
|||
PYTHONUNBUFFERED=1 \ |
|||
PIP_NO_CACHE_DIR=1 \ |
|||
PIP_DISABLE_PIP_VERSION_CHECK=1 |
|||
|
|||
# 1. System Dependencies (Cached permanently unless system packages change) |
|||
RUN apt-get update && apt-get install -y --no-install-recommends \ |
|||
gcc \ |
|||
g++ \ |
|||
curl \ |
|||
git \ |
|||
libpq-dev \ |
|||
libjpeg62-turbo-dev \ |
|||
zlib1g-dev \ |
|||
libfreetype6-dev \ |
|||
gettext \ |
|||
ffmpeg \ |
|||
&& rm -rf /var/lib/apt/lists/* |
|||
|
|||
# 2. Python Dependencies (Cached unless requirements.txt changes) |
|||
COPY ./requirements.txt . |
|||
RUN pip install --upgrade pip && \ |
|||
pip install -r requirements.txt |
|||
|
|||
# 3. Entrypoint script (Cached) |
|||
COPY ./entrypoint.sh . |
|||
RUN sed -i 's/\r$//g' /usr/src/app/entrypoint.sh && \ |
|||
chmod +x /usr/src/app/entrypoint.sh |
|||
|
|||
# 4. Application Source Code (Changes frequently on git push) |
|||
COPY . . |
|||
|
|||
# Run entrypoint |
|||
ENTRYPOINT ["/usr/src/app/entrypoint.sh"] |
|||
|
|||
@ -0,0 +1,195 @@ |
|||
# Django Backend Starter Template |
|||
|
|||
A modern, production-ready, batteries-included Django backend template built for rapid application development. |
|||
|
|||
--- |
|||
|
|||
## 🚀 Tech Stack |
|||
|
|||
- **Framework**: Django 5.0+ |
|||
- **API Engine**: Django REST Framework (DRF) |
|||
- **Admin Interface**: [Django Unfold](https://github.com/unfoldadmin/django-unfold) (Tailwind-based modern UI) |
|||
- **Database**: PostgreSQL |
|||
- **Caching & Broker**: Redis |
|||
- **Task Queue**: Celery & Celery Beat |
|||
- **API Documentation**: Swagger UI & ReDoc via `drf-yasg` |
|||
- **Static Assets**: WhiteNoise |
|||
- **Containerization**: Docker & Docker Compose |
|||
|
|||
--- |
|||
|
|||
## 🌟 Key Features |
|||
|
|||
1. **Custom User Authentication (`apps/account`)**: |
|||
- Email-based authentication (no cumbersome usernames). |
|||
- Profile management with avatar, phone number, and metadata. |
|||
- Built-in `LoginHistory` and `LocationHistory` tracking. |
|||
- Standard authentication endpoints (Register, Login, Token Exchange, Password Reset, Profile Update). |
|||
- Clean group and role-based permissions. |
|||
2. **Modern Admin Panel (`utils/admin.py` & Django Unfold)**: |
|||
- Modern Tailwind styling with dark/light mode. |
|||
- Dynamic Dashboard KPI statistics. |
|||
- Responsive sidebar with configurable navigation. |
|||
3. **Interactive API Documentation (`apps/api`)**: |
|||
- Live Swagger UI at `/swagger/` and ReDoc at `/redoc/`. |
|||
- Token authentication banner for test requests. |
|||
- Health check endpoint at `/api/v1/health/`. |
|||
- Mobile app release versioning (`AppVersion`). |
|||
4. **Runtime Dynamic Preferences (`dynamic_preferences/`)**: |
|||
- Editable site-wide settings directly from the admin panel (site title, contact email, maintenance mode). |
|||
5. **Production Ready**: |
|||
- Multi-stage Dockerfile and Docker Compose setup. |
|||
- Nginx reverse proxy configuration. |
|||
- Pre-configured Gzip compression, security headers, and media streaming. |
|||
|
|||
--- |
|||
|
|||
## 📁 Project Structure |
|||
|
|||
```text |
|||
├── apps/ |
|||
│ ├── account/ # Custom User, authentication, profile, notifications |
|||
│ │ ├── admin/ # Unfold user and group admin |
|||
│ │ ├── migrations/ # Initial schema migrations |
|||
│ │ ├── models/ # User, LoginHistory, Notification models |
|||
│ │ ├── serializers/ # DRF serializers for user & auth |
|||
│ │ ├── views/ # Register, login, profile, notification views |
|||
│ │ └── urls.py # Account API endpoints |
|||
│ └── api/ # Core API utilities, versions, health checks |
|||
│ ├── admin/ # Version & support admin |
|||
│ ├── migrations/ # Initial schema migrations |
|||
│ ├── models/ # AppVersion, SupportMessage |
|||
│ ├── serializers/ # API serializers |
|||
│ ├── views/ # HealthCheck, AppVersion, Swagger views |
|||
│ └── urls.py # Core API routes |
|||
├── config/ |
|||
│ ├── settings/ |
|||
│ │ ├── base.py # Base Django settings |
|||
│ │ ├── develop.py # Development settings |
|||
│ │ ├── production.py # Production settings |
|||
│ │ └── test.py # Test settings |
|||
│ ├── celery.py # Celery worker configuration |
|||
│ ├── urls.py # Root URL configuration |
|||
│ ├── wsgi.py # WSGI entry point |
|||
│ └── asgi.py # ASGI entry point |
|||
├── dynamic_preferences/ # In-tree runtime preferences registry |
|||
├── nginx/ |
|||
│ └── app.conf # Nginx reverse proxy config |
|||
├── static/ # Static CSS and JS assets |
|||
├── templates/ |
|||
│ ├── admin/ # Unfold admin dashboard & overrides |
|||
│ └── swagger/ # Custom Swagger UI templates |
|||
├── utils/ # Reusable helper modules |
|||
│ ├── admin.py # Unfold AdminSite and dashboard callbacks |
|||
│ ├── exceptions.py # DRF custom exception handler |
|||
│ ├── image_compression.py # Image optimization |
|||
│ ├── pagination.py # Standard REST pagination |
|||
│ └── redis.py # Redis token and cache helpers |
|||
├── .env.example # Environment variables template |
|||
├── .env.dev # Local development env defaults |
|||
├── docker-compose.yml # Docker compose configuration |
|||
├── Dockerfile # Docker build file |
|||
├── manage.py # Django CLI |
|||
└── requirements.txt # Python dependencies |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 🛠️ Quick Start |
|||
|
|||
### 1. Using Docker (Recommended) |
|||
|
|||
1. **Clone or copy the template**: |
|||
```bash |
|||
git init my-project |
|||
cd my-project |
|||
``` |
|||
|
|||
2. **Setup environment variables**: |
|||
```bash |
|||
cp .env.example .env.dev |
|||
``` |
|||
|
|||
3. **Build and start services**: |
|||
```bash |
|||
docker compose up -d --build |
|||
``` |
|||
|
|||
4. **Run migrations and create superuser**: |
|||
```bash |
|||
docker compose exec web python manage.py migrate |
|||
docker compose exec web python manage.py createsuperuser |
|||
``` |
|||
|
|||
5. Access the app: |
|||
- **Admin Panel**: [http://localhost:8000/admin/](http://localhost:8000/admin/) |
|||
- **Swagger Docs**: [http://localhost:8000/swagger/](http://localhost:8000/swagger/) |
|||
- **Health Check**: [http://localhost:8000/api/v1/health/](http://localhost:8000/api/v1/health/) |
|||
|
|||
--- |
|||
|
|||
### 2. Local Python Environment |
|||
|
|||
1. **Create and activate a virtual environment**: |
|||
```bash |
|||
python -m venv venv |
|||
source venv/bin/activate # On Windows: venv\Scripts\activate |
|||
``` |
|||
|
|||
2. **Install dependencies**: |
|||
```bash |
|||
pip install -r requirements.txt |
|||
``` |
|||
|
|||
3. **Configure environment**: |
|||
```bash |
|||
cp .env.example .env |
|||
# Edit .env with your local PostgreSQL and Redis credentials |
|||
``` |
|||
|
|||
4. **Apply migrations**: |
|||
```bash |
|||
python manage.py migrate |
|||
python manage.py createsuperuser |
|||
``` |
|||
|
|||
5. **Run the development server**: |
|||
```bash |
|||
python manage.py runserver |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 🔑 Environment Variables Reference |
|||
|
|||
| Variable | Description | Default | |
|||
|----------|-------------|---------| |
|||
| `DJANGO_SECRET_KEY` | Unique Django secret key | (Required in production) | |
|||
| `DJANGO_DEBUG` | Enable debug mode | `True` | |
|||
| `DJANGO_ALLOWED_HOSTS` | Comma-separated allowed hostnames | `127.0.0.1,localhost` | |
|||
| `POSTGRES_DB` | PostgreSQL database name | `app_db` | |
|||
| `POSTGRES_USER` | PostgreSQL user | `postgres` | |
|||
| `POSTGRES_PASSWORD` | PostgreSQL password | `postgres` | |
|||
| `POSTGRES_HOST` | PostgreSQL host | `postgres` / `localhost` | |
|||
| `POSTGRES_PORT` | PostgreSQL port | `5432` | |
|||
| `REDIS_URL` | Redis connection URL | `redis://redis:6379/0` | |
|||
| `SENTRY_DSN` | Sentry error tracking DSN | (Optional) | |
|||
|
|||
--- |
|||
|
|||
## 📡 API Endpoints Overview |
|||
|
|||
- **Auth & Account**: |
|||
- `POST /api/v1/account/register/` - User registration |
|||
- `POST /api/v1/account/login/` - User login & token generation |
|||
- `GET /api/v1/account/profile/` - Authenticated user profile |
|||
- `PUT /api/v1/account/profile/update/` - Update profile |
|||
- `POST /api/v1/account/recover/` - Request password recovery |
|||
- `POST /api/v1/account/reset/` - Reset password |
|||
- **System & Utilities**: |
|||
- `GET /api/v1/health/` - Server health status |
|||
- `GET /api/v1/version/` - Latest mobile application version |
|||
- `POST /api/v1/contact-us/` - Submit support/contact message |
|||
- **Documentation**: |
|||
- `/swagger/` - Interactive Swagger UI |
|||
- `/redoc/` - ReDoc API documentation |
|||
@ -0,0 +1,100 @@ |
|||
# Account API Error Documentation |
|||
|
|||
This document lists the potential errors returned by the registration and account endpoints in the `account` app, including the error messages and the reasons they occur. |
|||
|
|||
## Common Error Format |
|||
All errors follow a standardized JSON structure defined in the project's custom exception handler: |
|||
|
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 400, |
|||
"message": "There were validation errors.", |
|||
"errors": [ |
|||
{ |
|||
"field": "email", |
|||
"message": "This email is already registered." |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 1. Registration Endpoints |
|||
**Endpoints:** `POST /register/`, `POST /web/register/` |
|||
|
|||
| Error Message | Field | Reason | |
|||
| :--- | :--- | :--- | |
|||
| `This email is already registered.` | `email` | The email address is already associated with an existing account. | |
|||
| `Enter a valid email address.` | `email` | The provided email format is incorrect (e.g., missing `@` or domain). | |
|||
| `This field is required.` | Multiple | A mandatory field (like `email`, `fullname`, or `password` for web) was missing from the request. | |
|||
| `This password is too short...` | `password` | (Web only) The password does not meet Django's security requirements (length, complexity). | |
|||
|
|||
--- |
|||
|
|||
## 2. Verification Endpoint |
|||
**Endpoint:** `POST /verify/` |
|||
|
|||
| Error Message | Field | Reason | |
|||
| :--- | :--- | :--- | |
|||
| `Verification data not found or expired.` | `code` | There is no active registration session in Redis for this email. Usually occurs if the user waits too long or tries to verify an email they didn't just register. | |
|||
| `The verification code has expired.` | `code` | The OTP code's Time-To-Live (TTL) has passed (usually 5-10 minutes). | |
|||
| `code notfound` | `code` | The provided OTP code is incorrect. | |
|||
| `enter code numeric` | `code` | The provided code contains non-numeric characters. | |
|||
|
|||
--- |
|||
|
|||
## 3. Authentication & Login |
|||
**Endpoint:** `POST /login/` |
|||
|
|||
| Error Message | Field | Reason | |
|||
| :--- | :--- | :--- | |
|||
| `user not exists with this email` | `email` | No user account was found with the provided email address. | |
|||
| `password is incorrect` | `password` | The email is correct, but the password does not match the record in the database. | |
|||
| `Unable to log in with provided credentials.` | `non_field_errors` | Catch-all for failed authentication attempts. | |
|||
|
|||
--- |
|||
|
|||
## 4. Guest Account Endpoints |
|||
**Endpoints:** `POST /guest/`, `POST /web/guest/` |
|||
|
|||
| Error Message | Field | Reason | |
|||
| :--- | :--- | :--- | |
|||
| `Device ID is required for guest users.` | `device_id` | (Mobile) The unique device identifier was not sent in the request. | |
|||
| `Device ID is required for web guest users.` | `device_id` | (Web) Internal error where the identifier generation failed. | |
|||
|
|||
--- |
|||
|
|||
## 5. Token Exchange (Mobile Auth) |
|||
**Endpoint:** `POST /exchange-token/` |
|||
|
|||
| Error Message | Status Code | Reason | |
|||
| :--- | :--- | :--- | |
|||
| `توکن ارسال نشده است` | 400 | The `temp_token` was missing from the request body. | |
|||
| `توکن نامعتبر یا منقضی شده است` | 404 | The temporary token from the login redirect has expired or is invalid. | |
|||
| `توکن نامعتبر است` | 400 | The token exists but is missing required session data (`user_id`). | |
|||
| `کاربر یافت نشد` | 404 | The user account associated with the token has been deleted. | |
|||
|
|||
--- |
|||
|
|||
## 6. Profile & Password Management |
|||
**Endpoints:** `GET/PUT /profile/update/`, `POST /reset/` |
|||
|
|||
| Error Message | Status Code | Reason | |
|||
| :--- | :--- | :--- | |
|||
| `Authentication credentials were not provided.` | 401 | Missing or incorrect `Authorization: Token <key>` header. | |
|||
| `Invalid token.` | 401 | The provided token has expired or belongs to a deleted user. | |
|||
| `This password is too common.` | 400 | Password reset failed because the new password is too simple. | |
|||
| `You do not have permission...` | 403 | The user's account has been deactivated (inactive). | |
|||
|
|||
--- |
|||
|
|||
## 7. Account Deletion |
|||
**Endpoint:** `DELETE /profile/delete/` |
|||
|
|||
| Error Message | Status Code | Reason | |
|||
| :--- | :--- | :--- | |
|||
| `Unable to log in with provided credentials.` | 204 | Attempted to delete the protected primary administrator account (`[email protected]`). | |
|||
| `User does not exist.` | 404 | The system could not find the user object to perform the soft-delete. | |
|||
@ -0,0 +1,32 @@ |
|||
from unfold.components import BaseComponent, register_component |
|||
from django.template.loader import render_to_string |
|||
|
|||
from .user import * |
|||
from .location import * |
|||
from .notification import * |
|||
|
|||
|
|||
@register_component |
|||
class AllUserComponent(BaseComponent): |
|||
def get_context_data(self, **kwargs): |
|||
context = super().get_context_data(**kwargs) |
|||
context["children"] = render_to_string( |
|||
"admin/helpers/kpi_progress.html", |
|||
{ |
|||
"total": User.objects.filter(is_active=True).count(), |
|||
}, |
|||
) |
|||
return context |
|||
|
|||
|
|||
@register_component |
|||
class GuestUserComponent(BaseComponent): |
|||
def get_context_data(self, **kwargs): |
|||
context = super().get_context_data(**kwargs) |
|||
context["children"] = render_to_string( |
|||
"admin/helpers/kpi_progress.html", |
|||
{ |
|||
"total": User.objects.filter(email__isnull=True).count(), |
|||
}, |
|||
) |
|||
return context |
|||
@ -0,0 +1,59 @@ |
|||
from django.contrib import admin |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from unfold.admin import ModelAdmin, TabularInline |
|||
from unfold.decorators import display |
|||
from unfold.contrib.filters.admin import ( |
|||
RangeDateTimeFilter, |
|||
TextFilter, |
|||
AutocompleteSelectFilter, |
|||
) |
|||
|
|||
from apps.account.models import LocationHistory, User |
|||
from utils.admin import project_admin_site, dovoodi_admin_site |
|||
|
|||
|
|||
class LocationHistoryInline(TabularInline): |
|||
model = LocationHistory |
|||
extra = 0 |
|||
tab = True |
|||
fields = ('lat', 'lon', 'country', 'city', 'selected_manually', 'ip', 'timezone', 'at_time') |
|||
readonly_fields = ('lat', 'lon', 'country', 'city', 'selected_manually', 'ip', 'timezone', 'at_time',) |
|||
verbose_name = _("Location History") |
|||
verbose_name_plural = _("Location History") |
|||
can_delete = False |
|||
show_change_link = True |
|||
|
|||
|
|||
class LocationHistoryAdmin(ModelAdmin): |
|||
list_display = ('user', 'display_location', 'country', 'city', 'selected_manually', 'ip', 'display_at_time') |
|||
list_filter = [ |
|||
('user', AutocompleteSelectFilter), |
|||
'country', |
|||
'city', |
|||
'selected_manually', |
|||
('at_time', RangeDateTimeFilter), |
|||
] |
|||
search_fields = ('user__email', 'user__fullname', 'country', 'city', 'ip') |
|||
readonly_fields = ('at_time',) |
|||
fieldsets = ( |
|||
(None, { |
|||
'fields': ('user', ('lat', 'lon'), ('country', 'city')) |
|||
}), |
|||
(_('Additional Information'), { |
|||
'fields': ('selected_manually', 'ip', 'timezone', 'at_time'), |
|||
'classes': ('tab',), |
|||
}), |
|||
) |
|||
|
|||
@display(description=_("Location")) |
|||
def display_location(self, instance: LocationHistory): |
|||
return f"{instance.lat}, {instance.lon}" |
|||
|
|||
@display(description=_("Date & Time")) |
|||
def display_at_time(self, instance: LocationHistory): |
|||
return instance.at_time.strftime("%Y-%m-%d %H:%M") if instance.at_time else "-" |
|||
|
|||
|
|||
|
|||
# Register with project admin site |
|||
project_admin_site.register(LocationHistory, LocationHistoryAdmin) |
|||
@ -0,0 +1,14 @@ |
|||
from django.contrib import admin |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from ajaxdatatable.admin import AjaxDatatable |
|||
|
|||
from apps.account.models import User, Notification |
|||
|
|||
@admin.register(Notification) |
|||
class NotificationAdmin(AjaxDatatable): |
|||
list_display = ('title', 'user', 'is_read', 'created_at') |
|||
list_filter = ('is_read', 'created_at') |
|||
search_fields = ('title', 'message', 'user__fullname') |
|||
list_editable = ('is_read',) |
|||
ordering = ('-created_at',) |
|||
autocomplete_fields = ['user',] |
|||
@ -0,0 +1,158 @@ |
|||
from django import forms |
|||
from django.contrib import admin |
|||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin |
|||
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin |
|||
from django.contrib.auth.models import Group |
|||
from django.db import models |
|||
from django.utils.html import format_html |
|||
from django.utils.translation import gettext_lazy as _, ngettext |
|||
|
|||
from rest_framework.authtoken.models import TokenProxy |
|||
from unfold.admin import ModelAdmin, StackedInline |
|||
from unfold.decorators import display |
|||
from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm |
|||
from unfold.contrib.filters.admin import RangeDateTimeFilter |
|||
|
|||
from apps.account.models import User, ClientUser, LocationHistory |
|||
from utils.admin import project_admin_site |
|||
from apps.account.admin.location import LocationHistoryInline |
|||
|
|||
|
|||
# ========================================================= |
|||
# 1. Base User Admin Form & Admin |
|||
# ========================================================= |
|||
|
|||
class UserAdminCreationForm(UserCreationForm): |
|||
class Meta(UserCreationForm.Meta): |
|||
model = User |
|||
fields = ("fullname", "email") |
|||
|
|||
def __init__(self, *args, **kwargs): |
|||
super().__init__(*args, **kwargs) |
|||
if 'fullname' in self.fields: |
|||
self.fields['fullname'].required = True |
|||
if 'email' in self.fields: |
|||
self.fields['email'].required = True |
|||
|
|||
def clean_email(self): |
|||
email = self.cleaned_data.get('email') |
|||
if User.objects.filter(email=email).exists(): |
|||
raise forms.ValidationError(_("A user with this email already exists.")) |
|||
return email |
|||
|
|||
|
|||
class UserAdminChangeForm(UserChangeForm): |
|||
class Meta(UserChangeForm.Meta): |
|||
model = User |
|||
|
|||
def __init__(self, *args, **kwargs): |
|||
super().__init__(*args, **kwargs) |
|||
if 'fullname' in self.fields: |
|||
self.fields['fullname'].required = True |
|||
if 'email' in self.fields: |
|||
self.fields['email'].required = True |
|||
|
|||
|
|||
class UserAdmin(ModelAdmin, BaseUserAdmin): |
|||
form = UserAdminChangeForm |
|||
add_form = UserAdminCreationForm |
|||
change_password_form = AdminPasswordChangeForm |
|||
compressed_fields = False |
|||
list_display = ('fullname', 'email', 'user_type', 'is_staff', 'is_active', 'display_date_joined',) |
|||
ordering = ("-id",) |
|||
search_fields = ('email', 'fullname', 'username',) |
|||
list_filter = [ |
|||
"user_type", |
|||
"is_active", |
|||
"is_staff", |
|||
("last_login", RangeDateTimeFilter), |
|||
("date_joined", RangeDateTimeFilter), |
|||
] |
|||
inlines = [LocationHistoryInline] |
|||
|
|||
add_fieldsets = ( |
|||
(None, { |
|||
'classes': ('wide',), |
|||
'fields': (('fullname', 'email'), 'phone_number', 'birthdate', 'gender', 'avatar', 'skill', 'info'), |
|||
}), |
|||
) |
|||
|
|||
fieldsets = ( |
|||
(None, { |
|||
"fields": ( |
|||
("fullname", "email"), |
|||
("password",), |
|||
), |
|||
}), |
|||
(_("Profile Details"), { |
|||
"classes": ["tab"], |
|||
"fields": ( |
|||
("avatar",), |
|||
("phone_number", "gender"), |
|||
("birthdate", "language"), |
|||
("skill",), |
|||
("info",), |
|||
), |
|||
}), |
|||
(_("Access & Status"), { |
|||
"classes": ["tab"], |
|||
"fields": ( |
|||
("user_type",), |
|||
("is_active", "is_staff", "is_superuser"), |
|||
("groups", "user_permissions"), |
|||
), |
|||
}), |
|||
(_("Timestamps & Metadata"), { |
|||
"classes": ["tab"], |
|||
"fields": ( |
|||
("date_joined", "last_login"), |
|||
("deleted_at",), |
|||
), |
|||
}), |
|||
) |
|||
|
|||
readonly_fields = ('date_joined', 'last_login') |
|||
|
|||
@display(description=_("Date Joined")) |
|||
def display_date_joined(self, instance: User): |
|||
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-" |
|||
|
|||
|
|||
class GroupAdmin(BaseGroupAdmin, ModelAdmin): |
|||
list_display = ('name', 'permissions_count') |
|||
search_fields = ('name',) |
|||
ordering = ('name',) |
|||
filter_horizontal = ('permissions',) |
|||
|
|||
fieldsets = ( |
|||
(None, {'fields': ('name',)}), |
|||
(_('Permissions'), {'fields': ('permissions',), 'classes': ['tab']}), |
|||
) |
|||
|
|||
@display(description=_("Permissions")) |
|||
def permissions_count(self, obj): |
|||
count = obj.permissions.count() |
|||
return ngettext("{count} permission", "{count} permissions", count).format(count=count) if count > 0 else "-" |
|||
|
|||
|
|||
# ========================================================= |
|||
# 2. Registrations |
|||
# ========================================================= |
|||
|
|||
try: |
|||
admin.site.unregister(User) |
|||
except admin.sites.NotRegistered: |
|||
pass |
|||
|
|||
try: |
|||
admin.site.register(User, UserAdmin) |
|||
except admin.sites.AlreadyRegistered: |
|||
pass |
|||
|
|||
project_admin_site.register(User, UserAdmin) |
|||
project_admin_site.register(Group, GroupAdmin) |
|||
|
|||
try: |
|||
admin.site.unregister(TokenProxy) |
|||
except admin.sites.NotRegistered: |
|||
pass |
|||
@ -0,0 +1,7 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class AccountConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.account' |
|||
icon = 'mi-person' |
|||
@ -0,0 +1,28 @@ |
|||
from django.contrib.auth.backends import BaseBackend |
|||
from django.db.models import Q |
|||
|
|||
from apps.account.models import User |
|||
from utils.exceptions import UserNotFoundException |
|||
from rest_framework.exceptions import AuthenticationFailed |
|||
|
|||
|
|||
class CustomLoginBackend(BaseBackend): |
|||
""" |
|||
Authenticate with username email and phone_number. |
|||
""" |
|||
|
|||
def authenticate(self, request, username=None, password=None): |
|||
if user := self.get_user(username): |
|||
if user.check_password(password): |
|||
return user |
|||
|
|||
return None |
|||
|
|||
def get_user(self, username): |
|||
try: |
|||
if isinstance(username, int): |
|||
return User.objects.filter(id=int(username)).first() |
|||
return User.objects.filter(Q(email=username) | Q(phone_number=str(username))).first() |
|||
|
|||
except User.DoesNotExist: |
|||
return None |
|||
@ -0,0 +1,835 @@ |
|||
def doc_reset(): |
|||
return """ |
|||
# 🐈 Scenario |
|||
🛠️ تنظیم مجدد رمز عبور |
|||
|
|||
کاربر پس از تأیید کد بازیابی رمز عبور، میتواند رمز عبور جدید خود را تنظیم کند. برای این کار، کاربر باید رمز عبور جدید و تأیید آن را وارد کند. |
|||
|
|||
بعد از ریکاور و وریفای |
|||
به این صفحه برای ریست میآید |
|||
که باید با همان توکنی که در وریفای دریافت کرده است را درخواست کند |
|||
|
|||
(نکته بعد از ریست پسورد توکن ذخیره شده حذف شود و کاربر باید با رمز عبور جدیدی که ست کرده است مجددا لاگین را انجام دهد) |
|||
--- |
|||
|
|||
## 🚀 درخواست API |
|||
|
|||
### URL: |
|||
``` |
|||
POST /api/reset-password/ |
|||
``` |
|||
|
|||
### Header: |
|||
| کلید | مقدار | |
|||
|---------------|---------------------------------| |
|||
| Content-Type | application/json | |
|||
| Authorization | Bearer <توکن احراز هویت> | |
|||
|
|||
### Body: |
|||
```json |
|||
{ |
|||
"password": "newstrongpassword", |
|||
"password_confirmation": "newstrongpassword" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📊 پاسخها |
|||
|
|||
| کد وضعیت | توضیحات | |
|||
|---------------|-----------------------------------------------------------| |
|||
| `200` | موفقیتآمیز - رمز عبور با موفقیت تغییر یافت. | |
|||
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
|||
| `401` | عدم احراز هویت - کاربر وارد نشده است یا توکن نامعتبر است. | |
|||
| `500` | مشکل موقتی در سرور. | |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ موفقیتآمیز |
|||
|
|||
```json |
|||
{ |
|||
"message": "Your password has been changed successfully." |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ خطا |
|||
|
|||
### رمز عبور و تأیید رمز عبور برابر نیستند: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 400, |
|||
"message": "Passwords do not match." |
|||
} |
|||
``` |
|||
|
|||
### رمز عبور کوتاهتر از 8 کاراکتر است: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 400, |
|||
"message": "Password must be at least 8 characters long." |
|||
} |
|||
``` |
|||
|
|||
### عدم احراز هویت: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "unauthorized", |
|||
"status_code": 401, |
|||
"message": "Authentication credentials were not provided or are invalid." |
|||
} |
|||
``` |
|||
|
|||
### مشکل موقتی در سرور: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "service_unavailable", |
|||
"status_code": 500, |
|||
"message": "Service temporarily unavailable." |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 💡 نکات مهم: |
|||
1. **رمز عبور جدید:** |
|||
- باید حداقل 8 کاراکتر باشد و تأیید رمز عبور (`password_confirmation`) باید با رمز عبور اصلی یکسان باشد. |
|||
2. **امنیت:** |
|||
- کاربر باید توکن احراز هویت معتبر برای تنظیم مجدد رمز عبور ارائه دهد. |
|||
3. **توکن احراز هویت:** |
|||
- فقط کاربران احراز هویت شده میتوانند رمز عبور خود را تغییر دهند. |
|||
|
|||
--- |
|||
|
|||
## 🔧 توضیحات فنی: |
|||
|
|||
### فرآیند تنظیم مجدد رمز عبور: |
|||
1. کاربر باید ابتدا کد بازیابی رمز عبور را تأیید کند. |
|||
2. پس از تأیید موفقیتآمیز، کاربر با استفاده از توکن احراز هویت، رمز عبور جدید و تأیید آن را وارد میکند. |
|||
3. اگر دادهها معتبر باشند، رمز عبور جدید برای کاربر تنظیم میشود. |
|||
4. اگر دادهها نادرست باشند، پیام خطای مناسب به کاربر بازگردانده میشود. |
|||
|
|||
### ولیدیشنها: |
|||
- **رمز عبور:** |
|||
- بررسی میشود که رمز عبور حداقل 8 کاراکتر باشد. |
|||
- بررسی میشود که رمز عبور و تأیید آن یکسان باشند. |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه درخواست: |
|||
|
|||
### درخواست کامل: |
|||
```json |
|||
{ |
|||
"password": "mynewpassword", |
|||
"password_confirmation": "mynewpassword" |
|||
} |
|||
``` |
|||
|
|||
### پاسخ موفق: |
|||
```json |
|||
{ |
|||
"message": "Your password has been changed successfully." |
|||
} |
|||
``` |
|||
""" |
|||
|
|||
|
|||
|
|||
def doc_recover(): |
|||
return """ |
|||
# 🐈 Scenario |
|||
🛠️ بازیابی رمز عبور |
|||
|
|||
کاربر با وارد کردن ایمیل خود، درخواست بازیابی رمز عبور میدهد. |
|||
یک کد تأیید به ایمیل کاربر ارسال میشود تا کاربر بتواند رمز عبور خود را بازیابی کند. |
|||
سپس کاربر باید به صفحه وریفای ریدایرکت شود |
|||
و بعد از تایید وریفای با توکن داده شده |
|||
به صفحه ریست پسورد ریدایرکت میشود تا پسور جدیدی را ست کند |
|||
|
|||
--- |
|||
|
|||
## 🚀 درخواست API |
|||
|
|||
### URL: |
|||
``` |
|||
POST /api/recover-password/ |
|||
``` |
|||
|
|||
### Header: |
|||
| کلید | مقدار | |
|||
|---------------|---------------------------------| |
|||
| Content-Type | application/json | |
|||
| Authorization | Optional (برای این endpoint نیاز نیست) | |
|||
|
|||
### Body: |
|||
```json |
|||
{ |
|||
"email": "[email protected]" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📊 پاسخها |
|||
|
|||
| کد وضعیت | توضیحات | |
|||
|---------------|-----------------------------------------------------------| |
|||
| `202` | موفقیتآمیز - کد بازیابی رمز عبور به ایمیل کاربر ارسال شد. | |
|||
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
|||
| `404` | کاربر یافت نشد. | |
|||
| `500` | مشکل موقتی در سرور. | |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ موفقیتآمیز |
|||
|
|||
```json |
|||
{ |
|||
"id": 1, |
|||
"fullname": "John Doe", |
|||
"phone_number": "1234567890", |
|||
"email": "[email protected]", |
|||
"avatar": null, |
|||
"message": "Forgot password code sent" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ خطا |
|||
|
|||
### کاربر یافت نشد: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "not_found", |
|||
"status_code": 404, |
|||
"message": "User not found." |
|||
} |
|||
``` |
|||
|
|||
### مشکل موقتی در سرور: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "service_unavailable", |
|||
"status_code": 500, |
|||
"message": "Service temporarily unavailable." |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 💡 نکات مهم: |
|||
1. **کد بازیابی رمز عبور:** |
|||
- کد تأیید به ایمیل کاربر ارسال میشود و باید در مرحله بعدی برای بازیابی رمز عبور استفاده شود. |
|||
2. **امنیت:** |
|||
- کد بازیابی رمز عبور فقط برای مدت محدود اعتبار دارد و بعد از آن منقضی میشود. |
|||
|
|||
--- |
|||
|
|||
## 🔧 توضیحات فنی: |
|||
|
|||
### فرآیند بازیابی رمز عبور: |
|||
1. کاربر ایمیل خود را وارد میکند. |
|||
2. سیستم بررسی میکند که آیا کاربری با این ایمیل وجود دارد یا خیر. |
|||
3. اگر کاربر یافت شود، یک کد تأیید بازیابی رمز عبور به ایمیل کاربر ارسال میشود. |
|||
4. کاربر باید این کد را در مرحله بعدی برای تنظیم رمز عبور جدید وارد کند. |
|||
|
|||
### ولیدیشنها: |
|||
- **ایمیل:** |
|||
- بررسی میشود که ایمیل وارد شده معتبر باشد. |
|||
- اگر کاربری با این ایمیل یافت نشود، پیام خطای مناسب برگردانده میشود. |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه درخواست: |
|||
|
|||
### درخواست کامل: |
|||
```json |
|||
{ |
|||
"email": "[email protected]" |
|||
} |
|||
``` |
|||
|
|||
### پاسخ موفق: |
|||
```json |
|||
{ |
|||
"id": 2, |
|||
"fullname": "Jane Doe", |
|||
"phone_number": "0987654321", |
|||
"email": "[email protected]", |
|||
"avatar": null, |
|||
"message": "Forgot password code sent" |
|||
} |
|||
``` |
|||
""" |
|||
|
|||
|
|||
|
|||
def doc_login(): |
|||
return """ |
|||
# 🐈 Scenario |
|||
🛠️ ورود به حساب کاربری |
|||
|
|||
کاربر با وارد کردن ایمیل و رمز عبور خود به سیستم وارد میشود. اگر اعتبارنامهها معتبر باشند، توکن احراز هویت برای دسترسی به دیگر بخشهای سیستم بازگردانده میشود. |
|||
|
|||
--- |
|||
|
|||
## 🚀 درخواست API |
|||
|
|||
### URL: |
|||
``` |
|||
POST /api/login/ |
|||
``` |
|||
|
|||
### Header: |
|||
| کلید | مقدار | |
|||
|---------------|---------------------------------| |
|||
| Content-Type | application/json | |
|||
| Authorization | Optional (برای این endpoint نیاز نیست) | |
|||
|
|||
### Body: |
|||
```json |
|||
{ |
|||
"email": "[email protected]", |
|||
"password": "strongpassword", |
|||
"fcm": "fcm_token_optional", |
|||
"device_id": "device_id_optional" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📊 پاسخها |
|||
|
|||
| کد وضعیت | توضیحات | |
|||
|---------------|-----------------------------------------------------------| |
|||
| `201` | موفقیتآمیز - کاربر با موفقیت وارد شد و توکن احراز هویت بازگردانده شد. | |
|||
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
|||
| `404` | کاربر یافت نشد. | |
|||
| `500` | مشکل موقتی در سرور. | |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ موفقیتآمیز |
|||
|
|||
```json |
|||
{ |
|||
"id": 1, |
|||
"fullname": "John Doe", |
|||
"email": "[email protected]", |
|||
"token": "abc123def456", |
|||
"avatar": "https://example.com/avatar.jpg" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ خطا |
|||
|
|||
### ورود ناموفق (اطلاعات اشتباه): |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "invalid_credentials", |
|||
"status_code": 400, |
|||
"message": "Unable to log in with provided credentials." |
|||
} |
|||
``` |
|||
|
|||
### کاربر یافت نشد: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "not_found", |
|||
"status_code": 404, |
|||
"message": "User not found." |
|||
} |
|||
``` |
|||
|
|||
### مشکل موقتی در سرور: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "service_unavailable", |
|||
"status_code": 500, |
|||
"message": "Service temporarily unavailable." |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 💡 نکات مهم: |
|||
1. **رمز عبور:** |
|||
- رمز عبور باید صحیح و مطابق با آنچه کاربر هنگام ثبتنام ارائه کرده است، باشد. |
|||
2. **توکن احراز هویت:** |
|||
- پس از ورود موفقیتآمیز، توکن احراز هویت به کاربر بازگردانده میشود که برای دسترسی به دیگر بخشهای سیستم نیاز است. |
|||
3. **اطلاعات دستگاه:** |
|||
- `fcm` و `device_id` به عنوان اطلاعات اختیاری برای شناسایی دستگاه ارسال میشوند. |
|||
|
|||
--- |
|||
|
|||
## 🔧 توضیحات فنی: |
|||
|
|||
### فرآیند ورود به حساب کاربری: |
|||
1. کاربر ایمیل و رمز عبور خود را وارد میکند. |
|||
2. سیستم سعی میکند کاربر را با استفاده از اعتبارنامههای ارائه شده احراز هویت کند. |
|||
3. اگر کاربر یافت شود و اعتبارنامهها صحیح باشند، یک توکن احراز هویت ایجاد شده و به کاربر بازگردانده میشود. |
|||
4. اگر اعتبارنامه نادرست باشند، پیام خطا برگردانده میشود. |
|||
|
|||
### ولیدیشنها: |
|||
- **ایمیل و رمز عبور:** |
|||
- بررسی میشود که ایمیل و رمز عبور وارد شده معتبر باشند. |
|||
- اگر کاربر با این ایمیل و رمز عبور یافت نشود، پیام خطای مناسب برگردانده میشود. |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه درخواست: |
|||
|
|||
### درخواست کامل: |
|||
```json |
|||
{ |
|||
"email": "[email protected]", |
|||
"password": "mypassword", |
|||
"fcm": "fcm_token_example", |
|||
"device_id": "device_id_example" |
|||
} |
|||
``` |
|||
|
|||
### پاسخ موفق: |
|||
```json |
|||
{ |
|||
"id": 2, |
|||
"fullname": "Jane Doe", |
|||
"email": "[email protected]", |
|||
"token": "xyz987uvw654", |
|||
"avatar": null |
|||
} |
|||
``` |
|||
""" |
|||
|
|||
|
|||
def doc_verify(): |
|||
return """ |
|||
# 🐈 Scenario |
|||
📅️ تأیید حساب کاربری با کد تأیید |
|||
|
|||
کاربر پس از ثبتنام، باید با استفاده از کد تأییدی که به ایمیل او ارسال شده است، |
|||
حساب کاربری خود را تأیید کند. در این مرحله، کاربر ایمیل و کد تأیید خود را ارسال میکند. |
|||
|
|||
--- |
|||
|
|||
## 🚀 درخواست API |
|||
|
|||
### URL: |
|||
``` |
|||
POST /api/verify/ |
|||
``` |
|||
|
|||
### Header: |
|||
| کلید | مقدار | |
|||
|---------------|---------------------------------| |
|||
| Content-Type | application/json | |
|||
| Authorization | Optional (برای این endpoint نیاز نیست) | |
|||
|
|||
### Body: |
|||
```json |
|||
{ |
|||
"email": "[email protected]", |
|||
"code": "12345" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📊 پاسخها |
|||
|
|||
| کد وضعیت | توضیحات | |
|||
|---------------|-----------------------------------------------------------| |
|||
| `201` | موفقیتآمیز - کاربر تأیید شد و توکن احراز هویت بازگردانده شد. | |
|||
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
|||
| `404` | کاربر یا کد تأیید یافت نشد. | |
|||
| `410` | کد تأیید منقضی شده است. | |
|||
| `500` | مشکل موقتی در سرور. | |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ موفقیتآمیز |
|||
|
|||
```json |
|||
{ |
|||
"token": "abc123def456", |
|||
"user_id": 1, |
|||
"phone_number": "1234567890", |
|||
"email": "[email protected]", |
|||
"fullname": "John Doe", |
|||
"avatar": null |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ خطا |
|||
|
|||
### کد تأیید نادرست: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "invalid_verification_code", |
|||
"status_code": 400, |
|||
"message": "The verification code is invalid." |
|||
} |
|||
``` |
|||
|
|||
### کد تأیید منقضی شده است: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "expired_code", |
|||
"status_code": 410, |
|||
"message": "The verification code has expired." |
|||
} |
|||
``` |
|||
|
|||
### کاربر یا کد تأیید یافت نشد: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "not_found", |
|||
"status_code": 404, |
|||
"message": "Verification data not found or expired." |
|||
} |
|||
``` |
|||
|
|||
### مشکل موقتی در سرور: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "service_unavailable", |
|||
"status_code": 500, |
|||
"message": "Service temporarily unavailable." |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 💡 نکات مهم: |
|||
1. **کد تأیید:** |
|||
- کد تأیید باید دقیقاً با کدی که به ایمیل کاربر ارسال شده مطابقت داشته باشد. |
|||
- کد تأیید فقط برای یک مدت محدود اعتبار دارد. |
|||
2. **خطاها:** |
|||
- اگر کد تأیید نادرست باشد، پیام مناسب بازگردانده میشود. |
|||
- اگر کد تأیید منقضی شده باشد، کاربر باید درخواست کد جدید کند. |
|||
3. **توکن احراز هویت:** |
|||
- پس از تأیید موفقیتآمیز، توکن احراز هویت به کاربر بازگردانده میشود که برای دسترسی به دیگر بخشهای سیستم نیاز است. |
|||
|
|||
--- |
|||
|
|||
### ولیدیشنها: |
|||
- **کد تأیید:** |
|||
- باید حداکثر 5 کاراکتر باشد. |
|||
- اگر کد معتبر نباشد یا منقضی شده باشد، پیام خطای مناسب برگردانده میشود. |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه درخواست: |
|||
|
|||
### درخواست کامل: |
|||
```json |
|||
{ |
|||
"email": "[email protected]", |
|||
"code": "67890" |
|||
} |
|||
``` |
|||
|
|||
### پاسخ موفق: |
|||
```json |
|||
{ |
|||
"token": "xyz987uvw654", |
|||
"user_id": 2, |
|||
"phone_number": "0987654321", |
|||
"email": "[email protected]", |
|||
"fullname": "Jane Doe", |
|||
"avatar": null |
|||
} |
|||
``` |
|||
""" |
|||
|
|||
|
|||
def doc_register(): |
|||
return """ |
|||
# 🐈 Scenario |
|||
ثبت نام کاربر |
|||
|
|||
کاربر با وارد کردن اطلاعات مورد نیاز شامل نام کامل، ایمیل، رمز عبور و تأیید رمز عبور درخواست ثبتنام ارسال میکند. پس از ثبت موفق، یک کد تأیید به ایمیل ارسال میشود که برای تکمیل ثبتنام مورد نیاز است. |
|||
|
|||
--- |
|||
|
|||
## 🚀 درخواست API |
|||
|
|||
### URL: |
|||
``` |
|||
POST /api/register/ |
|||
``` |
|||
|
|||
### Header: |
|||
| کلید | مقدار | |
|||
|---------------|---------------------------------| |
|||
| Content-Type | application/json | |
|||
| Authorization | Optional (برای این endpoint نیاز نیست) | |
|||
|
|||
### Body: |
|||
```json |
|||
{ |
|||
"fullname": "John Doe", |
|||
"email": "[email protected]", |
|||
"password": "strongpassword", |
|||
"password_confirmation": "strongpassword", |
|||
"fcm": "fcm_token_optional", |
|||
"device_id": "device_id_optional" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📊 پاسخها |
|||
|
|||
| کد وضعیت | توضیحات | |
|||
|---------------|-----------------------------------------------------------| |
|||
| `202` | موفقیتآمیز - کد تأیید به ایمیل کاربر ارسال شد. | |
|||
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. | |
|||
| `409` | ایمیل قبلاً ثبت شده است. | |
|||
| `404` | کاربر یا منبع یافت نشد. | |
|||
| `410` | کد تأیید منقضی شده است. | |
|||
| `500` | مشکل موقتی در سرور. | |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ موفقیتآمیز |
|||
|
|||
```json |
|||
{ |
|||
"user": { |
|||
"id": 1, |
|||
"fullname": "John Doe", |
|||
"email": "[email protected]" |
|||
}, |
|||
"message": "The otp code was sent to the user's email" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه پاسخ خطا |
|||
|
|||
### ایمیل تکراری: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 409, |
|||
"message": "There were validation errors.", |
|||
"errors": [ |
|||
{ |
|||
"field": "email", |
|||
"message": "This email is already registered." |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
### رمز عبور و تأیید رمز عبور برابر نیستند: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 400, |
|||
"message": "There were validation errors.", |
|||
"errors": [ |
|||
{ |
|||
"field": "password_confirmation", |
|||
"message": "Passwords do not match." |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
### رمز عبور کوتاهتر از 8 کاراکتر است: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 400, |
|||
"message": "There were validation errors.", |
|||
"errors": [ |
|||
{ |
|||
"field": "password", |
|||
"message": "Password must be at least 8 characters long." |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
### درخواست نامعتبر (فیلدهای اجباری): |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 400, |
|||
"message": "There were validation errors.", |
|||
"errors": [ |
|||
{ |
|||
"field": "fullname", |
|||
"message": "This field is required." |
|||
}, |
|||
{ |
|||
"field": "email", |
|||
"message": "This field is required." |
|||
}, |
|||
{ |
|||
"field": "password", |
|||
"message": "This field is required." |
|||
}, |
|||
{ |
|||
"field": "password_confirmation", |
|||
"message": "This field is required." |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
### کاربر یافت نشد: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "not_found", |
|||
"status_code": 404, |
|||
"message": "The requested resource was not found." |
|||
} |
|||
``` |
|||
|
|||
### کد تأیید منقضی شده است: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "expired_code", |
|||
"status_code": 410, |
|||
"message": "The verification code has expired." |
|||
} |
|||
``` |
|||
|
|||
### مشکل موقتی در سرور: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "service_unavailable", |
|||
"status_code": 500, |
|||
"message": "Service temporarily unavailable." |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 💡 نکات مهم: |
|||
1. **رمز عبور:** |
|||
- باید حداقل 8 کاراکتر باشد. |
|||
- رمز عبور و تأیید رمز عبور (`password_confirmation`) باید یکسان باشند. |
|||
2. **ایمیل:** |
|||
- باید یک آدرس ایمیل معتبر باشد. |
|||
- ایمیلهای تکراری مجاز نیستند. |
|||
3. **کد OTP:** |
|||
- کد تأیید به ایمیل ارسال میشود و برای وریفای کاربر استفاده میشود. |
|||
4. **فیلدهای اختیاری:** |
|||
- `fcm` و `device_id` در صورت نیاز میتوانند ارسال شوند اما اجباری نیستند. |
|||
|
|||
--- |
|||
|
|||
### ولیدیشنها: |
|||
- **ایمیل:** |
|||
- بررسی میشود که در سیستم موجود نباشد. |
|||
- اگر موجود باشد، پیام خطای زیر برگردانده میشود: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 409, |
|||
"message": "There were validation errors.", |
|||
"errors": [ |
|||
{ |
|||
"field": "email", |
|||
"message": "This email is already registered." |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
- **رمز عبور:** |
|||
- بررسی میشود که حداقل 8 کاراکتر باشد: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 400, |
|||
"message": "There were validation errors.", |
|||
"errors": [ |
|||
{ |
|||
"field": "password", |
|||
"message": "Password must be at least 8 characters long." |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
- بررسی میشود که با `password_confirmation` یکسان باشد: |
|||
```json |
|||
{ |
|||
"status": "error", |
|||
"code": "validation_error", |
|||
"status_code": 400, |
|||
"message": "There were validation errors.", |
|||
"errors": [ |
|||
{ |
|||
"field": "password_confirmation", |
|||
"message": "Passwords do not match." |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📄 نمونه درخواست: |
|||
|
|||
### درخواست کامل: |
|||
```json |
|||
{ |
|||
"fullname": "Jane Doe", |
|||
"email": "[email protected]", |
|||
"password": "securepassword", |
|||
"password_confirmation": "securepassword", |
|||
"fcm": "fcm_token_example", |
|||
"device_id": "device_id_example" |
|||
} |
|||
``` |
|||
|
|||
### پاسخ موفق: |
|||
```json |
|||
{ |
|||
"user": { |
|||
"id": 2, |
|||
"fullname": "Jane Doe", |
|||
"email": "[email protected]" |
|||
}, |
|||
"message": "The otp code was sent to the user's email" |
|||
} |
|||
``` |
|||
""" |
|||
@ -0,0 +1,28 @@ |
|||
from django.core.management.base import BaseCommand |
|||
from django.db import transaction |
|||
from django.db.models import Q |
|||
|
|||
from apps.account.models import User |
|||
|
|||
|
|||
class Command(BaseCommand): |
|||
help = "Assign slugs to all professor users that currently lack one." |
|||
|
|||
def handle(self, *args, **options): |
|||
professors = User.objects.filter( |
|||
user_type=User.UserType.PROFESSOR |
|||
).filter(Q(slug__isnull=True) | Q(slug="")) |
|||
|
|||
if not professors.exists(): |
|||
self.stdout.write(self.style.SUCCESS("All professor users already have slugs.")) |
|||
return |
|||
|
|||
updated = 0 |
|||
with transaction.atomic(): |
|||
for professor in professors.iterator(): |
|||
if professor.ensure_professor_profile(): |
|||
updated += 1 |
|||
|
|||
self.stdout.write( |
|||
self.style.SUCCESS(f"Assigned slugs to {updated} professor user(s).") |
|||
) |
|||
@ -0,0 +1,187 @@ |
|||
from django.core.management.base import BaseCommand |
|||
from django.contrib.auth.models import Group |
|||
|
|||
from apps.account.models import User |
|||
|
|||
|
|||
class Command(BaseCommand): |
|||
help = ( |
|||
"Audit admin panel access consistency for users. " |
|||
"Shows users with panel access and highlights unsafe role/flag/group combinations. " |
|||
"Use --fix to automatically normalize unsafe records." |
|||
) |
|||
|
|||
PANEL_GROUPS = {"Professor Group", "Admin Group", "Super Admin Group", "Super admin Group"} |
|||
|
|||
def add_arguments(self, parser): |
|||
parser.add_argument( |
|||
"--fix", |
|||
action="store_true", |
|||
help="Automatically fix unsafe user records.", |
|||
) |
|||
parser.add_argument( |
|||
"--email", |
|||
action="append", |
|||
dest="emails", |
|||
help="Limit audit to one or more email addresses. Can be passed multiple times.", |
|||
) |
|||
|
|||
def handle(self, *args, **options): |
|||
should_fix = options["fix"] |
|||
emails = options.get("emails") or [] |
|||
|
|||
queryset = User.objects.filter(email__isnull=False).exclude(email="").order_by("id") |
|||
if emails: |
|||
queryset = queryset.filter(email__in=emails) |
|||
|
|||
panel_users = [] |
|||
anomalies = [] |
|||
fixed_count = 0 |
|||
|
|||
for user in queryset: |
|||
groups = list(user.groups.values_list("name", flat=True)) |
|||
can_panel = user.can_access_admin_panel() |
|||
|
|||
if can_panel or user.is_staff or user.is_superuser or any(g in self.PANEL_GROUPS for g in groups): |
|||
panel_users.append( |
|||
{ |
|||
"id": user.id, |
|||
"email": user.email, |
|||
"user_type": user.user_type, |
|||
"is_staff": user.is_staff, |
|||
"is_superuser": user.is_superuser, |
|||
"groups": groups, |
|||
"can_panel": can_panel, |
|||
} |
|||
) |
|||
|
|||
user_anomalies = self.get_anomalies(user, groups) |
|||
if user_anomalies: |
|||
anomalies.append( |
|||
{ |
|||
"user": user, |
|||
"groups": groups, |
|||
"anomalies": user_anomalies, |
|||
} |
|||
) |
|||
|
|||
self.stdout.write(self.style.MIGRATE_HEADING("Panel Access Users")) |
|||
if panel_users: |
|||
for item in panel_users: |
|||
self.stdout.write(str(item)) |
|||
else: |
|||
self.stdout.write(self.style.SUCCESS("No panel-related users found.")) |
|||
|
|||
self.stdout.write("") |
|||
self.stdout.write(self.style.MIGRATE_HEADING("Anomalies")) |
|||
if anomalies: |
|||
for item in anomalies: |
|||
user = item["user"] |
|||
self.stdout.write( |
|||
self.style.WARNING( |
|||
str( |
|||
{ |
|||
"id": user.id, |
|||
"email": user.email, |
|||
"user_type": user.user_type, |
|||
"is_staff": user.is_staff, |
|||
"is_superuser": user.is_superuser, |
|||
"groups": item["groups"], |
|||
"anomalies": item["anomalies"], |
|||
} |
|||
) |
|||
) |
|||
) |
|||
if should_fix: |
|||
if self.fix_user(user, item["anomalies"]): |
|||
fixed_count += 1 |
|||
else: |
|||
self.stdout.write(self.style.SUCCESS("No anomalies found.")) |
|||
|
|||
if should_fix: |
|||
self.stdout.write("") |
|||
self.stdout.write(self.style.SUCCESS(f"Fixed {fixed_count} user(s).")) |
|||
|
|||
def get_anomalies(self, user, groups): |
|||
anomalies = [] |
|||
|
|||
if user.user_type in [User.UserType.STUDENT, User.UserType.CLIENT, User.UserType.CONSULTANT]: |
|||
if user.is_staff or user.is_superuser: |
|||
anomalies.append("low_role_with_staff_flags") |
|||
if any(group in self.PANEL_GROUPS for group in groups): |
|||
anomalies.append("low_role_with_panel_group") |
|||
if user.can_access_admin_panel(): |
|||
anomalies.append("low_role_can_access_panel") |
|||
|
|||
if user.user_type == User.UserType.PROFESSOR and "Professor Group" not in groups: |
|||
anomalies.append("professor_missing_group") |
|||
|
|||
if user.user_type == User.UserType.ADMIN and "Admin Group" not in groups: |
|||
anomalies.append("admin_missing_group") |
|||
|
|||
if user.user_type == User.UserType.SUPER_ADMIN and not any( |
|||
group in groups for group in ["Super Admin Group", "Super admin Group"] |
|||
): |
|||
anomalies.append("super_admin_missing_group") |
|||
|
|||
if user.user_type == User.UserType.SUPER_ADMIN and not user.is_superuser: |
|||
anomalies.append("super_admin_missing_superuser_flag") |
|||
|
|||
return anomalies |
|||
|
|||
def fix_user(self, user, anomalies): |
|||
changed = False |
|||
|
|||
if user.user_type == User.UserType.SUPER_ADMIN: |
|||
if not user.is_staff: |
|||
user.is_staff = True |
|||
changed = True |
|||
if not user.is_superuser: |
|||
user.is_superuser = True |
|||
changed = True |
|||
changed = self.ensure_group(user, "Super Admin Group") or changed |
|||
changed = self.remove_groups(user, {"Professor Group", "Admin Group", "Super admin Group"}) or changed |
|||
|
|||
elif user.user_type == User.UserType.ADMIN: |
|||
if user.is_superuser: |
|||
user.is_superuser = False |
|||
changed = True |
|||
changed = self.ensure_group(user, "Admin Group") or changed |
|||
changed = self.remove_groups(user, {"Professor Group", "Super Admin Group", "Super admin Group"}) or changed |
|||
|
|||
elif user.user_type == User.UserType.PROFESSOR: |
|||
if user.is_superuser: |
|||
user.is_superuser = False |
|||
changed = True |
|||
changed = self.ensure_group(user, "Professor Group") or changed |
|||
changed = self.remove_groups(user, {"Admin Group", "Super Admin Group", "Super admin Group"}) or changed |
|||
|
|||
elif user.user_type in [User.UserType.STUDENT, User.UserType.CLIENT, User.UserType.CONSULTANT]: |
|||
if user.is_staff: |
|||
user.is_staff = False |
|||
changed = True |
|||
if user.is_superuser: |
|||
user.is_superuser = False |
|||
changed = True |
|||
changed = self.remove_groups(user, self.PANEL_GROUPS) or changed |
|||
|
|||
if changed: |
|||
user.save() |
|||
self.stdout.write(self.style.SUCCESS(f"Fixed user {user.id} <{user.email}>")) |
|||
|
|||
return changed |
|||
|
|||
def ensure_group(self, user, group_name): |
|||
group, _ = Group.objects.get_or_create(name=group_name) |
|||
if not user.groups.filter(id=group.id).exists(): |
|||
user.groups.add(group) |
|||
return True |
|||
return False |
|||
|
|||
def remove_groups(self, user, group_names): |
|||
groups = Group.objects.filter(name__in=group_names) |
|||
existing_ids = set(user.groups.filter(id__in=groups.values("id")).values_list("id", flat=True)) |
|||
if existing_ids: |
|||
user.groups.remove(*groups) |
|||
return True |
|||
return False |
|||
@ -0,0 +1,175 @@ |
|||
import os |
|||
import csv |
|||
import zipfile |
|||
import requests |
|||
from pathlib import Path |
|||
from django.core.management.base import BaseCommand, CommandError |
|||
from django.db import connection |
|||
|
|||
|
|||
class Command(BaseCommand): |
|||
help = 'Create and populate geonames_city table with GeoNames data' |
|||
|
|||
def add_arguments(self, parser): |
|||
parser.add_argument( |
|||
'--force', |
|||
action='store_true', |
|||
help='Force recreation of table even if it exists', |
|||
) |
|||
parser.add_argument( |
|||
'--skip-download', |
|||
action='store_true', |
|||
help='Skip downloading data, use existing files', |
|||
) |
|||
|
|||
def handle(self, *args, **options): |
|||
self.stdout.write('Creating geonames_city table...') |
|||
|
|||
# Create table |
|||
with connection.cursor() as cursor: |
|||
if options['force']: |
|||
cursor.execute('DROP TABLE IF EXISTS geonames_city') |
|||
|
|||
cursor.execute(''' |
|||
CREATE TABLE IF NOT EXISTS geonames_city ( |
|||
id SERIAL PRIMARY KEY, |
|||
geonameid INTEGER, |
|||
name VARCHAR(200), |
|||
asciiname VARCHAR(200), |
|||
alternatenames TEXT, |
|||
latitude DECIMAL(10, 7), |
|||
longitude DECIMAL(10, 7), |
|||
feature_class CHAR(1), |
|||
feature_code VARCHAR(10), |
|||
country_code CHAR(2), |
|||
cc2 VARCHAR(200), |
|||
admin1_code VARCHAR(20), |
|||
admin2_code VARCHAR(80), |
|||
admin3_code VARCHAR(20), |
|||
admin4_code VARCHAR(20), |
|||
population BIGINT, |
|||
elevation INTEGER, |
|||
dem INTEGER, |
|||
timezone VARCHAR(40), |
|||
modification_date DATE |
|||
) |
|||
''') |
|||
|
|||
# Create indexes for better performance |
|||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geonames_city_coords ON geonames_city (latitude, longitude)') |
|||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geonames_city_country ON geonames_city (country_code)') |
|||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geonames_city_feature ON geonames_city (feature_class)') |
|||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geonames_city_population ON geonames_city (population)') |
|||
|
|||
self.stdout.write(self.style.SUCCESS('Table created successfully')) |
|||
|
|||
if not options['skip_download']: |
|||
self.download_and_import_data() |
|||
else: |
|||
self.stdout.write('Skipping download, using existing data...') |
|||
|
|||
def download_and_import_data(self): |
|||
"""Download and import GeoNames cities data""" |
|||
self.stdout.write('Downloading GeoNames cities data...') |
|||
|
|||
# Create data directory |
|||
data_dir = Path('utils/geonames_data') |
|||
data_dir.mkdir(exist_ok=True) |
|||
|
|||
# Download cities500.zip (cities with population > 500) |
|||
url = 'https://download.geonames.org/export/dump/cities500.zip' |
|||
zip_path = data_dir / 'cities500.zip' |
|||
|
|||
try: |
|||
response = requests.get(url, stream=True) |
|||
response.raise_for_status() |
|||
|
|||
with open(zip_path, 'wb') as f: |
|||
for chunk in response.iter_content(chunk_size=8192): |
|||
f.write(chunk) |
|||
|
|||
self.stdout.write('Download completed') |
|||
|
|||
# Extract zip file |
|||
with zipfile.ZipFile(zip_path, 'r') as zip_ref: |
|||
zip_ref.extractall(data_dir) |
|||
|
|||
# Import data |
|||
self.import_cities_data(data_dir / 'cities500.txt') |
|||
|
|||
except Exception as e: |
|||
raise CommandError(f'Failed to download/import data: {e}') |
|||
|
|||
def import_cities_data(self, txt_file): |
|||
"""Import cities data from GeoNames text file""" |
|||
self.stdout.write(f'Importing data from {txt_file}...') |
|||
|
|||
if not txt_file.exists(): |
|||
raise CommandError(f'File {txt_file} does not exist') |
|||
|
|||
batch_size = 1000 |
|||
batch = [] |
|||
|
|||
with open(txt_file, 'r', encoding='utf-8') as f: |
|||
for line_num, line in enumerate(f, 1): |
|||
if line_num % 10000 == 0: |
|||
self.stdout.write(f'Processing line {line_num}...') |
|||
|
|||
fields = line.strip().split('\t') |
|||
if len(fields) < 19: |
|||
continue |
|||
|
|||
try: |
|||
# Parse the GeoNames format |
|||
geonameid = int(fields[0]) |
|||
name = fields[1][:200] if fields[1] else '' |
|||
asciiname = fields[2][:200] if fields[2] else '' |
|||
alternatenames = fields[3] if fields[3] else '' |
|||
latitude = float(fields[4]) |
|||
longitude = float(fields[5]) |
|||
feature_class = fields[6] |
|||
feature_code = fields[7] |
|||
country_code = fields[8][:2] if fields[8] else '' |
|||
cc2 = fields[9] if fields[9] else '' |
|||
admin1_code = fields[10] if fields[10] else '' |
|||
admin2_code = fields[11] if fields[11] else '' |
|||
admin3_code = fields[12] if fields[12] else '' |
|||
admin4_code = fields[13] if fields[13] else '' |
|||
population = int(fields[14]) if fields[14] and fields[14] != '0' else 0 |
|||
elevation = int(fields[15]) if fields[15] else None |
|||
dem = int(fields[16]) if fields[16] else None |
|||
timezone = fields[17] if fields[17] else '' |
|||
modification_date = fields[18] if fields[18] else None |
|||
|
|||
batch.append(( |
|||
geonameid, name, asciiname, alternatenames, latitude, longitude, |
|||
feature_class, feature_code, country_code, cc2, admin1_code, |
|||
admin2_code, admin3_code, admin4_code, population, elevation, |
|||
dem, timezone, modification_date |
|||
)) |
|||
|
|||
if len(batch) >= batch_size: |
|||
self.insert_batch(batch) |
|||
batch = [] |
|||
|
|||
except (ValueError, IndexError) as e: |
|||
self.stdout.write(self.style.WARNING(f'Error parsing line {line_num}: {e}')) |
|||
continue |
|||
|
|||
# Insert remaining records |
|||
if batch: |
|||
self.insert_batch(batch) |
|||
|
|||
self.stdout.write(self.style.SUCCESS('Data import completed')) |
|||
|
|||
def insert_batch(self, batch): |
|||
"""Insert a batch of records into the database""" |
|||
with connection.cursor() as cursor: |
|||
cursor.executemany(''' |
|||
INSERT INTO geonames_city ( |
|||
geonameid, name, asciiname, alternatenames, latitude, longitude, |
|||
feature_class, feature_code, country_code, cc2, admin1_code, |
|||
admin2_code, admin3_code, admin4_code, population, elevation, |
|||
dem, timezone, modification_date |
|||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) |
|||
''', batch) |
|||
@ -0,0 +1,52 @@ |
|||
from django.core.management.base import BaseCommand |
|||
from django.contrib.auth.models import Group, Permission |
|||
from django.contrib.contenttypes.models import ContentType |
|||
|
|||
from apps.account.models import User |
|||
|
|||
|
|||
|
|||
|
|||
class Command(BaseCommand): |
|||
help = 'Create default groups and assign permissions to them' |
|||
|
|||
def handle(self, *args, **kwargs): |
|||
# تعریف گروهها و پرمیشنها |
|||
groups_permissions = { |
|||
"Professor Group": [ |
|||
"view_user", "add_user", "change_user" |
|||
], |
|||
"Client Group": [ |
|||
"view_user" |
|||
], |
|||
"Admin Group": [ |
|||
"view_user", "add_user", "change_user", "delete_user" |
|||
], |
|||
"Super Admin Group": [ |
|||
"view_user", "add_user", "change_user", "delete_user", "manage_permissions" |
|||
], |
|||
"Student Group": [ |
|||
"view_user" |
|||
] |
|||
} |
|||
|
|||
content_type = ContentType.objects.get_for_model(User) |
|||
|
|||
for group_name, permissions in groups_permissions.items(): |
|||
group, created = Group.objects.get_or_create(name=group_name) |
|||
if created: |
|||
self.stdout.write(self.style.SUCCESS(f"Group '{group_name}' created successfully.")) |
|||
else: |
|||
self.stdout.write(self.style.WARNING(f"Group '{group_name}' already exists.")) |
|||
|
|||
for perm_codename in permissions: |
|||
permission, created = Permission.objects.get_or_create( |
|||
codename=perm_codename, |
|||
defaults={ |
|||
'name': f"Can {perm_codename.replace('_', ' ')} User", |
|||
'content_type': content_type |
|||
} |
|||
) |
|||
group.permissions.add(permission) |
|||
|
|||
self.stdout.write(self.style.SUCCESS("All groups and permissions have been created successfully.")) |
|||
@ -0,0 +1,208 @@ |
|||
from django.core.management.base import BaseCommand |
|||
from apps.account.models import NotificationTemplate |
|||
|
|||
class Command(BaseCommand): |
|||
help = 'Seeds default NotificationTemplate records in the database with RU and EN translations.' |
|||
|
|||
def handle(self, *args, **options): |
|||
default_templates = [ |
|||
{ |
|||
"notification_type": "course_access_granted", |
|||
"name": "Доступ к курсу активирован", |
|||
"title_ru": "Доступ к курсу активирован", |
|||
"title_en": "Course Access Granted", |
|||
"body_ru": "Ваш доступ к курсу «{course_title}» успешно активирован.", |
|||
"body_en": "Your access to the course '{course_title}' has been activated.", |
|||
"placeholders_info": "{course_title}, {course_name}, {course_slug}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "course_registered", |
|||
"name": "Регистрация на курс", |
|||
"title_ru": "Успешная регистрация на курс", |
|||
"title_en": "Course Registration Successful", |
|||
"body_ru": "Вы успешно зарегистрировались на курс «{course_title}».", |
|||
"body_en": "You have successfully registered for the course '{course_title}'.", |
|||
"placeholders_info": "{course_title}, {course_name}, {course_slug}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "lesson_completed", |
|||
"name": "Урок завершен", |
|||
"title_ru": "Урок завершен", |
|||
"title_en": "Lesson Completed", |
|||
"body_ru": "Поздравляем! Вы завершили изучение урока «{lesson_title}».", |
|||
"body_en": "Congratulations! You completed the lesson '{lesson_title}'.", |
|||
"placeholders_info": "{course_title}, {course_name}, {lesson_title}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "course_completed", |
|||
"name": "Курс завершен", |
|||
"title_ru": "Курс завершен", |
|||
"title_en": "Course Completed", |
|||
"body_ru": "Отличная работа! Вы успешно завершили курс «{course_title}».", |
|||
"body_en": "Well done! You have completed the course '{course_title}'.", |
|||
"placeholders_info": "{course_title}, {course_name}, {course_slug}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "new_course_weekly", |
|||
"name": "Новый курс за неделю", |
|||
"title_ru": "Добавлен новый курс", |
|||
"title_en": "New Course Registered", |
|||
"body_ru": "Зарегистрирован новый курс «{course_title}». Вы можете записаться прямо сейчас.", |
|||
"body_en": "A new course '{course_title}' has been registered. You can enroll now.", |
|||
"placeholders_info": "{course_title}, {course_name}, {course_slug}" |
|||
}, |
|||
{ |
|||
"notification_type": "low_quiz_result", |
|||
"name": "Предложение пересдать тест", |
|||
"title_ru": "Предложение пересдать тест", |
|||
"title_en": "Quiz Retake Suggestion", |
|||
"body_ru": "Вы набрали балл ниже проходного в тесте «{quiz_title}». Рекомендуем пройти его повторно.", |
|||
"body_en": "You scored below the passing threshold on quiz '{quiz_title}'. We suggest taking it again.", |
|||
"placeholders_info": "{quiz_title}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "live_class_rescheduled", |
|||
"name": "Время живого урока изменено", |
|||
"title_ru": "Время живого урока изменено", |
|||
"title_en": "Live Class Rescheduled", |
|||
"body_ru": "Время проведения живого урока по курсу «{course_title}» было изменено.", |
|||
"body_en": "The live class for '{course_title}' has been rescheduled.", |
|||
"placeholders_info": "{course_title}, {course_name}, {session_subject}, {session_start_time}" |
|||
}, |
|||
{ |
|||
"notification_type": "live_class_cancelled", |
|||
"name": "Живой урок отменен", |
|||
"title_ru": "Живой урок отменен", |
|||
"title_en": "Live Class Cancelled", |
|||
"body_ru": "Живой урок по курсу «{course_title}» был отменен.", |
|||
"body_en": "The live class for '{course_title}' has been cancelled.", |
|||
"placeholders_info": "{course_title}, {course_name}, {session_subject}" |
|||
}, |
|||
{ |
|||
"notification_type": "live_recording_available", |
|||
"name": "Доступна запись живого урока", |
|||
"title_ru": "Доступна запись урока", |
|||
"title_en": "Live Recording Available", |
|||
"body_ru": "Запись живого урока по курсу «{course_title}» уже доступна для просмотра.", |
|||
"body_en": "The recorded video of the live class for '{course_title}' is now available.", |
|||
"placeholders_info": "{course_title}, {course_name}" |
|||
}, |
|||
{ |
|||
"notification_type": "missed_live_sessions", |
|||
"name": "Пропуск живых уроков", |
|||
"title_ru": "Пропуск живых уроков", |
|||
"title_en": "Missed Live Sessions", |
|||
"body_ru": "Вы пропустили 2 живых урока подряд по курсу «{course_title}». Рекомендуем посмотреть их в записи.", |
|||
"body_en": "You have missed 2 consecutive live sessions in the course '{course_title}'. We recommend watching the recordings.", |
|||
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "payment_successful", |
|||
"name": "Оплата успешна", |
|||
"title_ru": "Оплата успешна", |
|||
"title_en": "Payment Successful", |
|||
"body_ru": "Ваша оплата за курс «{course_title}» успешно проведена.", |
|||
"body_en": "Your payment for course '{course_title}' was successful.", |
|||
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "payment_failed", |
|||
"name": "Ошибка оплаты", |
|||
"title_ru": "Ошибка оплаты", |
|||
"title_en": "Payment Failed", |
|||
"body_ru": "Произошла ошибка при обработке вашей оплаты за курс «{course_title}».", |
|||
"body_en": "We encountered an issue processing your payment for '{course_title}'.", |
|||
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "refund_completed", |
|||
"name": "Возврат средств", |
|||
"title_ru": "Возврат средств выполнен", |
|||
"title_en": "Refund Completed", |
|||
"body_ru": "Возврат средств за курс «{course_title}» успешно завершен.", |
|||
"body_en": "A refund has been successfully completed for the course '{course_title}'.", |
|||
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "student_inactivity", |
|||
"name": "Мы скучаем по вам", |
|||
"title_ru": "Мы скучаем по вам!", |
|||
"title_en": "We Miss You!", |
|||
"body_ru": "Вы не изучали уроки за последнюю неделю. Ждем вас для продолжения обучения!", |
|||
"body_en": "You haven't taken any lessons in the last week. Come back and continue your learning journey!", |
|||
"placeholders_info": "{student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "teacher_reply", |
|||
"name": "Ответ преподавателя", |
|||
"title_ru": "Новое сообщение от преподавателя", |
|||
"title_en": "New Message from Teacher", |
|||
"body_ru": "Преподаватель ответил на ваше сообщение.", |
|||
"body_en": "The teacher has replied to your message.", |
|||
"placeholders_info": "{student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "teacher_reply_private", |
|||
"name": "Личное сообщение от преподавателя", |
|||
"title_ru": "Новое сообщение от преподавателя", |
|||
"title_en": "New Message from Teacher", |
|||
"body_ru": "Преподаватель ответил на ваше сообщение.", |
|||
"body_en": "The teacher has replied to your message.", |
|||
"placeholders_info": "{student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "teacher_reply_course", |
|||
"name": "Сообщение от преподавателя в чате курса", |
|||
"title_ru": "Сообщение от преподавателя в чате курса", |
|||
"title_en": "Teacher Post in Course Chat", |
|||
"body_ru": "Преподаватель опубликовал новое сообщение в чате курса.", |
|||
"body_en": "The teacher posted in the course chat.", |
|||
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "support_reply", |
|||
"name": "Ответ поддержки", |
|||
"title_ru": "Новое сообщение от поддержки", |
|||
"title_en": "New Message from Support", |
|||
"body_ru": "Служба поддержки ответила на ваше сообщение.", |
|||
"body_en": "The support agent has replied to your message.", |
|||
"placeholders_info": "{student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "certificate_issued", |
|||
"name": "Сертификат выдан", |
|||
"title_ru": "Сертификат выдан", |
|||
"title_en": "Certificate Issued", |
|||
"body_ru": "Ваш сертификат по курсу «{course_title}» успешно выпущен.", |
|||
"body_en": "Your certificate for the course '{course_title}' has been issued.", |
|||
"placeholders_info": "{course_title}, {course_name}, {student_name}, {fullname}" |
|||
}, |
|||
{ |
|||
"notification_type": "live_class_reminder", |
|||
"name": "Напоминание о живом уроке", |
|||
"title_ru": "Напоминание о живом уроке", |
|||
"title_en": "Live Class Reminder", |
|||
"body_ru": "Напоминаем, что живой урок «{session_subject}» начнется в {session_start_time}.", |
|||
"body_en": "Reminder: The live class '{session_subject}' will start at {session_start_time}.", |
|||
"placeholders_info": "{session_subject}, {session_start_time}, {student_name}, {fullname}" |
|||
} |
|||
] |
|||
|
|||
count = 0 |
|||
for item in default_templates: |
|||
obj, created = NotificationTemplate.objects.update_or_create( |
|||
notification_type=item['notification_type'], |
|||
defaults={ |
|||
'name': item['name'], |
|||
'title_ru': item['title_ru'], |
|||
'title_en': item['title_en'], |
|||
'body_ru': item['body_ru'], |
|||
'body_en': item['body_en'], |
|||
'placeholders_info': item['placeholders_info'], |
|||
'is_active': True |
|||
} |
|||
) |
|||
if created: |
|||
count += 1 |
|||
|
|||
self.stdout.write(self.style.SUCCESS(f"Seeding completed! Created {count} new templates, updated existing ones.")) |
|||
@ -0,0 +1,115 @@ |
|||
import time |
|||
from django.core.management.base import BaseCommand |
|||
from django.contrib.auth import get_user_model |
|||
from apps.account.notification_service import create_and_send_notification |
|||
|
|||
class Command(BaseCommand): |
|||
help = 'Sends all non-live/non-chat test notifications to a specific user by email.' |
|||
|
|||
def handle(self, *args, **options): |
|||
email = '[email protected]' |
|||
User = get_user_model() |
|||
user = User.objects.filter(email=email).first() |
|||
|
|||
if not user: |
|||
self.stdout.write(self.style.ERROR(f"❌ User with email '{email}' not found.")) |
|||
return |
|||
|
|||
self.stdout.write(self.style.SUCCESS(f"🚀 Found user: {user.username} (FCM token: {getattr(user, 'fcm', 'None')})")) |
|||
|
|||
if not getattr(user, 'fcm', None): |
|||
self.stdout.write(self.style.WARNING("⚠️ Warning: This user has no FCM token registered. Notifications will only be saved in the database.")) |
|||
|
|||
notifications_to_send = [ |
|||
# 1. Learning Process |
|||
{ |
|||
"name": "1. Course Registered", |
|||
"title_en": "Course Registration Successful", |
|||
"body_en": "You have successfully registered for the course 'Quranic Studies'.", |
|||
"title_fa": "ثبتنام دوره موفقیتآمیز بود", |
|||
"body_fa": "ثبتنام شما در دوره «مطالعات قرآنی» با موفقیت انجام شد.", |
|||
"data": {"type": "course_registered", "course_id": "1"} |
|||
}, |
|||
{ |
|||
"name": "2. Lesson Completed", |
|||
"title_en": "Lesson Completed", |
|||
"body_en": "Congratulations! You completed the lesson 'Introduction to Surah Al-Fatiha'.", |
|||
"title_fa": "درس به اتمام رسید", |
|||
"body_fa": "تبریک! شما درس «آشنایی با سوره فاتحه» را به پایان رساندید.", |
|||
"data": {"type": "lesson_completed", "course_id": "1", "lesson_id": "1"} |
|||
}, |
|||
{ |
|||
"name": "3. Course Completed", |
|||
"title_en": "Course Completed", |
|||
"body_en": "Well done! You have completed the course 'Quranic Studies'.", |
|||
"title_fa": "دوره به اتمام رسید", |
|||
"body_fa": "آفرین! شما دوره «مطالعات قرآنی» را با موفقیت به پایان رساندید.", |
|||
"data": {"type": "course_completed", "course_id": "1"} |
|||
}, |
|||
# 2. Quizzes and Progress |
|||
{ |
|||
"name": "4. Low Quiz Score Suggestion", |
|||
"title_en": "Quiz Retake Suggestion", |
|||
"body_en": "You scored below the passing threshold on quiz 'Final Exam'. We suggest taking it again to improve your score.", |
|||
"title_fa": "پیشنهاد شرکت مجدد در آزمون", |
|||
"body_fa": "امتیاز شما در آزمون «امتحان نهایی» کمتر از حد نصاب شده است. پیشنهاد میکنیم مجدداً در این آزمون شرکت کنید تا نمره خود را بهبود ببخشید.", |
|||
"data": {"type": "low_quiz_result", "quiz_id": "1", "days_since_failure": "2"} |
|||
}, |
|||
# 3. Transactions and Finance |
|||
{ |
|||
"name": "5. Payment Successful", |
|||
"title_en": "Payment Successful", |
|||
"body_en": "Your payment for course 'Quranic Studies' was successful. Thank you for your purchase!", |
|||
"title_fa": "پرداخت موفقیتآمیز", |
|||
"body_fa": "پرداخت شما برای دوره «مطالعات قرآنی» موفقیتآمیز بود. از خرید شما سپاسگزاریم!", |
|||
"data": {"type": "payment_successful", "course_id": "1"} |
|||
}, |
|||
{ |
|||
"name": "6. Payment Failed", |
|||
"title_en": "Payment Failed", |
|||
"body_en": "We encountered an issue processing your payment for 'Quranic Studies'. Please try again.", |
|||
"title_fa": "خطا در پرداخت", |
|||
"body_fa": "در پردازش پرداخت شما برای دوره «مطالعات قرآنی» مشکلی رخ داد. لطفاً دوباره تلاش کنید.", |
|||
"data": {"type": "payment_failed", "course_id": "1"} |
|||
}, |
|||
{ |
|||
"name": "7. Refund Completed", |
|||
"title_en": "Refund Completed", |
|||
"body_en": "A refund has been successfully completed for the course 'Quranic Studies'.", |
|||
"title_fa": "بازگشت وجه انجام شد", |
|||
"body_fa": "بازگشت وجه برای دوره «مطالعات قرآنی» با موفقیت انجام شد.", |
|||
"data": {"type": "refund_completed", "course_id": "1"} |
|||
}, |
|||
# 4. Retention and Motivation |
|||
{ |
|||
"name": "8. Student Inactivity", |
|||
"title_en": "We Miss You!", |
|||
"body_en": "You haven't taken any lessons in the last week. Come back and continue your learning journey!", |
|||
"title_fa": "دلمان برایتان تنگ شده!", |
|||
"body_fa": "در یک هفته گذشته هیچ درسی را مطالعه نکردهاید. منتظرتان هستیم تا مسیر یادگیری خود را ادامه دهید!", |
|||
"data": {"type": "student_inactivity"} |
|||
} |
|||
] |
|||
|
|||
self.stdout.write(self.style.SUCCESS(f"Sending {len(notifications_to_send)} notifications (with a 2-second interval)...")) |
|||
|
|||
for index, item in enumerate(notifications_to_send, 1): |
|||
self.stdout.write(f"[{index}/{len(notifications_to_send)}] Sending notification: '{item['name']}'...") |
|||
try: |
|||
create_and_send_notification( |
|||
user=user, |
|||
title_en=item['title_en'], |
|||
body_en=item['body_en'], |
|||
title_fa=item['title_fa'], |
|||
body_fa=item['body_fa'], |
|||
service='imam-javad', |
|||
data=item['data'] |
|||
) |
|||
self.stdout.write(self.style.SUCCESS(f" Sent successfully!")) |
|||
except Exception as e: |
|||
self.stdout.write(self.style.ERROR(f" Failed to send: {e}")) |
|||
|
|||
if index < len(notifications_to_send): |
|||
time.sleep(2) |
|||
|
|||
self.stdout.write(self.style.SUCCESS("🎉 All test notifications processed successfully!")) |
|||
@ -0,0 +1,55 @@ |
|||
import secrets |
|||
from django.core.management.base import BaseCommand |
|||
from django.db.models import Q |
|||
from apps.account.models import User |
|||
|
|||
|
|||
class Command(BaseCommand): |
|||
help = 'Sets a new plain/hashed password and encrypts it for active users who lack an encrypted password.' |
|||
|
|||
def add_arguments(self, parser): |
|||
parser.add_argument( |
|||
'--password', |
|||
type=str, |
|||
help='Specific password to set for all matched users. If not provided, a random 8-character password will be generated for each user.' |
|||
) |
|||
parser.add_argument( |
|||
'--dry-run', |
|||
action='store_true', |
|||
help='Run the command without saving changes to the database.' |
|||
) |
|||
|
|||
def handle(self, *args, **options): |
|||
password_input = options['password'] |
|||
dry_run = options['dry_run'] |
|||
|
|||
# Find active users without an encrypted password |
|||
users = User.objects.filter( |
|||
Q(password_enc='') | Q(password_enc__isnull=True), |
|||
is_active=True |
|||
) |
|||
|
|||
if not users.exists(): |
|||
self.stdout.write(self.style.SUCCESS("No active users found without an encrypted password.")) |
|||
return |
|||
|
|||
self.stdout.write(f"Found {users.count()} active users without an encrypted password.") |
|||
|
|||
updated_count = 0 |
|||
for user in users: |
|||
# Generate or use the provided password |
|||
new_password = password_input if password_input else secrets.token_urlsafe(6)[:8] |
|||
|
|||
self.stdout.write(f"User: {user.email or user.fullname} (ID: {user.id}) -> Password: {new_password}") |
|||
|
|||
if not dry_run: |
|||
user.set_password(new_password) |
|||
user.set_plain_password(new_password) |
|||
user.save() |
|||
|
|||
updated_count += 1 |
|||
|
|||
if dry_run: |
|||
self.stdout.write(self.style.WARNING(f"[DRY-RUN] Would have updated {updated_count} users.")) |
|||
else: |
|||
self.stdout.write(self.style.SUCCESS(f"Successfully updated {updated_count} users.")) |
|||
@ -0,0 +1,37 @@ |
|||
from django.core.management.base import BaseCommand |
|||
from rest_framework.test import APIClient |
|||
from apps.account.models import User # Your user model |
|||
import json |
|||
|
|||
class Command(BaseCommand): |
|||
def handle(self, *args, **options): |
|||
client = APIClient() |
|||
|
|||
# Step 1: Create guest token |
|||
print("\n📝 Step 1: Creating guest token...") |
|||
response = client.post('/api/account/web/guest/', |
|||
data=json.dumps({"timezone": "UTC", "user_agent": "test"}), |
|||
content_type='application/json' |
|||
) |
|||
print(f"Status: {response.status_code}") |
|||
if response.status_code == 200: |
|||
print(f"Response: {response.json()}") |
|||
token = response.json()['token'] |
|||
else: |
|||
print(f"Error Response: {response.content.decode('utf-8')}") |
|||
print("❌ Failed to create token!") |
|||
return |
|||
print(f"✅ Token created: {token[:20]}...") |
|||
|
|||
# Step 2: Test authentication with token |
|||
print("\n🔐 Step 2: Testing token authentication...") |
|||
client.credentials(HTTP_AUTHORIZATION=f'Token {token}') |
|||
|
|||
response = client.get('/api/library/books/') |
|||
print(f"Status: {response.status_code}") |
|||
if response.status_code == 200: |
|||
print(f"Response: {response.json()}") |
|||
print("✅ Token authentication works!") |
|||
else: |
|||
print(f"Error Response: {response.content.decode('utf-8')}") |
|||
print("❌ Token authentication failed!") |
|||
@ -0,0 +1,41 @@ |
|||
from django.contrib.auth.models import BaseUserManager, Group |
|||
from django.db.models import Manager |
|||
|
|||
|
|||
class UserManager(BaseUserManager): |
|||
def create_user(self, email: str = None, password: str = None, **extra_fields): |
|||
if not email: |
|||
raise ValueError("The Email field must be set") |
|||
email = self.normalize_email(email) |
|||
user = self.model(email=email, **extra_fields) |
|||
user.set_password(password) |
|||
user.save(using=self._db) |
|||
return user |
|||
|
|||
def create_superuser(self, email, password, **extra_fields): |
|||
extra_fields.setdefault('is_staff', True) |
|||
extra_fields.setdefault('is_superuser', True) |
|||
extra_fields.setdefault('is_active', True) |
|||
extra_fields.setdefault('user_type', 'super_admin') |
|||
|
|||
if extra_fields.get('is_staff') is not True: |
|||
raise ValueError('Superuser must have is_staff=True.') |
|||
if extra_fields.get('is_superuser') is not True: |
|||
raise ValueError('Superuser must have is_superuser=True.') |
|||
|
|||
return self.create_user(email=email, password=password, **extra_fields) |
|||
|
|||
|
|||
class ClientUserManager(UserManager): |
|||
def get_queryset(self): |
|||
return super().get_queryset().filter(user_type="client") |
|||
|
|||
|
|||
class AdminUserManager(UserManager): |
|||
def get_queryset(self): |
|||
return super().get_queryset().filter(user_type="admin") |
|||
|
|||
|
|||
class SuperAdminUserManager(UserManager): |
|||
def get_queryset(self): |
|||
return super().get_queryset().filter(user_type="super_admin") |
|||
@ -0,0 +1,176 @@ |
|||
import django.contrib.auth.models |
|||
from django.conf import settings |
|||
from django.db import migrations, models |
|||
import django.db.models.deletion |
|||
import django.utils.timezone |
|||
import phonenumber_field.modelfields |
|||
import utils.validators |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
initial = True |
|||
|
|||
dependencies = [ |
|||
('auth', '0012_alter_user_first_name_max_length'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.CreateModel( |
|||
name='User', |
|||
fields=[ |
|||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('password', models.CharField(max_length=128, verbose_name='password')), |
|||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), |
|||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), |
|||
('username', models.CharField(blank=True, error_messages={'unique': 'A user with that username already exists.'}, max_length=150, null=True, unique=True, verbose_name='Username')), |
|||
('email', models.EmailField(blank=True, error_messages={'unique': 'A user with that email already exists.'}, help_text='User primary email address.', max_length=254, null=True, unique=True, verbose_name='Email Address')), |
|||
('fullname', models.CharField(blank=True, help_text='Full name of the user.', max_length=255, null=True, verbose_name='Full Name')), |
|||
('birthdate', models.DateField(blank=True, null=True, verbose_name='Birthdate')), |
|||
('avatar', models.ImageField(blank=True, max_length=512, null=True, upload_to='users/avatars/%Y/%m/', verbose_name='Avatar')), |
|||
('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='e.g., +1 555 1234567', max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='Phone Number')), |
|||
('language', models.CharField(blank=True, default='en', max_length=10, null=True, verbose_name='Language')), |
|||
('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female'), ('other', 'Other')], max_length=20, null=True, verbose_name='Gender')), |
|||
('user_type', models.CharField(choices=[('client', 'Client'), ('admin', 'Admin'), ('super_admin', 'Super Admin')], default='client', max_length=20, verbose_name='User Type')), |
|||
('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='Date Joined')), |
|||
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')), |
|||
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='Country')), |
|||
('device_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Device ID')), |
|||
('device_os', models.CharField(blank=True, choices=[('android', 'Android'), ('apple', 'Apple iOS'), ('web', 'Web')], max_length=16, null=True, verbose_name='Device OS')), |
|||
('user_agent', models.TextField(blank=True, null=True, verbose_name='User Agent')), |
|||
('client_ip', models.CharField(blank=True, max_length=64, null=True, verbose_name='Client IP')), |
|||
('fcm', models.CharField(blank=True, max_length=512, null=True, verbose_name='FCM Token')), |
|||
('slug', models.SlugField(blank=True, max_length=255, null=True, unique=True, verbose_name='Slug')), |
|||
('is_staff', models.BooleanField(default=False, verbose_name='Is Staff')), |
|||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active.', verbose_name='Active')), |
|||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), |
|||
('info', models.TextField(blank=True, null=True, verbose_name='Bio / Info')), |
|||
('skill', models.CharField(blank=True, max_length=512, null=True, verbose_name='Skill / Role')), |
|||
('password_enc', models.CharField(blank=True, default='', help_text='Encrypted copy of password for displaying in the admin panel if required.', max_length=512, verbose_name='Encrypted Password')), |
|||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), |
|||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'User', |
|||
'verbose_name_plural': 'Users', |
|||
'ordering': ('-id',), |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='NotificationTemplate', |
|||
fields=[ |
|||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('notification_type', models.CharField(max_length=50, unique=True, verbose_name='Notification Type')), |
|||
('name', models.CharField(max_length=100, verbose_name='Name')), |
|||
('is_active', models.BooleanField(default=True, verbose_name='Is Active')), |
|||
('title', models.CharField(default='', max_length=255, verbose_name='Title')), |
|||
('body', models.TextField(default='', max_length=1024, verbose_name='Body')), |
|||
('placeholders_info', models.CharField(blank=True, max_length=255, null=True, verbose_name='Allowed Placeholders Description')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Notification Template', |
|||
'verbose_name_plural': 'Notification Templates', |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='Notification', |
|||
fields=[ |
|||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('title', models.CharField(max_length=255, verbose_name='Title')), |
|||
('message', models.TextField(max_length=1024, verbose_name='Message')), |
|||
('is_read', models.BooleanField(default=False, verbose_name='Is Read')), |
|||
('notification_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='Notification Type')), |
|||
('action', models.CharField(default='navigate', max_length=50, verbose_name='Action')), |
|||
('navigate_to', models.CharField(blank=True, max_length=255, null=True, verbose_name='Navigate To')), |
|||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
|||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
|||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Notification', |
|||
'verbose_name_plural': 'Notifications', |
|||
'ordering': ('-created_at',), |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='LoginHistory', |
|||
fields=[ |
|||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('lat', models.FloatField(blank=True, null=True, verbose_name='Latitude')), |
|||
('lon', models.FloatField(blank=True, null=True, verbose_name='Longitude')), |
|||
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='Country')), |
|||
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')), |
|||
('ip', models.CharField(blank=True, max_length=255, null=True, verbose_name='IP Address')), |
|||
('timezone', models.CharField(blank=True, max_length=100, null=True, verbose_name='Timezone')), |
|||
('user_agent', models.TextField(blank=True, null=True, verbose_name='User Agent')), |
|||
('device_os', models.CharField(blank=True, max_length=16, null=True, verbose_name='Device OS')), |
|||
('at_time', models.DateTimeField(auto_now_add=True, verbose_name='Timestamp')), |
|||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_history', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Login History', |
|||
'verbose_name_plural': 'Login Histories', |
|||
'ordering': ('-at_time',), |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='LocationHistory', |
|||
fields=[ |
|||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('lat', models.FloatField(blank=True, null=True, verbose_name='Latitude')), |
|||
('lon', models.FloatField(blank=True, null=True, verbose_name='Longitude')), |
|||
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='Country')), |
|||
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')), |
|||
('selected_manually', models.BooleanField(default=False, verbose_name='Selected Manually')), |
|||
('ip', models.CharField(blank=True, max_length=255, null=True, verbose_name='IP Address')), |
|||
('timezone', models.CharField(blank=True, max_length=60, null=True, verbose_name='Timezone')), |
|||
('at_time', models.DateTimeField(auto_now_add=True, verbose_name='Timestamp')), |
|||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='location_history', to=settings.AUTH_USER_MODEL, verbose_name='User')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Location History', |
|||
'verbose_name_plural': 'Location History', |
|||
'ordering': ('-at_time',), |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='ClientUser', |
|||
fields=[ |
|||
], |
|||
options={ |
|||
'verbose_name': 'Client User', |
|||
'verbose_name_plural': 'Client Users', |
|||
'ordering': ('-id',), |
|||
'proxy': True, |
|||
'indexes': [], |
|||
'constraints': [], |
|||
}, |
|||
bases=('account.user',), |
|||
), |
|||
migrations.CreateModel( |
|||
name='AdminUser', |
|||
fields=[ |
|||
], |
|||
options={ |
|||
'verbose_name': 'Admin User', |
|||
'verbose_name_plural': 'Admin Users', |
|||
'proxy': True, |
|||
'indexes': [], |
|||
'constraints': [], |
|||
}, |
|||
bases=('account.user',), |
|||
), |
|||
migrations.CreateModel( |
|||
name='SuperAdminUser', |
|||
fields=[ |
|||
], |
|||
options={ |
|||
'verbose_name': 'Super Admin User', |
|||
'verbose_name_plural': 'Super Admin Users', |
|||
'proxy': True, |
|||
'indexes': [], |
|||
'constraints': [], |
|||
}, |
|||
bases=('account.user',), |
|||
), |
|||
] |
|||
@ -0,0 +1,3 @@ |
|||
from .user import * |
|||
from .groups import * |
|||
from .notification import * |
|||
@ -0,0 +1,51 @@ |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from apps.account.models.user import User |
|||
from apps.account.manager import ClientUserManager, AdminUserManager, SuperAdminUserManager |
|||
from django.contrib.auth.models import Group |
|||
|
|||
|
|||
class ClientUser(User): |
|||
objects = ClientUserManager() |
|||
|
|||
def save(self, *args, **kwargs): |
|||
self.user_type = User.UserType.CLIENT |
|||
super().save(*args, **kwargs) |
|||
group, _ = Group.objects.get_or_create(name="Client Group") |
|||
self.groups.add(group) |
|||
|
|||
class Meta: |
|||
proxy = True |
|||
verbose_name = _('Client User') |
|||
verbose_name_plural = _('Client Users') |
|||
ordering = ('-id',) |
|||
|
|||
|
|||
class AdminUser(User): |
|||
objects = AdminUserManager() |
|||
|
|||
def save(self, *args, **kwargs): |
|||
self.user_type = User.UserType.ADMIN |
|||
self.is_staff = True |
|||
super().save(*args, **kwargs) |
|||
group, _ = Group.objects.get_or_create(name="Admin Group") |
|||
self.groups.add(group) |
|||
|
|||
class Meta: |
|||
proxy = True |
|||
verbose_name = _("Admin User") |
|||
verbose_name_plural = _("Admin Users") |
|||
|
|||
|
|||
class SuperAdminUser(User): |
|||
objects = SuperAdminUserManager() |
|||
|
|||
def save(self, *args, **kwargs): |
|||
self.user_type = User.UserType.SUPER_ADMIN |
|||
self.is_staff = True |
|||
self.is_superuser = True |
|||
super().save(*args, **kwargs) |
|||
|
|||
class Meta: |
|||
proxy = True |
|||
verbose_name = _("Super Admin User") |
|||
verbose_name_plural = _("Super Admin Users") |
|||
@ -0,0 +1,53 @@ |
|||
from django.db import models |
|||
from django.utils.translation import gettext_lazy as _ |
|||
|
|||
|
|||
class Notification(models.Model): |
|||
title = models.CharField(max_length=255, verbose_name=_('Title')) |
|||
message = models.TextField(max_length=1024, verbose_name=_('Message')) |
|||
user = models.ForeignKey( |
|||
"account.User", |
|||
on_delete=models.CASCADE, |
|||
verbose_name=_('User'), |
|||
related_name='notifications' |
|||
) |
|||
is_read = models.BooleanField(default=False, verbose_name=_('Is Read')) |
|||
notification_type = models.CharField( |
|||
max_length=50, |
|||
null=True, |
|||
blank=True, |
|||
verbose_name=_('Notification Type') |
|||
) |
|||
action = models.CharField(max_length=50, default='navigate', verbose_name=_('Action')) |
|||
navigate_to = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Navigate To')) |
|||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) |
|||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) |
|||
|
|||
class Meta: |
|||
verbose_name = _('Notification') |
|||
verbose_name_plural = _('Notifications') |
|||
ordering = ('-created_at',) |
|||
|
|||
def __str__(self): |
|||
return f"{self.user} - {self.title}" |
|||
|
|||
|
|||
class NotificationTemplate(models.Model): |
|||
notification_type = models.CharField(max_length=50, unique=True, verbose_name=_('Notification Type')) |
|||
name = models.CharField(max_length=100, verbose_name=_('Name')) |
|||
is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) |
|||
title = models.CharField(max_length=255, default="", verbose_name=_('Title')) |
|||
body = models.TextField(max_length=1024, default="", verbose_name=_('Body')) |
|||
placeholders_info = models.CharField( |
|||
max_length=255, |
|||
blank=True, |
|||
null=True, |
|||
verbose_name=_('Allowed Placeholders Description') |
|||
) |
|||
|
|||
class Meta: |
|||
verbose_name = _('Notification Template') |
|||
verbose_name_plural = _('Notification Templates') |
|||
|
|||
def __str__(self): |
|||
return f"{self.name} ({self.notification_type})" |
|||
@ -0,0 +1,238 @@ |
|||
import random |
|||
import base64 |
|||
import hashlib |
|||
from cryptography.fernet import Fernet |
|||
from django.conf import settings |
|||
from django.contrib.auth.models import AbstractUser |
|||
from django.db import models |
|||
from django.utils.text import slugify |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from django.utils import timezone |
|||
from phonenumber_field.modelfields import PhoneNumberField |
|||
from utils.validators import validate_possible_number |
|||
from apps.account.manager import UserManager |
|||
|
|||
|
|||
class User(AbstractUser): |
|||
class DeviceOs(models.TextChoices): |
|||
android = 'android', _('Android') |
|||
apple = 'apple', _('Apple iOS') |
|||
web = 'web', _('Web') |
|||
|
|||
class UserType(models.TextChoices): |
|||
CLIENT = 'client', _('Client') |
|||
ADMIN = 'admin', _('Admin') |
|||
SUPER_ADMIN = 'super_admin', _('Super Admin') |
|||
|
|||
class GenderChoices(models.TextChoices): |
|||
MALE = 'male', _('Male') |
|||
FEMALE = 'female', _('Female') |
|||
OTHER = 'other', _('Other') |
|||
|
|||
last_name = None |
|||
first_name = None |
|||
username = models.CharField( |
|||
unique=True, |
|||
null=True, |
|||
blank=True, |
|||
max_length=150, |
|||
verbose_name=_("Username"), |
|||
error_messages={'unique': _("A user with that username already exists.")} |
|||
) |
|||
email = models.EmailField( |
|||
unique=True, |
|||
verbose_name=_("Email Address"), |
|||
help_text=_("User primary email address."), |
|||
null=True, |
|||
blank=True, |
|||
error_messages={'unique': _("A user with that email already exists.")} |
|||
) |
|||
fullname = models.CharField( |
|||
max_length=255, |
|||
verbose_name=_("Full Name"), |
|||
help_text=_("Full name of the user."), |
|||
null=True, |
|||
blank=True |
|||
) |
|||
birthdate = models.DateField(verbose_name=_('Birthdate'), null=True, blank=True) |
|||
avatar = models.ImageField( |
|||
max_length=512, |
|||
null=True, |
|||
blank=True, |
|||
upload_to='users/avatars/%Y/%m/', |
|||
verbose_name=_('Avatar') |
|||
) |
|||
phone_number = PhoneNumberField( |
|||
validators=[validate_possible_number], |
|||
null=True, |
|||
blank=True, |
|||
verbose_name=_('Phone Number'), |
|||
help_text=_("e.g., +1 555 1234567") |
|||
) |
|||
language = models.CharField( |
|||
max_length=10, |
|||
default='en', |
|||
choices=settings.LANGUAGES, |
|||
null=True, |
|||
blank=True, |
|||
verbose_name=_('Language') |
|||
) |
|||
gender = models.CharField( |
|||
max_length=20, |
|||
choices=GenderChoices.choices, |
|||
null=True, |
|||
blank=True, |
|||
verbose_name=_('Gender') |
|||
) |
|||
user_type = models.CharField( |
|||
max_length=20, |
|||
choices=UserType.choices, |
|||
default=UserType.CLIENT, |
|||
verbose_name=_("User Type") |
|||
) |
|||
date_joined = models.DateTimeField( |
|||
auto_now_add=True, |
|||
verbose_name=_("Date Joined") |
|||
) |
|||
|
|||
city = models.CharField(verbose_name=_('City'), max_length=255, null=True, blank=True) |
|||
country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) |
|||
|
|||
device_id = models.CharField(verbose_name=_('Device ID'), max_length=255, null=True, blank=True) |
|||
device_os = models.CharField(choices=DeviceOs.choices, null=True, blank=True, max_length=16, verbose_name=_('Device OS')) |
|||
user_agent = models.TextField(verbose_name=_('User Agent'), null=True, blank=True) |
|||
client_ip = models.CharField(max_length=64, verbose_name=_('Client IP'), null=True, blank=True) |
|||
|
|||
fcm = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('FCM Token')) |
|||
slug = models.SlugField(max_length=255, unique=True, null=True, blank=True, verbose_name=_('Slug')) |
|||
is_staff = models.BooleanField(default=False, verbose_name=_('Is Staff')) |
|||
is_active = models.BooleanField( |
|||
default=True, |
|||
verbose_name=_("Active"), |
|||
help_text=_("Designates whether this user should be treated as active.") |
|||
) |
|||
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name=_('Deleted At')) |
|||
info = models.TextField(verbose_name=_("Bio / Info"), null=True, blank=True) |
|||
skill = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('Skill / Role')) |
|||
password_enc = models.CharField( |
|||
max_length=512, |
|||
blank=True, |
|||
default='', |
|||
verbose_name=_("Encrypted Password"), |
|||
help_text=_("Encrypted copy of password for displaying in the admin panel if required.") |
|||
) |
|||
|
|||
objects = UserManager() |
|||
|
|||
EMAIL_FIELD = "email" |
|||
USERNAME_FIELD = "email" |
|||
REQUIRED_FIELDS = [] |
|||
|
|||
def __str__(self): |
|||
return self.email or self.fullname or self.username or f"User #{self.id}" |
|||
|
|||
def soft_delete(self): |
|||
self.deleted_at = timezone.now() |
|||
self.is_active = False |
|||
self.fullname = f'{self.fullname}:deleted' if self.fullname else 'deleted_user' |
|||
number = str(random.randint(1000000000, 9999999999)) |
|||
if self.phone_number: |
|||
self.phone_number = f'{self.phone_number}:deleted{number}' |
|||
if self.email: |
|||
self.email = f'{self.email}:deleted{number}' |
|||
if self.device_id: |
|||
self.device_id = f'{self.device_id}:deleted{number}' |
|||
self.save() |
|||
|
|||
def save(self, *args, **kwargs): |
|||
if not self.username and self.email: |
|||
self.username = self.email |
|||
elif self.email and User.objects.filter(username=self.email).exclude(pk=self.pk).exists(): |
|||
self.username = f'{self.email}:{self.id or random.randint(1000, 9999)}' |
|||
|
|||
return super().save(*args, **kwargs) |
|||
|
|||
def get_full_name(self): |
|||
return self.fullname or self.email or "" |
|||
|
|||
@property |
|||
def is_guest(self): |
|||
return self.email is None |
|||
|
|||
def has_role(self, role_name): |
|||
return self.groups.filter(name__iexact=f"{role_name} Group").exists() or self.user_type == role_name |
|||
|
|||
def _get_fernet(self): |
|||
key_bytes = settings.SECRET_KEY.encode('utf-8') |
|||
hashed_key = hashlib.sha256(key_bytes).digest() |
|||
fernet_key = base64.urlsafe_b64encode(hashed_key) |
|||
return Fernet(fernet_key) |
|||
|
|||
def set_plain_password(self, plain_password): |
|||
if not plain_password: |
|||
self.password_enc = '' |
|||
return |
|||
try: |
|||
password_bytes = plain_password.encode('utf-8') |
|||
fernet = self._get_fernet() |
|||
encrypted_token = fernet.encrypt(password_bytes) |
|||
self.password_enc = encrypted_token.decode('utf-8') |
|||
except Exception: |
|||
self.password_enc = '' |
|||
|
|||
def get_plain_password(self): |
|||
if not self.password_enc: |
|||
return '' |
|||
try: |
|||
encrypted_bytes = self.password_enc.encode('utf-8') |
|||
fernet = self._get_fernet() |
|||
decrypted_bytes = fernet.decrypt(encrypted_bytes) |
|||
return decrypted_bytes.decode('utf-8') |
|||
except Exception: |
|||
return '' |
|||
|
|||
class Meta: |
|||
ordering = ("-id",) |
|||
verbose_name = _("User") |
|||
verbose_name_plural = _("Users") |
|||
|
|||
|
|||
class LoginHistory(models.Model): |
|||
user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='login_history', verbose_name=_('User')) |
|||
lat = models.FloatField(verbose_name=_('Latitude'), null=True, blank=True) |
|||
lon = models.FloatField(verbose_name=_('Longitude'), null=True, blank=True) |
|||
country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) |
|||
city = models.CharField(max_length=255, verbose_name=_('City'), null=True, blank=True) |
|||
ip = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('IP Address')) |
|||
timezone = models.CharField(max_length=100, null=True, blank=True, verbose_name=_('Timezone')) |
|||
user_agent = models.TextField(verbose_name=_('User Agent'), null=True, blank=True) |
|||
device_os = models.CharField(max_length=16, null=True, blank=True, verbose_name=_('Device OS')) |
|||
at_time = models.DateTimeField(auto_now_add=True, verbose_name=_('Timestamp')) |
|||
|
|||
class Meta: |
|||
verbose_name = _('Login History') |
|||
verbose_name_plural = _('Login Histories') |
|||
ordering = ('-at_time',) |
|||
|
|||
def __str__(self): |
|||
return f"{self.user} - {self.ip} ({self.at_time})" |
|||
|
|||
|
|||
class LocationHistory(models.Model): |
|||
user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='location_history', verbose_name=_('User')) |
|||
lat = models.FloatField(verbose_name=_('Latitude'), null=True, blank=True) |
|||
lon = models.FloatField(verbose_name=_('Longitude'), null=True, blank=True) |
|||
country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True) |
|||
city = models.CharField(max_length=255, verbose_name=_('City'), null=True, blank=True) |
|||
selected_manually = models.BooleanField(default=False, verbose_name=_('Selected Manually')) |
|||
ip = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('IP Address')) |
|||
timezone = models.CharField(null=True, blank=True, max_length=60, verbose_name=_('Timezone')) |
|||
at_time = models.DateTimeField(auto_now_add=True, verbose_name=_('Timestamp')) |
|||
|
|||
class Meta: |
|||
verbose_name = _('Location History') |
|||
verbose_name_plural = _('Location History') |
|||
ordering = ('-at_time',) |
|||
|
|||
def __str__(self): |
|||
return f"{self.user} - {self.city}, {self.country}" |
|||
@ -0,0 +1,119 @@ |
|||
import asyncio |
|||
import logging |
|||
import string |
|||
from apps.account.models import Notification, NotificationTemplate |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
class SafeFormatter(string.Formatter): |
|||
def get_value(self, key, args, kwargs): |
|||
if isinstance(key, str): |
|||
if key not in kwargs: |
|||
return f"{{{key}}}" |
|||
return kwargs[key] |
|||
return super().get_value(key, args, kwargs) |
|||
|
|||
def get_template_context(user, notification_type, data): |
|||
""" |
|||
Builds a dynamic context dictionary containing user info to format templates. |
|||
""" |
|||
context = { |
|||
'student_name': getattr(user, 'fullname', user.username) or user.username, |
|||
'fullname': getattr(user, 'fullname', user.username) or user.username, |
|||
'username': user.username, |
|||
} |
|||
return context |
|||
|
|||
def resolve_notification_route(notification_type, data): |
|||
""" |
|||
Resolves standard GoRouter paths based on notification type and payload data. |
|||
""" |
|||
return None |
|||
|
|||
def create_and_send_notification(user, title_en, body_en, title_fa=None, body_fa=None, service='imam-javad', |
|||
data=None, notification_type=None, action='navigate', navigate_to=None, |
|||
title_ru=None, body_ru=None): |
|||
""" |
|||
Creates a Notification record in the database and sends a push notification to FCM. |
|||
Loads and renders the NotificationTemplate from the database if present. |
|||
If the template is inactive, the notification is discarded. |
|||
""" |
|||
# Map Persian arguments to Russian to preserve backward compatibility with existing signals/tasks |
|||
title_ru = title_ru or title_fa |
|||
body_ru = body_ru or body_fa |
|||
|
|||
# Auto-resolve notification type from data payload if not provided |
|||
if not notification_type and isinstance(data, dict): |
|||
notification_type = data.get('type') |
|||
|
|||
# 1. Check for template settings in the database |
|||
if notification_type: |
|||
template = NotificationTemplate.objects.filter(notification_type=notification_type).first() |
|||
if template: |
|||
if not template.is_active: |
|||
logger.info(f"Notification type '{notification_type}' is disabled via database template settings.") |
|||
return None |
|||
# Override templates with database values |
|||
title_ru = template.title_ru |
|||
title_en = template.title_en |
|||
body_ru = template.body_ru |
|||
body_en = template.body_en |
|||
|
|||
# 2. Render templates safely with dynamic context |
|||
context = get_template_context(user, notification_type, data) |
|||
formatter = SafeFormatter() |
|||
|
|||
try: |
|||
title_ru = formatter.format(title_ru, **context) if title_ru else "" |
|||
title_en = formatter.format(title_en, **context) if title_en else "" |
|||
body_ru = formatter.format(body_ru, **context) if body_ru else "" |
|||
body_en = formatter.format(body_en, **context) if body_en else "" |
|||
except Exception as e: |
|||
logger.error(f"Error formatting templates: {e}") |
|||
|
|||
# Determine user localized text |
|||
lang = getattr(user, 'language', None) |
|||
lang_code = lang.code if lang and hasattr(lang, 'code') else 'ru' |
|||
|
|||
title = title_ru if lang_code == 'ru' else title_en |
|||
message = body_ru if lang_code == 'ru' else body_en |
|||
|
|||
# Auto-resolve GoRouter navigation route if not provided |
|||
if not navigate_to and notification_type: |
|||
navigate_to = resolve_notification_route(notification_type, data) |
|||
|
|||
# Save to database |
|||
from apps.account.tasks import send_notification |
|||
notif = Notification.objects.create( |
|||
user=user, |
|||
title=title, |
|||
message=message, |
|||
service=service, |
|||
notification_type=notification_type, |
|||
action=action, |
|||
navigate_to=navigate_to |
|||
) |
|||
|
|||
fcm_token = getattr(user, 'fcm', None) |
|||
if fcm_token: |
|||
# Prepare the payload including the new routing properties |
|||
fcm_data = dict(data or {}) |
|||
fcm_data.setdefault('type', notification_type or '') |
|||
fcm_data.setdefault('action', action or '') |
|||
fcm_data.setdefault('navigate_to', navigate_to or '') |
|||
|
|||
try: |
|||
try: |
|||
loop = asyncio.get_event_loop() |
|||
except RuntimeError: |
|||
loop = asyncio.new_event_loop() |
|||
asyncio.set_event_loop(loop) |
|||
|
|||
if loop.is_running(): |
|||
loop.create_task(send_notification([fcm_token], title, message, fcm_data)) |
|||
else: |
|||
loop.run_until_complete(send_notification([fcm_token], title, message, fcm_data)) |
|||
except Exception as e: |
|||
logger.error(f"Failed to send push notification via FCM: {e}") |
|||
|
|||
return notif |
|||
@ -0,0 +1,56 @@ |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
from rest_framework.permissions import BasePermission, SAFE_METHODS |
|||
|
|||
|
|||
class IsActiveUser(BasePermission): |
|||
|
|||
def has_permission(self, request, view): |
|||
return request.user and request.user.is_active |
|||
|
|||
|
|||
class IsSuperAdmin(BasePermission): |
|||
""" |
|||
Allows access to super admins and admins with full panel privileges. |
|||
""" |
|||
def has_permission(self, request, view): |
|||
return ( |
|||
request.user and |
|||
request.user.is_authenticated and |
|||
(request.user.is_super_admin_panel_user() or request.user.is_admin_panel_user()) |
|||
) |
|||
|
|||
|
|||
class IsPanelUser(BasePermission): |
|||
""" |
|||
Allows access to super admins, admins, and professors. |
|||
""" |
|||
def has_permission(self, request, view): |
|||
return ( |
|||
request.user and |
|||
request.user.is_authenticated and |
|||
request.user.can_access_admin_panel() |
|||
) |
|||
|
|||
|
|||
class IsSuperAdminOrReadOnlyForProfessor(BasePermission): |
|||
""" |
|||
Allows full read-write access to super admins and admins, |
|||
but only read-only (GET, HEAD, OPTIONS) access to professors. |
|||
""" |
|||
def has_permission(self, request, view): |
|||
if not request.user or not request.user.is_authenticated or not request.user.is_active: |
|||
return False |
|||
|
|||
# Super admin / admin can do everything |
|||
if request.user.is_super_admin_panel_user() or request.user.is_admin_panel_user(): |
|||
return True |
|||
|
|||
# Professor has read-only access |
|||
if request.user.is_professor_panel_user(): |
|||
return request.method in SAFE_METHODS |
|||
|
|||
return False |
|||
@ -0,0 +1,4 @@ |
|||
from .user import * |
|||
from .notification import * |
|||
from .auth import * |
|||
from .location_history import * |
|||
@ -0,0 +1,11 @@ |
|||
from rest_framework import serializers |
|||
|
|||
|
|||
class ExchangeTokenSerializer(serializers.Serializer): |
|||
temp_token = serializers.CharField(max_length=128) |
|||
|
|||
def validate_temp_token(self, value: str) -> str: |
|||
value = value.strip() |
|||
if not value: |
|||
raise serializers.ValidationError("temp_token is required.") |
|||
return value |
|||
@ -0,0 +1,37 @@ |
|||
from rest_framework import serializers |
|||
|
|||
from apps.account.models import LocationHistory |
|||
|
|||
|
|||
class LocationHistorySerializer(serializers.ModelSerializer): |
|||
user = serializers.HiddenField(default=serializers.CurrentUserDefault()) |
|||
class Meta: |
|||
model = LocationHistory |
|||
exclude = ('at_time',) |
|||
|
|||
class ReverseGeolocationSerializer(serializers.Serializer): |
|||
"""Serializer for reverse geolocation request query parameters""" |
|||
lat = serializers.FloatField( |
|||
required=True, |
|||
min_value=-90.0, |
|||
max_value=90.0, |
|||
help_text="Latitude coordinate (-90 to 90)" |
|||
) |
|||
lon = serializers.FloatField( |
|||
required=True, |
|||
min_value=-180.0, |
|||
max_value=180.0, |
|||
help_text="Longitude coordinate (-180 to 180)" |
|||
) |
|||
|
|||
|
|||
class ReverseGeolocationResponseSerializer(serializers.Serializer): |
|||
"""Serializer for reverse geolocation response""" |
|||
latitude = serializers.FloatField(read_only=True) |
|||
longitude = serializers.FloatField(read_only=True) |
|||
city = serializers.CharField(max_length=100, allow_null=True, read_only=True) |
|||
country = serializers.CharField(max_length=100, allow_null=True, read_only=True) |
|||
country_code = serializers.CharField(max_length=10, allow_null=True, read_only=True) |
|||
accuracy_radius = serializers.IntegerField(allow_null=True, read_only=True, required=False) |
|||
time_zone = serializers.CharField(max_length=100, allow_null=True, allow_blank=True, read_only=True, required=False) |
|||
postal_code = serializers.CharField(max_length=20, allow_null=True, allow_blank=True, read_only=True, required=False) |
|||
@ -0,0 +1,36 @@ |
|||
from rest_framework import serializers |
|||
from apps.account.models import Notification, NotificationTemplate |
|||
from apps.account.models import User |
|||
|
|||
|
|||
class NotificationSerializer(serializers.ModelSerializer): |
|||
class Meta: |
|||
model = Notification |
|||
fields = ['id', 'title', 'message', 'is_read', 'notification_type', 'action', 'navigate_to', 'created_at', 'updated_at'] |
|||
|
|||
|
|||
class NotificationSendSerializer(serializers.Serializer): |
|||
title = serializers.CharField() |
|||
body = serializers.CharField() |
|||
data = serializers.DictField(required=False) |
|||
user_id = serializers.IntegerField(required=True) |
|||
|
|||
|
|||
class AdminNotificationSerializer(serializers.ModelSerializer): |
|||
user_fullname = serializers.CharField(source='user.fullname', read_only=True) |
|||
user_email = serializers.CharField(source='user.email', read_only=True) |
|||
user_id = serializers.IntegerField(source='user.id', read_only=True) |
|||
|
|||
class Meta: |
|||
model = Notification |
|||
fields = [ |
|||
'id', 'title', 'message', 'is_read', 'notification_type', |
|||
'action', 'navigate_to', 'created_at', 'updated_at', |
|||
'user_fullname', 'user_email', 'user_id' |
|||
] |
|||
|
|||
|
|||
class NotificationTemplateSerializer(serializers.ModelSerializer): |
|||
class Meta: |
|||
model = NotificationTemplate |
|||
fields = ['id', 'notification_type', 'name', 'is_active', 'title', 'body', 'placeholders_info'] |
|||
@ -0,0 +1,377 @@ |
|||
from rest_framework import serializers |
|||
from rest_framework.authtoken.models import Token |
|||
from django.contrib.auth.models import Group |
|||
from django.contrib.auth.password_validation import validate_password |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from apps.account.models import User |
|||
from utils import FileFieldSerializer, absolute_url |
|||
from utils.validators import validate_type_code |
|||
|
|||
|
|||
|
|||
class UserProfileSerializer(serializers.ModelSerializer): |
|||
avatar = FileFieldSerializer(required=False, allow_null=True) |
|||
password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) |
|||
fullname = serializers.CharField(required=False) |
|||
gender = serializers.ChoiceField( |
|||
choices=User.GenderChoices.choices, |
|||
required=False, |
|||
help_text="Select the user's gender." |
|||
) |
|||
fcm = serializers.CharField(required=False, help_text="Firebase Cloud Messaging token.") |
|||
saved_location = serializers.SerializerMethodField() |
|||
|
|||
class Meta: |
|||
model = User |
|||
fields = ['id', 'device_id', 'fcm', 'fullname', 'slug', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender', 'saved_location'] |
|||
read_only_fields = ['email', 'info', 'skill', 'device_id', 'slug', 'saved_location'] |
|||
|
|||
def get_saved_location(self, obj): |
|||
# Check if user is authenticated and has location_history attribute |
|||
if not obj.is_authenticated or not hasattr(obj, 'location_history'): |
|||
return None |
|||
|
|||
last_location = obj.location_history.order_by('-at_time').first() |
|||
if last_location: |
|||
return { |
|||
'lat': last_location.lat, |
|||
'lon': last_location.lon, |
|||
'city': last_location.city, |
|||
'country': last_location.country, |
|||
'timezone': last_location.timezone, |
|||
'selected_manually': last_location.selected_manually, |
|||
'at_time': last_location.at_time, |
|||
} |
|||
return None |
|||
|
|||
# def validate_email(self, value): |
|||
# if User.objects.filter(email=value).exists(): |
|||
# raise serializers.ValidationError("This email is already registered.") |
|||
# return value |
|||
|
|||
def update(self, instance, validated_data): |
|||
# Pop the password from the data to handle it separately |
|||
password = validated_data.pop('password', None) |
|||
|
|||
# Use the default update logic for all other fields |
|||
for attr, value in validated_data.items(): |
|||
if value is not None: |
|||
setattr(instance, attr, value) |
|||
|
|||
# If a new password was provided, hash and set it correctly |
|||
if password: |
|||
instance.set_password(password) |
|||
|
|||
instance.save() |
|||
return instance |
|||
|
|||
|
|||
class UserRegisterSerializer(serializers.ModelSerializer): |
|||
password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) |
|||
fcm = serializers.CharField(required=False, allow_blank=True, allow_null=True) |
|||
device_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, write_only=True) |
|||
email = serializers.EmailField() |
|||
|
|||
class Meta: |
|||
model = User |
|||
fields = ['id', 'fullname', 'email', 'password', 'fcm', 'device_id'] |
|||
extra_kwargs = { |
|||
'fullname': {'required': True}, |
|||
'email': {'required': True}, |
|||
} |
|||
|
|||
def create(self, validated_data): |
|||
device_id = validated_data.pop('device_id', None) |
|||
password = validated_data.pop('password', None) |
|||
user = super().create(validated_data) |
|||
if password: |
|||
user.set_password(password) |
|||
if device_id: |
|||
user.device_id = device_id |
|||
user.save() |
|||
return user |
|||
|
|||
def validate_email(self, value): |
|||
normalized_email = User.objects.normalize_email(value) |
|||
if User.objects.filter(email=normalized_email).exists(): |
|||
raise serializers.ValidationError("This email is already registered.") |
|||
return normalized_email |
|||
|
|||
|
|||
|
|||
class UserVerifySerializer(serializers.Serializer): |
|||
code = serializers.CharField(max_length=6, validators=[validate_type_code]) |
|||
email = serializers.EmailField() |
|||
device_id = serializers.CharField(max_length=255, required=False) |
|||
|
|||
def validate_email(self, value): |
|||
""" |
|||
Normalize the email to ensure the Redis key matches correctly. |
|||
""" |
|||
return User.objects.normalize_email(value) |
|||
|
|||
|
|||
class UserLoginSerializer(serializers.Serializer): |
|||
password = serializers.CharField(write_only=True) |
|||
token = serializers.CharField(allow_null=True, read_only=True, required=False) |
|||
fullname = serializers.CharField(allow_null=True, read_only=True, required=False) |
|||
avatar = serializers.CharField(allow_null=True, read_only=True, required=False) |
|||
email = serializers.EmailField(write_only=True) |
|||
password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False) |
|||
fcm = serializers.CharField(required=False) |
|||
device_id = serializers.CharField(required=False) |
|||
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
|||
|
|||
def validate(self, data): |
|||
# Custom validation logic can be added here if needed |
|||
# data.pop('fcm', None) |
|||
# data.pop('device_id', None) |
|||
return data |
|||
def validate_email(self, value): |
|||
""" |
|||
Normalize email for case-insensitive login. |
|||
""" |
|||
return User.objects.normalize_email(value) |
|||
|
|||
|
|||
# class UserLoginSerializer(serializers.Serializer): |
|||
# password = serializers.CharField(write_only=True) |
|||
# token = serializers.CharField(allow_null=True, read_only=True, required=False) |
|||
# fullname = serializers.CharField(allow_null=True, read_only=True, required=False) |
|||
# avatar = serializers.CharField(allow_null=True, read_only=True, required=False) |
|||
# email = serializers.EmailField(write_only=True) |
|||
# password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False) |
|||
# fcm = serializers.CharField(required=False) |
|||
# device_id = serializers.CharField(required=False) |
|||
# timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
|||
|
|||
|
|||
|
|||
|
|||
# class UserRecoverPasswordSerializer(serializers.ModelSerializer): |
|||
# email = serializers.EmailField() |
|||
|
|||
# class Meta: |
|||
# model = User |
|||
# fields = ['email',] |
|||
# extra_kwargs = { |
|||
# 'email': {'required': True,}, |
|||
# } |
|||
|
|||
class UserRecoverPasswordSerializer(serializers.Serializer): |
|||
""" |
|||
Validates that an email is provided and is in a valid format |
|||
without checking for database uniqueness. |
|||
""" |
|||
email = serializers.EmailField(required=True) |
|||
|
|||
def validate_email(self, value): |
|||
""" |
|||
Normalize the email address to ensure case-insensitive lookups. |
|||
""" |
|||
return User.objects.normalize_email(value) |
|||
|
|||
class UserResetPasswordSerializer(serializers.ModelSerializer): |
|||
password = serializers.CharField(write_only=True) |
|||
|
|||
class Meta: |
|||
model = User |
|||
fields = ['password', ] |
|||
extra_kwargs = { |
|||
'password': {'required': True,}, |
|||
} |
|||
|
|||
|
|||
|
|||
|
|||
class UserGuestSerializer(serializers.ModelSerializer): |
|||
lat = serializers.CharField(max_length=255, allow_null=True, allow_blank=True, required=False) |
|||
lon = serializers.CharField(max_length=255, allow_null=True, allow_blank=True, required=False) |
|||
fcm = serializers.CharField(required=False) |
|||
device_id = serializers.CharField(required=False) |
|||
device_os = serializers.ChoiceField(choices=User.DeviceOs.choices, required=False) |
|||
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
|||
|
|||
class Meta: |
|||
model = User |
|||
fields = ['device_id', 'fcm', 'device_os', 'lat', 'lon', 'timezone'] |
|||
|
|||
def validate(self, data): |
|||
# Make sure at least device_id is provided |
|||
if not data.get('device_id'): |
|||
raise serializers.ValidationError({"device_id": "Device ID is required for guest users."}) |
|||
|
|||
return data |
|||
|
|||
class WebUserGuestSerializer(serializers.ModelSerializer): |
|||
|
|||
user_agent = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
|||
client_ip = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
|||
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) |
|||
|
|||
class Meta: |
|||
model = User |
|||
fields = ['user_agent', 'client_ip', 'timezone', 'device_id', 'device_os'] |
|||
|
|||
def validate(self, data): |
|||
# Ensure device_id is provided (generated by view) |
|||
if not data.get('device_id'): |
|||
raise serializers.ValidationError({"device_id": "Device ID is required for web guest users."}) |
|||
return data |
|||
|
|||
|
|||
|
|||
class UserFCMSerializer(serializers.ModelSerializer): |
|||
class Meta: |
|||
model = User |
|||
fields = ['fcm'] |
|||
|
|||
|
|||
class AdminUserSerializer(serializers.ModelSerializer): |
|||
avatar = FileFieldSerializer(required=False, allow_null=True) |
|||
password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) |
|||
email = serializers.EmailField(required=True) |
|||
auth_token = serializers.SerializerMethodField() |
|||
plain_password = serializers.SerializerMethodField() |
|||
client_ip = serializers.SerializerMethodField() |
|||
device_os = serializers.SerializerMethodField() |
|||
user_agent = serializers.SerializerMethodField() |
|||
device_id = serializers.SerializerMethodField() |
|||
|
|||
def get_auth_token(self, obj): |
|||
token = Token.objects.filter(user=obj).first() |
|||
return token.key if token else None |
|||
|
|||
def get_plain_password(self, obj): |
|||
request = self.context.get('request') |
|||
if request and request.user and (request.user.is_superuser or request.user.user_type in ['super_admin', 'admin']): |
|||
return obj.get_plain_password() |
|||
return None |
|||
|
|||
def get_client_ip(self, obj): |
|||
if obj.client_ip: |
|||
return obj.client_ip |
|||
history = obj.login_history.filter(ip__isnull=False).exclude(ip='').order_by('-at_time', '-id').first() |
|||
if history and history.ip: |
|||
return history.ip |
|||
loc_history = obj.location_history.filter(ip__isnull=False).exclude(ip='').order_by('-at_time', '-id').first() |
|||
if loc_history and loc_history.ip: |
|||
return loc_history.ip |
|||
return None |
|||
|
|||
def get_device_os(self, obj): |
|||
if obj.device_os: |
|||
return obj.device_os |
|||
history = obj.login_history.filter(device_os__isnull=False).exclude(device_os='').order_by('-at_time', '-id').first() |
|||
if history and history.device_os: |
|||
return history.device_os |
|||
return None |
|||
|
|||
def get_user_agent(self, obj): |
|||
if obj.user_agent: |
|||
return obj.user_agent |
|||
history = obj.login_history.filter(user_agent__isnull=False).exclude(user_agent='').order_by('-at_time', '-id').first() |
|||
if history and history.user_agent: |
|||
return history.user_agent |
|||
return None |
|||
|
|||
def get_device_id(self, obj): |
|||
return obj.device_id or None |
|||
|
|||
class Meta: |
|||
model = User |
|||
fields = [ |
|||
'id', 'fullname', 'email', 'phone_number', 'password', 'avatar', |
|||
'gender', 'birthdate', 'info', 'skill', 'city', 'country', |
|||
'device_id', 'device_os', 'user_agent', 'client_ip', 'fcm', |
|||
'user_type', 'is_active', 'is_staff', 'is_superuser', |
|||
'date_joined', 'last_login', 'auth_token', 'plain_password' |
|||
] |
|||
read_only_fields = ['id', 'date_joined', 'last_login', 'auth_token', 'device_id', 'device_os', 'user_agent', 'client_ip'] |
|||
|
|||
ROLE_GROUP_MAP = { |
|||
User.UserType.STUDENT: "Student Group", |
|||
User.UserType.PROFESSOR: "Professor Group", |
|||
User.UserType.ADMIN: "Admin Group", |
|||
User.UserType.SUPER_ADMIN: "Super Admin Group", |
|||
User.UserType.CONSULTANT: "Consultant Group", |
|||
} |
|||
PANEL_GROUPS = {"Professor Group", "Admin Group", "Super admin Group", "Super Admin Group"} |
|||
|
|||
def _ensure_group(self, instance, group_name): |
|||
group, _ = Group.objects.get_or_create(name=group_name) |
|||
instance.groups.add(group) |
|||
|
|||
def _remove_groups(self, instance, group_names): |
|||
if not group_names: |
|||
return |
|||
instance.groups.remove(*Group.objects.filter(name__in=group_names)) |
|||
|
|||
def _normalize_role_access(self, instance, requested_user_type): |
|||
if not requested_user_type: |
|||
return |
|||
|
|||
requested_user_type = str(requested_user_type) |
|||
|
|||
if requested_user_type == User.UserType.SUPER_ADMIN: |
|||
instance.is_staff = True |
|||
instance.is_superuser = True |
|||
self._ensure_group(instance, "Super Admin Group") |
|||
self._remove_groups(instance, {"Professor Group", "Admin Group", "Super admin Group"}) |
|||
return |
|||
|
|||
if requested_user_type == User.UserType.PROFESSOR: |
|||
instance.is_staff = False |
|||
instance.is_superuser = False |
|||
self._ensure_group(instance, "Professor Group") |
|||
self._remove_groups(instance, {"Admin Group", "Super admin Group", "Super Admin Group"}) |
|||
return |
|||
|
|||
if requested_user_type == User.UserType.ADMIN: |
|||
instance.is_staff = False |
|||
instance.is_superuser = False |
|||
self._ensure_group(instance, "Admin Group") |
|||
self._remove_groups(instance, {"Professor Group", "Super admin Group", "Super Admin Group"}) |
|||
return |
|||
|
|||
if requested_user_type == User.UserType.STUDENT: |
|||
instance.is_staff = False |
|||
instance.is_superuser = False |
|||
self._ensure_group(instance, "Student Group") |
|||
self._remove_groups(instance, self.PANEL_GROUPS) |
|||
return |
|||
|
|||
if requested_user_type == User.UserType.CONSULTANT: |
|||
instance.is_staff = False |
|||
instance.is_superuser = False |
|||
self._ensure_group(instance, "Consultant Group") |
|||
self._remove_groups(instance, self.PANEL_GROUPS) |
|||
return |
|||
|
|||
if requested_user_type == User.UserType.CLIENT: |
|||
instance.is_staff = False |
|||
instance.is_superuser = False |
|||
self._remove_groups(instance, self.PANEL_GROUPS) |
|||
|
|||
def create(self, validated_data): |
|||
password = validated_data.pop('password', None) |
|||
user = User(**validated_data) |
|||
if password: |
|||
user.set_password(password) |
|||
user.set_plain_password(password) |
|||
else: |
|||
user.set_unusable_password() |
|||
user.save() |
|||
self._normalize_role_access(user, validated_data.get('user_type', user.user_type)) |
|||
user.save() |
|||
return user |
|||
|
|||
def update(self, instance, validated_data): |
|||
password = validated_data.pop('password', None) |
|||
user = super().update(instance, validated_data) |
|||
self._normalize_role_access(user, validated_data.get('user_type', user.user_type)) |
|||
if password: |
|||
user.set_password(password) |
|||
user.set_plain_password(password) |
|||
user.save() |
|||
return user |
|||
@ -0,0 +1,29 @@ |
|||
from rest_framework import serializers |
|||
from django.contrib.auth.password_validation import validate_password |
|||
from apps.account.models import User |
|||
|
|||
|
|||
class WebUserRegisterSerializer(serializers.ModelSerializer): |
|||
password = serializers.CharField(write_only=True, validators=[validate_password]) |
|||
fcm = serializers.CharField(required=False, allow_blank=True, allow_null=True) |
|||
email = serializers.EmailField() |
|||
|
|||
class Meta: |
|||
model = User |
|||
fields = ['id', 'fullname', 'email', 'password', 'fcm'] |
|||
extra_kwargs = { |
|||
'fullname': {'required': True}, |
|||
'email': {'required': True}, |
|||
} |
|||
|
|||
def validate_email(self, value): |
|||
normalized_email = User.objects.normalize_email(value) |
|||
if User.objects.filter(email=normalized_email).exists(): |
|||
raise serializers.ValidationError("This email is already registered.") |
|||
return normalized_email |
|||
|
|||
def create(self, validated_data): |
|||
user = super().create(validated_data) |
|||
return user |
|||
|
|||
|
|||
@ -0,0 +1,39 @@ |
|||
import logging |
|||
from celery import shared_task |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
@shared_task(name="send_notification_task") |
|||
def send_notification_task(user_id: int, title: str, body: str, data: dict = None): |
|||
""" |
|||
Celery background task to dispatch notifications. |
|||
Can be connected to Firebase Cloud Messaging (FCM), APNS, or email. |
|||
""" |
|||
try: |
|||
from apps.account.models import User, Notification |
|||
user = User.objects.get(id=user_id) |
|||
|
|||
# Save in-app notification record |
|||
Notification.objects.create( |
|||
user=user, |
|||
title=title, |
|||
message=body, |
|||
notification_type=data.get('type') if data else 'general', |
|||
action=data.get('action', 'navigate') if data else 'navigate', |
|||
navigate_to=data.get('navigate_to', '') if data else '', |
|||
) |
|||
logger.info(f"Notification recorded for user #{user_id}: {title}") |
|||
return True |
|||
except Exception as e: |
|||
logger.error(f"Error sending notification to user #{user_id}: {e}") |
|||
return False |
|||
|
|||
|
|||
@shared_task(name="cleanup_expired_tokens_task") |
|||
def cleanup_expired_tokens_task(): |
|||
""" |
|||
Periodic task to clean up old or expired auth tokens/sessions. |
|||
""" |
|||
logger.info("Executing cleanup_expired_tokens_task...") |
|||
return True |
|||
@ -0,0 +1,40 @@ |
|||
|
|||
{% load unfold i18n %} |
|||
|
|||
<div class="border border-base-300 border-dashed mb-4 p-3 rounded dark:border-base-700"> |
|||
{% trans "Driver before template" %} |
|||
</div> |
|||
|
|||
<div class="grid gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4 "> |
|||
{% component "unfold/components/card.html" %} |
|||
{% component "unfold/components/text.html" %} |
|||
{% trans "Active drivers" %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/title.html" with component_class="DriverActiveComponent" %}{% endcomponent %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/card.html" %} |
|||
{% component "unfold/components/text.html" %} |
|||
{% trans "Inactive drivers" %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/title.html" with component_class="DriverInactiveComponent" %}{% endcomponent %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/card.html" %} |
|||
{% component "unfold/components/text.html" %} |
|||
{% trans "Total points" %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/title.html" with component_class="DriverTotalPointsComponent" %}{% endcomponent %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/card.html" %} |
|||
{% component "unfold/components/text.html" %} |
|||
{% trans "Total races" %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/title.html" with component_class="DriverRacesComponent" %}{% endcomponent %} |
|||
{% endcomponent %} |
|||
</div> |
|||
@ -0,0 +1,839 @@ |
|||
{% load i18n %} |
|||
<div class="json-editor-container"> |
|||
<textarea style="display: none" name="{{ widget.name }}" id="{{ widget.attrs.id }}" {% |
|||
include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</textarea> |
|||
<div class="json-view-editor" id='date-view-editor-{{ widget.attrs.id }}'></div> |
|||
</div> |
|||
|
|||
<script defer="defer"> |
|||
document.addEventListener('DOMContentLoaded', function () { |
|||
function initJsonEditor() { |
|||
let editor_ = document.getElementById("{{ widget.attrs.id }}"); |
|||
if (!editor_) { |
|||
console.error("Editor element not found"); |
|||
return; |
|||
} |
|||
|
|||
let startValue; |
|||
try { |
|||
startValue = editor_.value && editor_.value.trim() !== '' ? JSON.parse(editor_.value) : []; |
|||
} catch (e) { |
|||
console.error("Error parsing JSON value:", e); |
|||
startValue = []; |
|||
} |
|||
|
|||
let jsonViewerDiv = document.getElementById('date-view-editor-{{ widget.attrs.id }}'); |
|||
|
|||
if (typeof JSONEditor === 'undefined') { |
|||
console.error("JSONEditor is not defined. Make sure the library is loaded."); |
|||
return; |
|||
} |
|||
|
|||
// Custom template for add button |
|||
JSONEditor.defaults.templates.button = function (text, icon, title) { |
|||
let el = document.createElement('button'); |
|||
el.type = 'button'; |
|||
el.classList.add('json-editor-btn-modern'); |
|||
|
|||
if (icon) { |
|||
let iconEl = document.createElement('span'); |
|||
iconEl.classList.add('json-editor-btn-icon'); |
|||
iconEl.innerHTML = icon; |
|||
el.appendChild(iconEl); |
|||
} |
|||
|
|||
if (text) { |
|||
let textEl = document.createElement('span'); |
|||
textEl.classList.add('json-editor-btn-text'); |
|||
textEl.textContent = text; |
|||
el.appendChild(textEl); |
|||
} |
|||
|
|||
if (title) el.title = title; |
|||
|
|||
return el; |
|||
}; |
|||
|
|||
// Custom icons |
|||
JSONEditor.defaults.iconlib = { |
|||
getIcon: function (key) { |
|||
switch (key) { |
|||
case 'add': |
|||
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/></svg>'; |
|||
case 'delete': |
|||
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>'; |
|||
case 'edit': |
|||
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>'; |
|||
case 'moveup': |
|||
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>'; |
|||
case 'movedown': |
|||
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>'; |
|||
default: |
|||
return ''; |
|||
} |
|||
} |
|||
}; |
|||
|
|||
try { |
|||
let jsonEditor = new JSONEditor( |
|||
jsonViewerDiv, { |
|||
theme: 'bootstrap4', |
|||
schema: {{ widget.attrs.schema | safe }}, |
|||
disable_edit_json: true, |
|||
disable_properties: true, |
|||
disable_array_delete_all_rows: false, |
|||
disable_array_delete_last_row: true, // Disable delete last row button |
|||
disable_array_reorder: true, // Disable array reordering |
|||
grid_columns: 12, |
|||
prompt_before_delete: true, |
|||
disable_collapse: false, // Enable collapse to show button sections |
|||
show_errors: 'always', |
|||
startval: startValue, |
|||
iconlib: 'custom', |
|||
object_layout: 'normal', // Changed from grid to normal for better layout |
|||
enable_array_copy: false, // Disable copy functionality |
|||
show_opt_in: false, |
|||
compact: false, |
|||
array_controls_top: false, // Move array controls to bottom |
|||
show_button_bar: true, // Show button bar |
|||
form_name_root: 'root' // Add a root name for better structure |
|||
} |
|||
); |
|||
|
|||
// Store the editor instance on the textarea |
|||
editor_.editor = jsonEditor; |
|||
|
|||
// Update the textarea when the editor changes |
|||
jsonEditor.on('change', function () { |
|||
editor_.value = JSON.stringify(jsonEditor.getValue()); |
|||
|
|||
// Trigger a change event on the textarea |
|||
let event = new Event('change', { bubbles: true }); |
|||
editor_.dispatchEvent(event); |
|||
|
|||
// Apply styling to newly added elements |
|||
applyCustomStyling(); |
|||
}); |
|||
|
|||
// Function to apply custom styling to all elements |
|||
function applyCustomStyling() { |
|||
// Add modern styling to buttons |
|||
const allButtons = jsonViewerDiv.querySelectorAll('button'); |
|||
allButtons.forEach(button => { |
|||
if (!button.classList.contains('json-editor-btn-modern')) { |
|||
button.classList.add('json-editor-btn-modern'); |
|||
|
|||
// Add specific styling based on button type |
|||
if (button.classList.contains('json-editor-btntype-add')) { |
|||
button.classList.add('json-editor-btn-add'); |
|||
} else if (button.classList.contains('json-editor-btntype-delete') || |
|||
button.classList.contains('json-editor-btntype-deleteall') || |
|||
button.classList.contains('json-editor-btntype-deletelast')) { |
|||
button.classList.add('json-editor-btn-delete'); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
// Style form controls |
|||
const formControls = jsonViewerDiv.querySelectorAll('input, select, textarea'); |
|||
formControls.forEach(control => { |
|||
control.classList.add('modern-form-control'); |
|||
}); |
|||
|
|||
// Style table headers |
|||
const tableHeaders = jsonViewerDiv.querySelectorAll('th'); |
|||
tableHeaders.forEach(header => { |
|||
header.classList.add('modern-table-header'); |
|||
}); |
|||
|
|||
// Style table rows |
|||
const tableRows = jsonViewerDiv.querySelectorAll('tr'); |
|||
tableRows.forEach(row => { |
|||
row.classList.add('modern-table-row'); |
|||
}); |
|||
|
|||
// Make table full width |
|||
const tables = jsonViewerDiv.querySelectorAll('table'); |
|||
tables.forEach(table => { |
|||
table.classList.add('full-width-table'); |
|||
}); |
|||
|
|||
// Make table cells take equal space |
|||
const tableCells = jsonViewerDiv.querySelectorAll('td'); |
|||
tableCells.forEach(cell => { |
|||
if (!cell.classList.contains('table-controls-cell')) { |
|||
cell.classList.add('equal-width-cell'); |
|||
} |
|||
}); |
|||
|
|||
// Add special styling to control cells |
|||
const controlCells = jsonViewerDiv.querySelectorAll('td:last-child'); |
|||
controlCells.forEach(cell => { |
|||
cell.classList.add('table-controls-cell'); |
|||
}); |
|||
|
|||
// Fix button group styling |
|||
const buttonGroups = jsonViewerDiv.querySelectorAll('.btn-group, .json-editor-btngroup'); |
|||
buttonGroups.forEach(group => { |
|||
group.classList.add('modern-btn-group'); |
|||
}); |
|||
|
|||
// Fix card styling |
|||
const cards = jsonViewerDiv.querySelectorAll('.card'); |
|||
cards.forEach(card => { |
|||
card.classList.add('modern-card'); |
|||
}); |
|||
|
|||
// Ensure button sections are visible |
|||
const buttonSections = jsonViewerDiv.querySelectorAll('.json-editor-btngroup'); |
|||
buttonSections.forEach(section => { |
|||
section.style.display = 'flex'; |
|||
section.style.visibility = 'visible'; |
|||
}); |
|||
|
|||
// Style button bars |
|||
const buttonBars = jsonViewerDiv.querySelectorAll('.json-editor-btn-bar'); |
|||
buttonBars.forEach(bar => { |
|||
bar.style.display = 'flex'; |
|||
bar.style.flexWrap = 'wrap'; |
|||
bar.style.gap = '0.5rem'; |
|||
bar.style.marginTop = '1rem'; |
|||
bar.style.marginBottom = '0'; |
|||
bar.style.padding = '0.75rem'; |
|||
bar.style.backgroundColor = 'rgba(1, 53, 59, 0.05)'; |
|||
bar.style.borderRadius = '0.5rem'; |
|||
bar.style.width = '100%'; |
|||
bar.style.justifyContent = 'flex-end'; |
|||
|
|||
// Move button bar to the end of its parent container |
|||
const parent = bar.parentElement; |
|||
if (parent) { |
|||
parent.style.display = 'flex'; |
|||
parent.style.flexDirection = 'column'; |
|||
parent.appendChild(bar); |
|||
} |
|||
}); |
|||
|
|||
// Hide specific buttons (Copy, Move up, Move down, Delete Last) |
|||
const buttonsToHide = jsonViewerDiv.querySelectorAll('.json-editor-btntype-copy, .json-editor-btntype-move, .json-editor-btntype-deletelast'); |
|||
buttonsToHide.forEach(button => { |
|||
button.style.display = 'none'; |
|||
}); |
|||
|
|||
// Ensure form fields take full width |
|||
const formRows = jsonViewerDiv.querySelectorAll('.row'); |
|||
formRows.forEach(row => { |
|||
row.style.width = '100%'; |
|||
const cols = row.querySelectorAll('[class*="col-"]'); |
|||
cols.forEach(col => { |
|||
col.style.width = '100%'; |
|||
col.style.maxWidth = '100%'; |
|||
col.style.flex = '0 0 100%'; |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
// Apply styling immediately after initialization |
|||
setTimeout(applyCustomStyling, 100); |
|||
|
|||
// Process error messages to make them HTML5-like |
|||
function processErrorMessages() { |
|||
const errorElements = jsonViewerDiv.querySelectorAll('.je-error'); |
|||
errorElements.forEach(error => { |
|||
// Get the error message text |
|||
const errorText = error.textContent.trim(); |
|||
|
|||
// Set the data-content attribute for the tooltip |
|||
error.setAttribute('data-content', errorText); |
|||
|
|||
// Find the parent form group |
|||
const formGroup = error.closest('.form-group'); |
|||
if (formGroup) { |
|||
formGroup.classList.add('has-error'); |
|||
|
|||
// Find the input element |
|||
const input = formGroup.querySelector('input, select, textarea'); |
|||
if (input) { |
|||
// Add error class to the input |
|||
input.classList.add('is-invalid'); |
|||
|
|||
// Add title attribute for native tooltip |
|||
input.setAttribute('title', errorText); |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// Remove "Delete Last Course Features" button if it exists |
|||
function removeDeleteLastButton() { |
|||
const deleteLastButtons = jsonViewerDiv.querySelectorAll('button.json-editor-btntype-deletelast'); |
|||
deleteLastButtons.forEach(button => { |
|||
const buttonText = button.textContent.trim(); |
|||
if (buttonText.includes('Delete Last') && buttonText.includes('Course Features')) { |
|||
button.style.display = 'none'; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// Add mutation observer to apply styling to dynamically added elements |
|||
const observer = new MutationObserver(function (mutations) { |
|||
applyCustomStyling(); |
|||
processErrorMessages(); |
|||
removeDeleteLastButton(); |
|||
}); |
|||
|
|||
observer.observe(jsonViewerDiv, { |
|||
childList: true, |
|||
subtree: true |
|||
}); |
|||
|
|||
// Initial processing |
|||
setTimeout(() => { |
|||
processErrorMessages(); |
|||
removeDeleteLastButton(); |
|||
}, 200); |
|||
|
|||
} catch (e) { |
|||
console.error("Error initializing JSONEditor:", e); |
|||
} |
|||
} |
|||
|
|||
// Initialize the editor |
|||
if (document.readyState === 'complete') { |
|||
initJsonEditor(); |
|||
} else { |
|||
window.addEventListener('load', initJsonEditor); |
|||
} |
|||
}); |
|||
</script> |
|||
|
|||
<style> |
|||
/* Modern JSON Editor Container - Unfold theme */ |
|||
.json-editor-container { |
|||
margin-bottom: 1.5rem; |
|||
background-color: #0C0B1D; |
|||
border-radius: 0.5rem; |
|||
overflow: hidden; |
|||
width: 100%; |
|||
box-shadow: 0 1px 3px rgba(1, 53, 59, 0.08); |
|||
border: 1px solid rgba(1, 53, 59, 0.05); |
|||
} |
|||
|
|||
/* Editor container */ |
|||
.json-view-editor { |
|||
width: 100%; |
|||
border: none; |
|||
padding: 1.25rem; |
|||
} |
|||
|
|||
/* Card styling */ |
|||
.card { |
|||
border: none !important; |
|||
margin-bottom: 1.25rem !important; |
|||
background-color: transparent !important; |
|||
} |
|||
|
|||
.card-body { |
|||
padding: 0.75rem 0 !important; |
|||
} |
|||
|
|||
/* Hide unnecessary elements */ |
|||
.json-view-editor .card-title { |
|||
display: none !important; |
|||
} |
|||
|
|||
/* Modern JSON Editor styling */ |
|||
.json-editor-modern { |
|||
border: none !important; |
|||
box-shadow: none !important; |
|||
} |
|||
|
|||
.jsoneditor-menu { |
|||
display: none !important; |
|||
} |
|||
|
|||
/* Modern card styling */ |
|||
.modern-card { |
|||
border: none !important; |
|||
box-shadow: none !important; |
|||
background: transparent !important; |
|||
margin-bottom: 1rem !important; |
|||
padding: 0 !important; |
|||
width: 100% !important; |
|||
} |
|||
|
|||
.card-body { |
|||
padding: 0 !important; |
|||
width: 100% !important; |
|||
} |
|||
|
|||
/* Table styling */ |
|||
.full-width-table { |
|||
width: 100% !important; |
|||
margin-bottom: 1rem !important; |
|||
border-collapse: separate !important; |
|||
border-spacing: 0 !important; |
|||
} |
|||
|
|||
.table-responsive { |
|||
border: 1px solid rgba(1, 53, 59, 0.1); |
|||
border-radius: 0.5rem; |
|||
overflow: hidden; |
|||
margin-bottom: 1.25rem; |
|||
background-color: white; |
|||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); |
|||
} |
|||
|
|||
.equal-width-cell { |
|||
width: 45% !important; |
|||
} |
|||
|
|||
.table-controls-cell { |
|||
width: 10% !important; |
|||
text-align: right !important; |
|||
} |
|||
|
|||
.modern-table-header { |
|||
background-color: rgba(1, 53, 59, 0.03) !important; |
|||
color: rgb(1, 53, 59) !important; |
|||
font-weight: 600 !important; |
|||
text-transform: uppercase !important; |
|||
font-size: 0.6875rem !important; |
|||
letter-spacing: 0.05em !important; |
|||
padding: 0.625rem 0.875rem !important; |
|||
border-bottom: 1px solid rgba(1, 53, 59, 0.08) !important; |
|||
} |
|||
|
|||
.modern-table-row { |
|||
border-bottom: 1px solid rgba(1, 53, 59, 0.06) !important; |
|||
} |
|||
|
|||
.modern-table-row:last-child { |
|||
border-bottom: none !important; |
|||
} |
|||
|
|||
.modern-table-row:hover { |
|||
background-color: rgba(37, 208, 118, 0.03) !important; |
|||
} |
|||
|
|||
.modern-table-row td { |
|||
padding: 0.625rem 0.875rem !important; |
|||
vertical-align: middle !important; |
|||
font-size: 0.875rem !important; |
|||
} |
|||
|
|||
/* Modern buttons - Using Unfold color scheme */ |
|||
.json-editor-btn-modern { |
|||
display: inline-flex !important; |
|||
align-items: center !important; |
|||
justify-content: center !important; |
|||
gap: 0.375rem !important; |
|||
background-color: rgb(37, 208, 118) !important; |
|||
/* Unfold primary-500 */ |
|||
color: white !important; |
|||
border: none !important; |
|||
border-radius: 0.375rem !important; |
|||
padding: 0.5rem 0.875rem !important; |
|||
/* Smaller padding */ |
|||
font-weight: 500 !important; |
|||
cursor: pointer !important; |
|||
transition: all 0.2s ease !important; |
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important; |
|||
margin: 0 0.25rem !important; |
|||
font-size: 0.8125rem !important; |
|||
/* Smaller font */ |
|||
line-height: 1.4 !important; |
|||
text-transform: none !important; |
|||
letter-spacing: 0.01em !important; |
|||
} |
|||
|
|||
.json-editor-btn-modern:hover { |
|||
background-color: rgb(29, 166, 94) !important; |
|||
/* Unfold primary-600 */ |
|||
transform: translateY(-1px) !important; |
|||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08) !important; |
|||
} |
|||
|
|||
.json-editor-btn-modern:active { |
|||
transform: translateY(0) !important; |
|||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08) !important; |
|||
} |
|||
|
|||
/* Button sections styling */ |
|||
.json-editor-btngroup, |
|||
.json-editor-btn-bar { |
|||
display: flex !important; |
|||
flex-wrap: wrap !important; |
|||
gap: 0.5rem !important; |
|||
margin-top: 1rem !important; |
|||
margin-bottom: 0 !important; |
|||
padding: 0.75rem !important; |
|||
background-color: rgba(1, 53, 59, 0.05) !important; |
|||
border-radius: 0.5rem !important; |
|||
visibility: visible !important; |
|||
width: 100% !important; |
|||
justify-content: flex-end !important; |
|||
} |
|||
|
|||
/* Button icons */ |
|||
.json-editor-btn-icon { |
|||
display: flex !important; |
|||
align-items: center !important; |
|||
justify-content: center !important; |
|||
} |
|||
|
|||
/* Add button styling */ |
|||
.json-editor-btn-add { |
|||
background-color: rgb(37, 208, 118) !important; |
|||
/* Unfold primary-500 */ |
|||
padding: 0.625rem 1rem !important; |
|||
/* Smaller padding */ |
|||
font-size: 0.875rem !important; |
|||
/* Smaller font */ |
|||
font-weight: 600 !important; |
|||
letter-spacing: 0.01em !important; |
|||
border-radius: 0.375rem !important; |
|||
width: auto !important; |
|||
/* Not full width */ |
|||
margin-bottom: 0.75rem !important; |
|||
} |
|||
|
|||
.json-editor-btn-add:hover { |
|||
background-color: rgb(29, 166, 94) !important; |
|||
/* Unfold primary-600 */ |
|||
} |
|||
|
|||
/* Modern HTML5-like error styling */ |
|||
.je-error-container { |
|||
display: none !important; |
|||
/* Hide the default error container */ |
|||
} |
|||
|
|||
/* Style for invalid inputs */ |
|||
.je-error+input, |
|||
.je-error+select, |
|||
.je-error+textarea, |
|||
.has-error .modern-form-control { |
|||
border-color: rgb(239, 68, 68) !important; |
|||
padding-right: 2.5rem !important; |
|||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ef4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); |
|||
background-repeat: no-repeat; |
|||
background-position: right 0.75rem center; |
|||
background-size: 1.25rem; |
|||
} |
|||
|
|||
/* Custom tooltip for errors */ |
|||
.je-error { |
|||
position: relative !important; |
|||
display: inline-block !important; |
|||
color: transparent !important; |
|||
font-size: 0 !important; |
|||
width: 0 !important; |
|||
height: 0 !important; |
|||
overflow: visible !important; |
|||
} |
|||
|
|||
.je-error::after { |
|||
content: attr(data-content) !important; |
|||
position: absolute !important; |
|||
bottom: 125% !important; |
|||
right: 0 !important; |
|||
visibility: hidden !important; |
|||
width: 200px !important; |
|||
background-color: rgb(239, 68, 68) !important; |
|||
color: white !important; |
|||
text-align: center !important; |
|||
border-radius: 0.375rem !important; |
|||
padding: 0.5rem 0.75rem !important; |
|||
font-size: 0.8125rem !important; |
|||
font-weight: 500 !important; |
|||
opacity: 0 !important; |
|||
transition: opacity 0.3s !important; |
|||
z-index: 100 !important; |
|||
pointer-events: none !important; |
|||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important; |
|||
} |
|||
|
|||
/* Show tooltip on hover over the input */ |
|||
.je-error+input:hover+.je-error::after, |
|||
.je-error+select:hover+.je-error::after, |
|||
.je-error+textarea:hover+.je-error::after, |
|||
.has-error .modern-form-control:hover+.je-error::after { |
|||
visibility: visible !important; |
|||
opacity: 1 !important; |
|||
} |
|||
|
|||
/* Arrow for tooltip */ |
|||
.je-error::before { |
|||
content: "" !important; |
|||
position: absolute !important; |
|||
bottom: 125% !important; |
|||
right: 10px !important; |
|||
visibility: hidden !important; |
|||
border-width: 5px !important; |
|||
border-style: solid !important; |
|||
border-color: rgb(239, 68, 68) transparent transparent transparent !important; |
|||
opacity: 0 !important; |
|||
transition: opacity 0.3s !important; |
|||
} |
|||
|
|||
.je-error+input:hover+.je-error::before, |
|||
.je-error+select:hover+.je-error::before, |
|||
.je-error+textarea:hover+.je-error::before, |
|||
.has-error .modern-form-control:hover+.je-error::before { |
|||
visibility: visible !important; |
|||
opacity: 1 !important; |
|||
} |
|||
|
|||
/* Delete button styling */ |
|||
.json-editor-btn-delete { |
|||
background-color: rgb(1, 53, 59) !important; |
|||
/* Unfold secondary-500 */ |
|||
} |
|||
|
|||
.json-editor-btn-delete:hover { |
|||
background-color: rgb(1, 43, 48) !important; |
|||
/* Unfold secondary-600 */ |
|||
} |
|||
|
|||
/* Hide Delete Last buttons */ |
|||
.json-editor-btntype-deletelast { |
|||
display: none !important; |
|||
} |
|||
|
|||
/* Move up/down buttons */ |
|||
.json-editor-btntype-moveup, |
|||
.json-editor-btntype-movedown { |
|||
background-color: rgba(1, 53, 59, 0.8) !important; |
|||
} |
|||
|
|||
.json-editor-btntype-moveup:hover, |
|||
.json-editor-btntype-movedown:hover { |
|||
background-color: rgb(1, 53, 59) !important; |
|||
} |
|||
|
|||
/* Button sections styling */ |
|||
.json-editor-btngroup, |
|||
.json-editor-btn-bar { |
|||
display: flex !important; |
|||
flex-wrap: wrap !important; |
|||
gap: 0.375rem !important; |
|||
margin-top: 1rem !important; |
|||
padding: 0.625rem !important; |
|||
background-color: rgba(1, 53, 59, 0.03) !important; |
|||
/* More subtle background */ |
|||
border: 1px solid rgba(1, 53, 59, 0.08) !important; |
|||
/* Subtle border */ |
|||
border-radius: 0.5rem !important; |
|||
visibility: visible !important; |
|||
width: 100% !important; |
|||
justify-content: flex-end !important; |
|||
order: 999 !important; |
|||
/* Ensure it appears at the bottom */ |
|||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important; |
|||
/* Subtle shadow */ |
|||
} |
|||
|
|||
/* Modern button group styling */ |
|||
.modern-btn-group { |
|||
display: inline-flex !important; |
|||
gap: 0.25rem !important; |
|||
margin: 0.125rem !important; |
|||
} |
|||
|
|||
/* Form controls - Unfold theme */ |
|||
.modern-form-control { |
|||
width: 100% !important; |
|||
border: 1px solid rgba(1, 53, 59, 0.15) !important; |
|||
border-radius: 0.375rem !important; |
|||
padding: 0.625rem 0.875rem !important; |
|||
font-size: 0.875rem !important; |
|||
line-height: 1.5 !important; |
|||
color: rgb(1, 53, 59) !important; |
|||
background-color: #fff !important; |
|||
transition: all 0.2s ease !important; |
|||
appearance: none !important; |
|||
margin-bottom: 0.875rem !important; |
|||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important; |
|||
} |
|||
|
|||
.modern-form-control:focus { |
|||
border-color: rgb(37, 208, 118) !important; |
|||
box-shadow: 0 0 0 3px rgba(37, 208, 118, 0.15) !important; |
|||
outline: none !important; |
|||
} |
|||
|
|||
.modern-form-control::placeholder { |
|||
color: rgba(1, 53, 59, 0.4) !important; |
|||
} |
|||
|
|||
/* Form labels */ |
|||
label, |
|||
.je-label { |
|||
font-size: 0.875rem !important; |
|||
font-weight: 500 !important; |
|||
color: rgb(1, 53, 59) !important; |
|||
margin-bottom: 0.375rem !important; |
|||
display: block !important; |
|||
} |
|||
|
|||
/* Button group styling */ |
|||
.modern-btn-group { |
|||
display: flex !important; |
|||
flex-wrap: nowrap !important; |
|||
align-items: center !important; |
|||
justify-content: flex-end !important; |
|||
gap: 0.5rem !important; |
|||
} |
|||
|
|||
/* Error message styling */ |
|||
.invalid-feedback { |
|||
color: #ef4444 !important; |
|||
font-size: 0.875rem !important; |
|||
margin-top: 0.25rem !important; |
|||
margin-bottom: 0.5rem !important; |
|||
} |
|||
|
|||
/* Labels */ |
|||
label, |
|||
.je-label { |
|||
display: block !important; |
|||
margin-bottom: 0.5rem !important; |
|||
font-weight: 500 !important; |
|||
color: rgb(1, 53, 59) !important; |
|||
font-size: 0.9375rem !important; |
|||
} |
|||
|
|||
/* Form groups */ |
|||
.form-group, |
|||
.je-object__container { |
|||
margin-bottom: 1.5rem !important; |
|||
width: 100% !important; |
|||
} |
|||
|
|||
/* Make the JSON editor more responsive */ |
|||
@media (max-width: 767px) { |
|||
.modern-form-control { |
|||
font-size: 0.875rem !important; |
|||
padding: 0.5rem 0.625rem !important; |
|||
} |
|||
|
|||
.json-editor-btn-modern { |
|||
padding: 0.375rem 0.75rem !important; |
|||
font-size: 0.875rem !important; |
|||
} |
|||
|
|||
.modern-table-header { |
|||
font-size: 0.75rem !important; |
|||
padding: 0.625rem 0.75rem !important; |
|||
} |
|||
|
|||
.modern-table-row td { |
|||
padding: 0.625rem 0.75rem !important; |
|||
} |
|||
} |
|||
|
|||
/* Dark mode support - Using Unfold color scheme */ |
|||
@media (prefers-color-scheme: dark) { |
|||
.json-editor-container { |
|||
background-color: rgb(1, 43, 48) !important; |
|||
/* Unfold secondary-600 */ |
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); |
|||
} |
|||
|
|||
.json-view-editor { |
|||
background-color: rgb(1, 43, 48) !important; |
|||
/* Unfold secondary-600 */ |
|||
} |
|||
|
|||
.table-responsive { |
|||
border-color: rgb(1, 36, 40) !important; |
|||
/* Unfold secondary-700 */ |
|||
background-color: rgb(1, 43, 48) !important; |
|||
/* Unfold secondary-600 */ |
|||
} |
|||
|
|||
.modern-table-header { |
|||
background-color: rgb(1, 30, 34) !important; |
|||
/* Unfold secondary-800 */ |
|||
color: white !important; |
|||
border-bottom-color: rgb(0, 26, 29) !important; |
|||
/* Unfold secondary-900 */ |
|||
} |
|||
|
|||
.modern-table-row { |
|||
border-bottom-color: rgb(1, 30, 34) !important; |
|||
/* Unfold secondary-800 */ |
|||
} |
|||
|
|||
.modern-table-row:hover { |
|||
background-color: rgb(1, 36, 40) !important; |
|||
/* Unfold secondary-700 */ |
|||
} |
|||
|
|||
.modern-table-row td { |
|||
color: white !important; |
|||
} |
|||
|
|||
.modern-form-control { |
|||
background-color: rgb(1, 36, 40) !important; |
|||
/* Unfold secondary-700 */ |
|||
border-color: rgb(1, 30, 34) !important; |
|||
/* Unfold secondary-800 */ |
|||
color: white !important; |
|||
} |
|||
|
|||
label, |
|||
.je-label { |
|||
color: white !important; |
|||
} |
|||
|
|||
/* Button sections in dark mode */ |
|||
.json-editor-btngroup, |
|||
.json-editor-btn-bar { |
|||
background-color: rgba(1, 30, 34, 0.25) !important; |
|||
/* More subtle dark background */ |
|||
border: 1px solid rgba(37, 208, 118, 0.1) !important; |
|||
/* Subtle primary color border */ |
|||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; |
|||
} |
|||
|
|||
.modern-form-control:focus { |
|||
border-color: rgb(37, 208, 118) !important; |
|||
/* Unfold primary-500 */ |
|||
box-shadow: 0 0 0 3px rgba(37, 208, 118, 0.2) !important; |
|||
} |
|||
|
|||
/* Error styling in dark mode */ |
|||
.je-error+input, |
|||
.je-error+select, |
|||
.je-error+textarea, |
|||
.has-error .modern-form-control { |
|||
border-color: rgb(252, 165, 165) !important; |
|||
/* Lighter red for dark mode */ |
|||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23fca5a5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); |
|||
} |
|||
|
|||
.je-error::after { |
|||
background-color: rgb(185, 28, 28) !important; |
|||
/* Darker red background for tooltip */ |
|||
color: white !important; |
|||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25) !important; |
|||
} |
|||
|
|||
.je-error::before { |
|||
border-color: rgb(185, 28, 28) transparent transparent transparent !important; |
|||
} |
|||
|
|||
/* Card styling in dark mode */ |
|||
.modern-card { |
|||
background-color: rgb(1, 43, 48) !important; |
|||
/* Unfold secondary-600 */ |
|||
} |
|||
|
|||
/* Error messages in dark mode */ |
|||
.invalid-feedback { |
|||
color: #f87171 !important; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,33 @@ |
|||
|
|||
{% load unfold i18n %} |
|||
|
|||
|
|||
<div class="grid gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4 "> |
|||
{% component "unfold/components/card.html" %} |
|||
{% component "unfold/components/text.html" %} |
|||
{% trans "Total Active Users" %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/title.html" with component_class="AllUserComponent" %}{% endcomponent %} |
|||
{% endcomponent %} |
|||
|
|||
{% component "unfold/components/card.html" %} |
|||
{% component "unfold/components/text.html" %} |
|||
{% trans "Total Guest Users" %} |
|||
{% endcomponent %} |
|||
{% component "unfold/components/title.html" with component_class="GuestUserComponent" %}{% endcomponent %} |
|||
{% endcomponent %} |
|||
{% component "unfold/components/card.html" %} |
|||
{% component "unfold/components/text.html" %} |
|||
{% trans "Total Students" %} |
|||
{% endcomponent %} |
|||
{% component "unfold/components/title.html" with component_class="StudentUserComponent" %}{% endcomponent %} |
|||
{% endcomponent %} |
|||
{% component "unfold/components/card.html" %} |
|||
{% component "unfold/components/text.html" %} |
|||
{% trans "Total Professors" %} |
|||
{% endcomponent %} |
|||
{% component "unfold/components/title.html" with component_class="ProfessorUserComponent" %}{% endcomponent %} |
|||
{% endcomponent %} |
|||
|
|||
</div> |
|||
@ -0,0 +1,47 @@ |
|||
from django.contrib.auth.models import Group |
|||
from django.urls import reverse |
|||
from rest_framework import status |
|||
from rest_framework.authtoken.models import Token |
|||
from rest_framework.test import APITestCase |
|||
|
|||
from apps.account.models import User |
|||
|
|||
|
|||
class UserAuthenticationTests(APITestCase): |
|||
def setUp(self): |
|||
self.super_admin = User.objects.create_superuser( |
|||
email="[email protected]", |
|||
password="SuperSecret123!", |
|||
fullname="Super Admin", |
|||
) |
|||
self.regular_user = User.objects.create_user( |
|||
email="[email protected]", |
|||
password="UserPass123!", |
|||
fullname="Regular User", |
|||
) |
|||
|
|||
def test_user_login_success(self): |
|||
response = self.client.post( |
|||
reverse("user-login"), |
|||
{"email": "[email protected]", "password": "UserPass123!"}, |
|||
format="json", |
|||
) |
|||
self.assertEqual(response.status_code, status.HTTP_200_OK) |
|||
self.assertIn("token", response.data) |
|||
|
|||
def test_regular_user_cannot_access_admin_login(self): |
|||
response = self.client.post( |
|||
reverse("admin-login"), |
|||
{"email": "[email protected]", "password": "UserPass123!"}, |
|||
format="json", |
|||
) |
|||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) |
|||
|
|||
def test_super_admin_can_access_admin_login(self): |
|||
response = self.client.post( |
|||
reverse("admin-login"), |
|||
{"email": "[email protected]", "password": "SuperSecret123!"}, |
|||
format="json", |
|||
) |
|||
self.assertEqual(response.status_code, status.HTTP_200_OK) |
|||
self.assertIn("token", response.data) |
|||
@ -0,0 +1,47 @@ |
|||
from django.urls import path, include |
|||
from rest_framework.routers import SimpleRouter |
|||
from apps.account import views |
|||
|
|||
admin_router = SimpleRouter() |
|||
admin_router.register(r'users', views.AdminUserViewSet, basename='admin-users') |
|||
admin_router.register(r'directory-users', views.AdminUserDirectoryViewSet, basename='admin-directory-users') |
|||
admin_router.register(r'notifications', views.AdminNotificationViewSet, basename='admin-notifications') |
|||
admin_router.register(r'notification-templates', views.AdminNotificationTemplateViewSet, basename='admin-notification-templates') |
|||
|
|||
# Hide admin viewsets from swagger |
|||
for prefix, viewset, basename in admin_router.registry: |
|||
viewset.swagger_schema = None |
|||
views.AdminLoginView.swagger_schema = None |
|||
|
|||
urlpatterns = [ |
|||
# Auth & Registration |
|||
path('register/', views.UserRegisterView.as_view(), name='user-register'), |
|||
path('web/register/', views.WebUserRegisterView.as_view(), name='web-user-register'), |
|||
path('verify/', views.UserVerifyView.as_view(), name='user-verify'), |
|||
path('login/', views.UserLoginView.as_view(), name='user-login'), |
|||
path('guest/', views.UserGuestView.as_view(), name='user-guest'), |
|||
path('exchange-token/', views.ExchangeTokenAPIView.as_view(), name='exchange-token'), |
|||
|
|||
# Profile Management |
|||
path('profile/', views.UserProfileView.as_view(), name='user-profile'), |
|||
path('profile/update/', views.UserUpdateView.as_view(), name='user-update'), |
|||
path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'), |
|||
path('update-fcm/', views.UpdateFCMView.as_view(), name='update-fcm'), |
|||
|
|||
# Password Recovery |
|||
path('recover/', views.UserRecoverPassword.as_view(), name='user-recover'), |
|||
path('reset/', views.UserResetPassword.as_view(), name='user-reset'), |
|||
|
|||
# Notifications |
|||
path('notif/', views.NotificationListView.as_view(), name='user-notif'), |
|||
path('notif/read/', views.NotificationReadAllView.as_view(), name='user-notif-read-all'), |
|||
path('notif/send/', views.SendNotificationView.as_view(), name='user-send-notif'), |
|||
|
|||
# Location & Region Info |
|||
path('location-update/', views.LocationHistoryView.as_view(), name='user-location-history'), |
|||
path('region-info/', views.RegionInfoView.as_view(), name='region-info'), |
|||
|
|||
# Admin API Endpoints |
|||
path('admin/login/', views.AdminLoginView.as_view(), name='admin-login'), |
|||
path('admin/', include(admin_router.urls)), |
|||
] |
|||
@ -0,0 +1,4 @@ |
|||
from .user import * |
|||
from .notification import * |
|||
from .auth import * |
|||
from .location_history import* |
|||
@ -0,0 +1,163 @@ |
|||
import logging |
|||
|
|||
from django.contrib.auth import get_user_model |
|||
|
|||
from drf_yasg import openapi |
|||
from drf_yasg.utils import swagger_auto_schema |
|||
from rest_framework import status |
|||
from rest_framework.authtoken.models import Token |
|||
from rest_framework.generics import GenericAPIView |
|||
from rest_framework.permissions import AllowAny |
|||
from rest_framework.response import Response |
|||
|
|||
from apps.account.serializers import ExchangeTokenSerializer |
|||
from utils import absolute_url |
|||
from utils.redis import OnlineClassTokenManager |
|||
|
|||
|
|||
from django.utils import timezone |
|||
from utils.ip_helper import get_client_ip |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
UserModel = get_user_model() |
|||
|
|||
|
|||
def detect_device_os(user_agent): |
|||
if not user_agent: |
|||
return None |
|||
ua = user_agent.lower() |
|||
if 'android' in ua: |
|||
return 'android' |
|||
if 'iphone' in ua or 'ipad' in ua or 'ios' in ua: |
|||
return 'apple' |
|||
if any(k in ua for k in ['mozilla', 'chrome', 'safari', 'windows', 'macintosh', 'linux']): |
|||
return 'web' |
|||
return None |
|||
|
|||
|
|||
class ExchangeTokenAPIView(GenericAPIView): |
|||
""" |
|||
تبدیل temporary token به اطلاعات کاربر برای ورود از اپ موبایل |
|||
""" |
|||
permission_classes = [AllowAny] |
|||
serializer_class = ExchangeTokenSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Exchange temporary token for user information and authentication token.", |
|||
request_body=ExchangeTokenSerializer, |
|||
responses={ |
|||
status.HTTP_200_OK: openapi.Response( |
|||
description="Token exchanged successfully.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": True, |
|||
"message": "ورود موفق", |
|||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", |
|||
"user": { |
|||
"id": 123, |
|||
"fullname": "علی احمدی", |
|||
"email": "[email protected]", |
|||
"avatar": "https://cdn.example.com/avatar.jpg" |
|||
} |
|||
} |
|||
} |
|||
), |
|||
status.HTTP_400_BAD_REQUEST: openapi.Response( |
|||
description="Invalid request.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": False, |
|||
"message": "توکن ارسال نشده است" |
|||
} |
|||
} |
|||
), |
|||
status.HTTP_404_NOT_FOUND: openapi.Response( |
|||
description="Token not found or expired.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": False, |
|||
"message": "توکن نامعتبر یا منقضی شده است" |
|||
} |
|||
} |
|||
), |
|||
} |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
|
|||
temp_token = serializer.validated_data['temp_token'] |
|||
|
|||
# دریافت اطلاعات از Redis/Cache |
|||
manager = OnlineClassTokenManager() |
|||
try: |
|||
token_data = manager.get_payload(temp_token) |
|||
except Exception: |
|||
return Response({ |
|||
'success': False, |
|||
'message': 'توکن نامعتبر یا منقضی شده است' |
|||
}, status=status.HTTP_404_NOT_FOUND) |
|||
|
|||
user_id = token_data.get('user_id') |
|||
if not user_id: |
|||
return Response({ |
|||
'success': False, |
|||
'message': 'توکن نامعتبر است' |
|||
}, status=status.HTTP_400_BAD_REQUEST) |
|||
|
|||
# دریافت کاربر |
|||
try: |
|||
user = UserModel.objects.get(id=user_id) |
|||
except UserModel.DoesNotExist: |
|||
return Response({ |
|||
'success': False, |
|||
'message': 'کاربر یافت نشد' |
|||
}, status=status.HTTP_404_NOT_FOUND) |
|||
|
|||
# حذف توکن موقت (one-time use) |
|||
manager.delete_token(temp_token) |
|||
|
|||
# دریافت یا تولید Token واقعی کاربر |
|||
auth_token, _ = Token.objects.get_or_create(user=user) |
|||
|
|||
client_ip = get_client_ip(request) |
|||
user_agent = request.META.get('HTTP_USER_AGENT', '') |
|||
device_os = token_data.get('device_os') |
|||
if not device_os and user_agent: |
|||
device_os = detect_device_os(user_agent) |
|||
|
|||
user.last_login = timezone.now() |
|||
if client_ip: |
|||
user.client_ip = client_ip |
|||
if user_agent: |
|||
user.user_agent = user_agent |
|||
if device_os: |
|||
user.device_os = device_os |
|||
user.save() |
|||
|
|||
user.login_history.create( |
|||
ip=client_ip, |
|||
user_agent=user_agent, |
|||
device_os=user.device_os or device_os, |
|||
) |
|||
|
|||
# دریافت avatar URL |
|||
avatar_url = None |
|||
if hasattr(user, 'avatar') and user.avatar: |
|||
try: |
|||
avatar_url = absolute_url(user.avatar.url) |
|||
except Exception: |
|||
avatar_url = None |
|||
|
|||
# برگرداندن اطلاعات کاربر با token واقعی |
|||
return Response({ |
|||
'success': True, |
|||
'message': 'ورود موفق', |
|||
'token': auth_token.key, |
|||
'user': { |
|||
'id': user.id, |
|||
'fullname': user.get_full_name() or user.username or '', |
|||
'email': user.email or '', |
|||
'avatar': avatar_url |
|||
} |
|||
}, status=status.HTTP_200_OK) |
|||
@ -0,0 +1,69 @@ |
|||
import logging |
|||
import re |
|||
from rest_framework.mixins import CreateModelMixin |
|||
from rest_framework.permissions import IsAuthenticated |
|||
from rest_framework.authentication import TokenAuthentication |
|||
from rest_framework.generics import GenericAPIView |
|||
from rest_framework.response import Response |
|||
from rest_framework import status |
|||
from apps.account.models import LocationHistory |
|||
from apps.account.serializers import LocationHistorySerializer |
|||
from utils.ip_helper import get_client_ip |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
def detect_browser_from_user_agent(user_agent): |
|||
if not user_agent: |
|||
return None |
|||
try: |
|||
ua = user_agent.lower() |
|||
patterns = [ |
|||
(r'edg/', 'Edge'), |
|||
(r'opr/', 'Opera'), |
|||
(r'chrome/', 'Chrome'), |
|||
(r'firefox/', 'Firefox'), |
|||
(r'safari/', 'Safari'), |
|||
] |
|||
for pattern, browser in patterns: |
|||
if re.search(pattern, ua): |
|||
return browser |
|||
return 'Unknown' |
|||
except Exception: |
|||
return None |
|||
|
|||
|
|||
class LocationHistoryView(GenericAPIView, CreateModelMixin): |
|||
permission_classes = [IsAuthenticated] |
|||
authentication_classes = [TokenAuthentication] |
|||
serializer_class = LocationHistorySerializer |
|||
|
|||
def post(self, request, *args, **kwargs): |
|||
ip = get_client_ip(request) |
|||
data = request.data.copy() |
|||
data['ip'] = ip |
|||
serializer = self.get_serializer(data=data) |
|||
|
|||
if serializer.is_valid(): |
|||
serializer.save(user=request.user) |
|||
return Response(serializer.data, status=status.HTTP_201_CREATED) |
|||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) |
|||
|
|||
def get_queryset(self): |
|||
return LocationHistory.objects.filter(user=self.request.user) |
|||
|
|||
|
|||
class RegionInfoView(GenericAPIView): |
|||
""" |
|||
Returns basic client region, browser, and network info |
|||
""" |
|||
def get(self, request, *args, **kwargs): |
|||
user_agent = request.META.get('HTTP_USER_AGENT', '') |
|||
client_ip = get_client_ip(request) |
|||
browser = detect_browser_from_user_agent(user_agent) |
|||
|
|||
return Response({ |
|||
'ip': client_ip, |
|||
'browser': browser, |
|||
'user_agent': user_agent, |
|||
}) |
|||
@ -0,0 +1,103 @@ |
|||
from rest_framework import generics, status |
|||
from rest_framework.response import Response |
|||
from rest_framework.authentication import TokenAuthentication |
|||
from drf_yasg.utils import swagger_auto_schema |
|||
from drf_yasg import openapi |
|||
from rest_framework.permissions import IsAuthenticated |
|||
from rest_framework.viewsets import ModelViewSet |
|||
|
|||
from apps.account.serializers import ( |
|||
NotificationSerializer, |
|||
NotificationSendSerializer, |
|||
AdminNotificationSerializer, |
|||
NotificationTemplateSerializer |
|||
) |
|||
from apps.account.models import Notification, User, NotificationTemplate |
|||
from apps.account.tasks import send_notification_task |
|||
from utils.pagination import StandardResultsSetPagination |
|||
from apps.account.permissions import IsSuperAdmin |
|||
import logging |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
class NotificationListView(generics.ListAPIView): |
|||
serializer_class = NotificationSerializer |
|||
permission_classes = [IsAuthenticated] |
|||
authentication_classes = [TokenAuthentication] |
|||
pagination_class = StandardResultsSetPagination |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Retrieve a list of notifications for the authenticated user.", |
|||
tags=['Notifications'], |
|||
) |
|||
def get(self, request, *args, **kwargs): |
|||
return super().get(request, *args, **kwargs) |
|||
|
|||
def get_queryset(self): |
|||
return Notification.objects.filter(user=self.request.user).order_by('-created_at') |
|||
|
|||
|
|||
class NotificationReadAllView(generics.GenericAPIView): |
|||
permission_classes = [IsAuthenticated] |
|||
authentication_classes = [TokenAuthentication] |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Mark all notifications as read for the authenticated user.", |
|||
tags=['Notifications'], |
|||
responses={200: "All notifications marked as read"} |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
Notification.objects.filter(user=request.user, is_read=False).update(is_read=True) |
|||
return Response({'status': 'all notifications marked as read'}, status=status.HTTP_200_OK) |
|||
|
|||
|
|||
class SendNotificationView(generics.GenericAPIView): |
|||
permission_classes = [IsAuthenticated] |
|||
authentication_classes = [TokenAuthentication] |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Dispatch an in-app notification to a specific user.", |
|||
tags=['Notifications'], |
|||
request_body=openapi.Schema( |
|||
type=openapi.TYPE_OBJECT, |
|||
required=['user_id', 'title', 'body'], |
|||
properties={ |
|||
'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='Target User ID'), |
|||
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Notification title'), |
|||
'body': openapi.Schema(type=openapi.TYPE_STRING, description='Notification body'), |
|||
'data': openapi.Schema(type=openapi.TYPE_OBJECT, description='Extra payload data'), |
|||
}, |
|||
), |
|||
responses={ |
|||
200: openapi.Response('Notification dispatched.'), |
|||
404: openapi.Response('User not found.'), |
|||
} |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
user_id = request.data.get('user_id') |
|||
title = request.data.get('title') |
|||
body = request.data.get('body') |
|||
data = request.data.get('data', {}) |
|||
|
|||
if not User.objects.filter(id=user_id).exists(): |
|||
return Response({'error': 'User not found.'}, status=status.HTTP_404_NOT_FOUND) |
|||
|
|||
send_notification_task.delay(user_id=user_id, title=title, body=body, data=data) |
|||
return Response({'status': 'notification queued'}, status=status.HTTP_200_OK) |
|||
|
|||
|
|||
class AdminNotificationViewSet(ModelViewSet): |
|||
queryset = Notification.objects.all().select_related('user') |
|||
serializer_class = AdminNotificationSerializer |
|||
permission_classes = [IsAuthenticated, IsSuperAdmin] |
|||
authentication_classes = [TokenAuthentication] |
|||
pagination_class = StandardResultsSetPagination |
|||
|
|||
|
|||
class AdminNotificationTemplateViewSet(ModelViewSet): |
|||
queryset = NotificationTemplate.objects.all() |
|||
serializer_class = NotificationTemplateSerializer |
|||
permission_classes = [IsAuthenticated, IsSuperAdmin] |
|||
authentication_classes = [TokenAuthentication] |
|||
pagination_class = StandardResultsSetPagination |
|||
@ -0,0 +1,942 @@ |
|||
import logging |
|||
import requests |
|||
import json |
|||
from rest_framework.generics import CreateAPIView, RetrieveUpdateAPIView, GenericAPIView, RetrieveAPIView, UpdateAPIView, ListAPIView |
|||
from rest_framework.views import APIView |
|||
from rest_framework.response import Response |
|||
from rest_framework import status |
|||
from django.db.models import Q |
|||
from rest_framework.permissions import AllowAny, IsAuthenticated |
|||
from rest_framework.authtoken.models import Token |
|||
from rest_framework.exceptions import AuthenticationFailed |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from django.shortcuts import get_object_or_404 |
|||
from rest_framework.authtoken.models import Token |
|||
|
|||
from django.utils import timezone |
|||
from rest_framework.authentication import TokenAuthentication |
|||
from django.contrib.auth import authenticate |
|||
from phonenumbers import parse, region_code_for_number |
|||
from drf_yasg.utils import swagger_auto_schema |
|||
from drf_yasg import openapi |
|||
from rest_framework.exceptions import ValidationError |
|||
|
|||
from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException |
|||
from apps.account.models import User |
|||
from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer,UserFCMSerializer,WebUserGuestSerializer, AdminUserSerializer |
|||
from apps.account.serializers.user_web import WebUserRegisterSerializer |
|||
from utils.redis import RedisManager |
|||
from utils.exceptions import AppAPIException |
|||
from utils import send_email, is_valid_email, absolute_https_url |
|||
from config.settings import base as settings |
|||
from apps.account.permissions import IsActiveUser, IsSuperAdminOrReadOnlyForProfessor |
|||
from apps.account.doc import * |
|||
from utils.ip_helper import get_client_ip |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
def detect_device_os(user_agent): |
|||
if not user_agent: |
|||
return None |
|||
ua = user_agent.lower() |
|||
if 'android' in ua: |
|||
return 'android' |
|||
if 'iphone' in ua or 'ipad' in ua or 'ios' in ua: |
|||
return 'apple' |
|||
if any(k in ua for k in ['mozilla', 'chrome', 'safari', 'windows', 'macintosh', 'linux']): |
|||
return 'web' |
|||
return None |
|||
|
|||
|
|||
|
|||
class UserGuestView(CreateAPIView): |
|||
permission_classes = [AllowAny] |
|||
authentication_classes = [] |
|||
serializer_class = UserGuestSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Create a guest user account with device information", |
|||
request_body=openapi.Schema( |
|||
type=openapi.TYPE_OBJECT, |
|||
properties={ |
|||
"device_id": openapi.Schema(type=openapi.TYPE_STRING, default="c9f0c1f4f5cee3d7"), |
|||
"fcm": openapi.Schema(type=openapi.TYPE_STRING, default=""), |
|||
"device_os": openapi.Schema(type=openapi.TYPE_STRING, default="android"), |
|||
"lat": openapi.Schema(type=openapi.TYPE_STRING, default="56"), |
|||
"lon": openapi.Schema(type=openapi.TYPE_STRING, default="44"), |
|||
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"), |
|||
}, |
|||
required=["device_id"], |
|||
), |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
logger.info(f'GuestAuthView--> {request.data}') |
|||
return super().post(request, *args, **kwargs) |
|||
|
|||
@staticmethod |
|||
def generate_login_token(user): |
|||
token, created = Token.objects.update_or_create(user=user) |
|||
return token.key |
|||
|
|||
def get_client_ip(self): |
|||
request = self.request |
|||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') |
|||
if x_forwarded_for: |
|||
ip = x_forwarded_for.split(',')[0] |
|||
else: |
|||
ip = request.META.get('REMOTE_ADDR') |
|||
return ip |
|||
|
|||
|
|||
def create(self, request, *args, **kwargs): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
user = self.perform_create(serializer) |
|||
return Response({ |
|||
'token': self.generate_login_token(user), |
|||
}, status=200) |
|||
|
|||
|
|||
def perform_create(self, serializer): |
|||
device_id = serializer.validated_data.get('device_id') |
|||
device_os = serializer.validated_data.get('device_os') |
|||
fcm = serializer.validated_data.get('fcm') |
|||
lat = serializer.validated_data.pop('lat', None) |
|||
lon = serializer.validated_data.pop('lon', None) |
|||
user_timezone = serializer.validated_data.pop('timezone', None) |
|||
client_ip = get_client_ip(self.request) |
|||
user_agent = self.request.META.get('HTTP_USER_AGENT', '') |
|||
|
|||
serializer_data = dict(serializer.validated_data) |
|||
|
|||
obj = User.objects.select_for_update().filter(Q(device_id=device_id)).first() |
|||
if not obj: |
|||
obj, created = User.objects.select_for_update().get_or_create( |
|||
device_id=device_id, |
|||
defaults=serializer_data |
|||
) |
|||
if created: |
|||
logger.info(f'Guest-(created)->: {obj.device_id}') |
|||
|
|||
obj.last_login = timezone.now() |
|||
if client_ip: |
|||
obj.client_ip = client_ip |
|||
if user_agent: |
|||
obj.user_agent = user_agent |
|||
if device_os: |
|||
obj.device_os = device_os |
|||
elif not obj.device_os and user_agent: |
|||
obj.device_os = detect_device_os(user_agent) |
|||
if fcm: |
|||
obj.fcm = fcm |
|||
obj.save() |
|||
login_history_obj = obj.login_history.create( |
|||
lat=lat, |
|||
lon=lon, |
|||
ip=client_ip, |
|||
timezone=user_timezone, |
|||
user_agent=user_agent, |
|||
device_os=obj.device_os, |
|||
) |
|||
return obj |
|||
|
|||
|
|||
import hashlib |
|||
from rest_framework.authtoken.models import Token |
|||
|
|||
class WebUserGuestView(CreateAPIView): |
|||
permission_classes = [AllowAny] |
|||
authentication_classes = [] |
|||
serializer_class = WebUserGuestSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Create a guest user account for web users using IP and user agent", |
|||
request_body=openapi.Schema( |
|||
type=openapi.TYPE_OBJECT, |
|||
properties={ |
|||
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"), |
|||
"user_agent": openapi.Schema(type=openapi.TYPE_STRING, default="Mozilla/5.0..."), |
|||
}, |
|||
required=[], # No required fields - we'll extract from request |
|||
), |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
logger.info(f'WebGuestAuthView--> IP: {self.get_client_ip()}, User-Agent: {self.get_user_agent()}') |
|||
return super().post(request, *args, **kwargs) |
|||
|
|||
@staticmethod |
|||
def generate_login_token(user): |
|||
# ✅ FIX 2: Prevent token rotation on every login |
|||
token, created = Token.objects.get_or_create(user=user) |
|||
return token.key |
|||
|
|||
def get_client_ip(self): |
|||
"""Get client IP address from request""" |
|||
request = self.request |
|||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') |
|||
if x_forwarded_for: |
|||
ip = x_forwarded_for.split(',')[0] |
|||
else: |
|||
ip = request.META.get('REMOTE_ADDR') |
|||
return ip |
|||
|
|||
def get_user_agent(self): |
|||
"""Get user agent from request headers""" |
|||
return self.request.META.get('HTTP_USER_AGENT', '') |
|||
|
|||
def create(self, request, *args, **kwargs): |
|||
data = request.data.copy() |
|||
client_ip = self.get_client_ip() |
|||
user_agent = self.get_user_agent() |
|||
|
|||
# ✅ FIX 1: Stable Hash (MD5) instead of random hash() |
|||
ua_hash = hashlib.md5(user_agent.encode('utf-8')).hexdigest()[:8] |
|||
web_user_id = f"{client_ip}_{ua_hash}" |
|||
|
|||
data.update({ |
|||
'device_id': web_user_id, |
|||
'device_os': 'web', |
|||
'user_agent': user_agent, |
|||
'client_ip': client_ip, |
|||
}) |
|||
|
|||
serializer = self.get_serializer(data=data) |
|||
serializer.is_valid(raise_exception=True) |
|||
user = self.perform_create(serializer) |
|||
return Response({ |
|||
'token': self.generate_login_token(user), |
|||
}, status=200) |
|||
|
|||
|
|||
def perform_create(self, serializer): |
|||
# Extract web-specific data |
|||
user_timezone = serializer.validated_data.pop('timezone', None) |
|||
device_id = serializer.validated_data.get('device_id') |
|||
user_agent = serializer.validated_data.get('user_agent') |
|||
client_ip = serializer.validated_data.get('client_ip') |
|||
|
|||
serializer_data = dict(serializer.validated_data) |
|||
|
|||
# Find or create user based on device_id (which is IP + hashed user agent) |
|||
obj = User.objects.select_for_update().filter(Q(device_id=device_id)).first() |
|||
if not obj: |
|||
obj, created = User.objects.select_for_update().get_or_create( |
|||
device_id=device_id, |
|||
defaults=serializer_data |
|||
) |
|||
if created: |
|||
logger.info(f'WebGuest-(created)->: {device_id} (IP: {client_ip})') |
|||
|
|||
# Update user on each login |
|||
obj.last_login = timezone.now() |
|||
obj.user_agent = user_agent # Update user agent on each login |
|||
obj.client_ip = client_ip # Update IP on each login |
|||
obj.save() |
|||
|
|||
# Create login history |
|||
login_history_obj = obj.login_history.create( |
|||
ip=client_ip, |
|||
user_agent=user_agent, |
|||
timezone=user_timezone, |
|||
device_os='web', |
|||
) |
|||
return obj |
|||
|
|||
|
|||
|
|||
class UserRegisterView(CreateAPIView): |
|||
permission_classes = [AllowAny] |
|||
authentication_classes = [] |
|||
serializer_class = UserRegisterSerializer |
|||
|
|||
|
|||
@swagger_auto_schema( |
|||
operation_description=doc_register(), |
|||
request_body=UserRegisterSerializer, |
|||
) |
|||
def post(self, request): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
data = serializer.validated_data |
|||
|
|||
code = RedisManager.generate_otp_code() |
|||
logger.info(f"phone= {data['email']}") |
|||
print(f'send {code}/{data["email"]}') |
|||
phone_number = RedisManager().add_to_redis(code, **data) |
|||
try: |
|||
send_email([data['email']], code) |
|||
except Exception as exp: |
|||
print(f'-exp-register-->{exp}') |
|||
return Response( |
|||
data= { |
|||
"user": data, |
|||
"message": "The otp code was sent to the user's email" |
|||
}, |
|||
status=status.HTTP_202_ACCEPTED, |
|||
) |
|||
|
|||
|
|||
|
|||
class UserVerifyView(CreateAPIView): |
|||
permission_classes = [AllowAny] |
|||
authentication_classes = [] |
|||
serializer_class = UserVerifySerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description=doc_verify(), |
|||
request_body=UserVerifySerializer, |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
print(f'-UserVerifyView-> {request.data}') |
|||
return super().post(request, *args, **kwargs) |
|||
|
|||
def create(self, request, *args, **kwargs): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
data = serializer.data |
|||
print(f'--UserVerifyView---1--') |
|||
try: |
|||
verify_data = RedisManager().get_by_redis(data['email']) |
|||
if not verify_data: |
|||
raise ValidationError({"code": "Verification data not found or expired."}) |
|||
# raise ExpiredCodeException("Verification data not found or expired.") |
|||
except (ServiceUnavailableException) as e: |
|||
return AppAPIException({"message": str(e)}, status_code=e.status_code) |
|||
except ExpiredCodeException: |
|||
# raise ExpiredCodeException("The verification code has expired.") |
|||
raise ValidationError({"code": "The verification code has expired."}) |
|||
|
|||
code = self.valied_code(data['code'], verify_data.get('code')) |
|||
verify_data.pop('code', None) |
|||
email = serializer.data['email'] |
|||
device_id = serializer.data.get('device_id') |
|||
verify_data.pop('email', None) |
|||
verify_data.pop('device_id', None) |
|||
user = self.perform_create( |
|||
email=email, |
|||
device_id=device_id, |
|||
client_ip=get_client_ip(request), |
|||
user_agent=request.META.get('HTTP_USER_AGENT', ''), |
|||
device_os=request.data.get('device_os'), |
|||
**verify_data |
|||
) |
|||
token, _ = Token.objects.get_or_create(user=user) |
|||
return Response(data={ |
|||
'token': str(token.key), |
|||
'user_id': user.id, |
|||
'phone_number': str(user.phone_number) if user.phone_number else None, |
|||
'email': str(user.email), |
|||
'fullname': str(user.fullname), |
|||
'avatar': str(user.avatar) if user.avatar else None |
|||
}, status=status.HTTP_201_CREATED) |
|||
|
|||
def valied_code(self, current_code, save_code): |
|||
if not current_code or not save_code or str(current_code) != str(save_code): |
|||
raise ValidationError({"code": "code notfound"}) |
|||
|
|||
return current_code |
|||
|
|||
def perform_create(self, *args, **kwargs): |
|||
email = kwargs.get('email') |
|||
device_id = kwargs.get('device_id') |
|||
password = kwargs.get('password') |
|||
client_ip = kwargs.get('client_ip') |
|||
user_agent = kwargs.get('user_agent') |
|||
device_os = kwargs.get('device_os') |
|||
if not device_os and user_agent: |
|||
device_os = detect_device_os(user_agent) |
|||
|
|||
create_kwargs = dict(kwargs) |
|||
create_kwargs.pop('password', None) |
|||
create_kwargs.pop('client_ip', None) |
|||
create_kwargs.pop('user_agent', None) |
|||
create_kwargs.pop('device_os', None) |
|||
|
|||
user = User.objects.filter(email=email).first() |
|||
if user: |
|||
if password and str(password).lower() != 'none': |
|||
user.set_password(password) |
|||
user.is_active = True |
|||
user.deleted_at = None |
|||
if device_id: |
|||
user.device_id = device_id |
|||
if client_ip: |
|||
user.client_ip = client_ip |
|||
if user_agent: |
|||
user.user_agent = user_agent |
|||
if device_os: |
|||
user.device_os = device_os |
|||
user.last_login = timezone.now() |
|||
user.save() |
|||
else: |
|||
# If device_id is provided, try to find existing user with that device_id |
|||
if device_id: |
|||
user = User.objects.filter(device_id=device_id, email__isnull=True).first() |
|||
else: |
|||
user = None |
|||
|
|||
if not user: |
|||
user = User(**create_kwargs) |
|||
if password and str(password).lower() != 'none': |
|||
user.set_password(password) |
|||
else: |
|||
user.set_unusable_password() |
|||
else: |
|||
user.email = email |
|||
user.fullname = kwargs.get('fullname') |
|||
if password and str(password).lower() != 'none': |
|||
user.set_password(password) |
|||
|
|||
if device_id: |
|||
user.device_id = device_id |
|||
if client_ip: |
|||
user.client_ip = client_ip |
|||
if user_agent: |
|||
user.user_agent = user_agent |
|||
if device_os: |
|||
user.device_os = device_os |
|||
user.last_login = timezone.now() |
|||
user.is_active = True |
|||
user.deleted_at = None |
|||
user.save() |
|||
|
|||
user.login_history.create( |
|||
ip=client_ip, |
|||
user_agent=user_agent, |
|||
device_os=user.device_os or device_os, |
|||
) |
|||
|
|||
return user |
|||
|
|||
|
|||
class WebUserRegisterView(CreateAPIView): |
|||
permission_classes = [AllowAny] |
|||
authentication_classes = [] |
|||
serializer_class = WebUserRegisterSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Web registration with password and confirmation", |
|||
request_body=WebUserRegisterSerializer, |
|||
) |
|||
def post(self, request): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
data = serializer.validated_data |
|||
|
|||
code = RedisManager.generate_otp_code() |
|||
logger.info(f"phone= {data['email']}") |
|||
print(f'send {code}/{data["email"]}') |
|||
# Store all registration data including password in Redis |
|||
RedisManager().add_to_redis(code, **data) |
|||
try: |
|||
send_email([data['email']], code) |
|||
except Exception as exp: |
|||
print(f'-exp-register-->{exp}') |
|||
return Response( |
|||
data={ |
|||
"user": { |
|||
"id": data.get('id'), |
|||
"fullname": data.get('fullname'), |
|||
"email": data.get('email'), |
|||
}, |
|||
"message": "The otp code was sent to the user's email" |
|||
}, |
|||
status=status.HTTP_202_ACCEPTED, |
|||
) |
|||
|
|||
|
|||
class UserLoginView(CreateAPIView): |
|||
permission_classes = [AllowAny] |
|||
authentication_classes = [] |
|||
serializer_class = UserLoginSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description=doc_login(), |
|||
request_body=UserLoginSerializer, |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
return super().post(request, *args, **kwargs) |
|||
def get_client_ip(self): |
|||
return get_client_ip(self.request) |
|||
|
|||
def create(self, request, *args, **kwargs): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
data = serializer.validated_data |
|||
|
|||
# Normalize email |
|||
raw_email = data.get('email') or request.data.get('email', '') |
|||
email = User.objects.normalize_email(raw_email.strip()) |
|||
password = data.get('password') or request.data.get('password', '') |
|||
|
|||
# Check if user with this email exists |
|||
user_obj = User.objects.filter(email__iexact=email, deleted_at__isnull=True).first() |
|||
if not user_obj: |
|||
raise ValidationError({"email": "user not exists with this email"}) |
|||
|
|||
# Authenticate with matching user email or normalized email |
|||
user = authenticate(request, username=user_obj.email, password=password) |
|||
if not user: |
|||
user = authenticate(request, username=email, password=password) |
|||
|
|||
if not user: |
|||
raise ValidationError({"password": "password is incorrect"}) |
|||
|
|||
client_ip = get_client_ip(request) |
|||
user_agent = request.META.get('HTTP_USER_AGENT', '') |
|||
device_os = request.data.get('device_os') |
|||
if not device_os and user_agent: |
|||
device_os = detect_device_os(user_agent) |
|||
device_id = request.data.get('device_id') |
|||
|
|||
user_timezone = serializer.validated_data.pop('timezone', None) |
|||
user.last_login = timezone.now() |
|||
user.is_active = True |
|||
if client_ip: |
|||
user.client_ip = client_ip |
|||
if user_agent: |
|||
user.user_agent = user_agent |
|||
if device_os: |
|||
user.device_os = device_os |
|||
if device_id: |
|||
user.device_id = device_id |
|||
user.save() |
|||
token, created = Token.objects.get_or_create(user=user) |
|||
|
|||
login_history_obj = user.login_history.create( |
|||
ip=client_ip, |
|||
timezone=user_timezone, |
|||
user_agent=user_agent, |
|||
device_os=user.device_os or device_os, |
|||
) |
|||
return Response({ |
|||
"id": user.id, |
|||
"fullname": user.fullname, |
|||
"email": user.email, |
|||
"token": token.key, |
|||
"user_type": user.user_type, |
|||
"avatar": absolute_https_url(user.avatar.url, request) if user.avatar else None, |
|||
}, status=status.HTTP_201_CREATED) |
|||
|
|||
|
|||
|
|||
class UserProfileView(RetrieveAPIView): |
|||
serializer_class = UserProfileSerializer |
|||
permission_classes = [IsAuthenticated, IsActiveUser] |
|||
authentication_classes = [TokenAuthentication] |
|||
queryset = User.objects.all() |
|||
|
|||
def get(self, request, *args, **kwargs): |
|||
logger.info(f'UserProfileView--> {request.data}') |
|||
return super().get(request, *args, **kwargs) |
|||
|
|||
def get_object(self): |
|||
return self.request.user |
|||
|
|||
|
|||
class UserUpdateView(UpdateAPIView): |
|||
permission_classes = [IsAuthenticated, IsActiveUser] |
|||
authentication_classes = [TokenAuthentication] |
|||
serializer_class = UserProfileSerializer |
|||
|
|||
def put(self, request, *args, **kwargs): |
|||
logger.info(f'UserProfileView--> {request.data}') |
|||
return super().put(request, *args, **kwargs) |
|||
|
|||
def get_object(self): |
|||
return self.request.user |
|||
|
|||
|
|||
class UserRecoverPassword(CreateAPIView): |
|||
permission_classes = [AllowAny] |
|||
authentication_classes = [] |
|||
serializer_class = UserRecoverPasswordSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description=doc_recover(), |
|||
request_body=UserRecoverPasswordSerializer, |
|||
) |
|||
def post(self, request): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
data = serializer.data |
|||
user = get_object_or_404(User, email=data['email']) |
|||
code = RedisManager.generate_otp_code() |
|||
print(f' send {code}') |
|||
phone_number = RedisManager().add_to_redis(code, fullname=str(user.fullname), password='', email=data['email']) |
|||
|
|||
try: |
|||
send_email([data['email']], code) |
|||
except Exception as exp: |
|||
print(f'-exp-register-->{exp}') |
|||
|
|||
return Response( |
|||
data= { |
|||
"id": user.id, |
|||
"fullname": user.fullname, |
|||
"phone_number": str(user.phone_number) if user.phone_number else None, |
|||
"email": user.email if user.email else None, |
|||
"avatar": absolute_https_url(user.avatar.url, request) if user.avatar else None, |
|||
"message": "Forgot password code sent" |
|||
}, |
|||
status=status.HTTP_202_ACCEPTED, |
|||
) |
|||
|
|||
|
|||
class UserResetPassword(CreateAPIView): |
|||
serializer_class = UserResetPasswordSerializer |
|||
permission_classes = [IsAuthenticated] |
|||
authentication_classes = [TokenAuthentication] |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description=doc_reset(), |
|||
request_body=UserResetPasswordSerializer, |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
# Get the logged-in user |
|||
user = request.user |
|||
|
|||
# Use the serializer to validate data |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
|
|||
# Set the new password |
|||
user.set_password(serializer.validated_data['password']) |
|||
user.save() |
|||
|
|||
# Return a success response |
|||
return Response({"message": "Your password has been changed successfully."}, status=status.HTTP_200_OK) |
|||
|
|||
|
|||
|
|||
|
|||
class UserDeleteView(APIView): |
|||
permission_classes = [IsAuthenticated] |
|||
authentication_classes = [TokenAuthentication] |
|||
|
|||
def delete(self, request, *args, **kwargs): |
|||
try: |
|||
user = request.user |
|||
if user.email == "[email protected]": |
|||
raise AppAPIException({"message": "Unable to log in with provided credentials."}, status_code=status.HTTP_204_NO_CONTENT) |
|||
|
|||
user.soft_delete() |
|||
if t := Token.objects.filter(user=user).first(): |
|||
t.delete() |
|||
|
|||
return Response({"detail": "Your account has been deleted."}, status=status.HTTP_204_NO_CONTENT) |
|||
|
|||
except Exception: |
|||
# پیام خطای ثابت برای سایر خطاهای غیرمنتظره |
|||
return Response({"detail": "User does not exist."}, status=status.HTTP_404_NOT_FOUND) |
|||
|
|||
|
|||
class UpdateFCMView(GenericAPIView): |
|||
permission_classes = [IsAuthenticated] |
|||
authentication_classes = [TokenAuthentication] |
|||
serializer_class = UserFCMSerializer |
|||
|
|||
def post(self, request, *args, **kwargs): |
|||
user = request.user |
|||
|
|||
fcm_token = request.data.get('fcm') |
|||
|
|||
if not fcm_token: |
|||
return Response({"detail": "FCM token is required."}, status=status.HTTP_200_OK) |
|||
|
|||
user.fcm = fcm_token |
|||
user.save() |
|||
|
|||
return Response({"detail": "FCM token updated successfully."}, status=status.HTTP_200_OK) |
|||
|
|||
|
|||
class AdminLoginView(CreateAPIView): |
|||
permission_classes = [AllowAny] |
|||
authentication_classes = [] |
|||
serializer_class = UserLoginSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Login specifically for Admin Panel users", |
|||
request_body=UserLoginSerializer, |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
return self.create(request, *args, **kwargs) |
|||
|
|||
def get_client_ip(self): |
|||
return get_client_ip(self.request) |
|||
|
|||
def create(self, request, *args, **kwargs): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
data = serializer.data |
|||
|
|||
email = request.data['email'].strip().lower() |
|||
try: |
|||
user_obj = User.objects.get(email__iexact=email) |
|||
except User.DoesNotExist: |
|||
raise ValidationError({"email": "No admin found with that information."}) |
|||
|
|||
# Use the actual stored email for authentication (ensures case matches DB) |
|||
user = authenticate(request, username=user_obj.email, password=data['password']) |
|||
if not user: |
|||
raise ValidationError({"password": "Password is incorrect"}) |
|||
|
|||
if not user.can_access_admin_panel(): |
|||
raise AuthenticationFailed("No admin found with that information.") |
|||
|
|||
client_ip = get_client_ip(request) |
|||
user_agent = request.META.get('HTTP_USER_AGENT', '') |
|||
user_timezone = serializer.validated_data.pop('timezone', None) |
|||
user.last_login = timezone.now() |
|||
user.is_active = True |
|||
if client_ip: |
|||
user.client_ip = client_ip |
|||
if user_agent: |
|||
user.user_agent = user_agent |
|||
user.device_os = 'web' |
|||
user.save() |
|||
|
|||
token, created = Token.objects.get_or_create(user=user) |
|||
|
|||
# Log the history |
|||
user.login_history.create( |
|||
ip=client_ip, |
|||
timezone=user_timezone, |
|||
user_agent=user_agent, |
|||
device_os='web_admin' |
|||
) |
|||
|
|||
if user.is_super_admin_panel_user(): |
|||
effective_user_type = 'super_admin' |
|||
elif user.is_admin_panel_user(): |
|||
effective_user_type = 'admin' |
|||
else: |
|||
effective_user_type = 'professor' |
|||
|
|||
return Response({ |
|||
"id": user.id, |
|||
"fullname": user.fullname, |
|||
"email": user.email, |
|||
"token": token.key, |
|||
"user_type": effective_user_type, |
|||
"avatar": absolute_https_url(user.avatar.url, request) if user.avatar else None, |
|||
}, status=status.HTTP_201_CREATED) |
|||
|
|||
|
|||
from rest_framework.viewsets import ModelViewSet |
|||
from rest_framework.decorators import action |
|||
from rest_framework.filters import SearchFilter, OrderingFilter |
|||
from django_filters.rest_framework import DjangoFilterBackend |
|||
from rest_framework.permissions import IsAdminUser |
|||
from utils.pagination import StandardResultsSetPagination |
|||
from utils.excel_exporter import export_to_excel_response |
|||
|
|||
class AdminUserViewSet(ModelViewSet): |
|||
""" |
|||
Admin-only endpoint for full CRUD operations on Users. |
|||
Includes searching, filtering, and pagination. |
|||
""" |
|||
serializer_class = AdminUserSerializer |
|||
permission_classes = [IsAuthenticated, IsSuperAdminOrReadOnlyForProfessor] |
|||
authentication_classes = [TokenAuthentication] |
|||
pagination_class = StandardResultsSetPagination |
|||
filter_backends = [] # Disable default backend filtering to use custom manual filtering |
|||
|
|||
@action(detail=False, methods=['post', 'get']) |
|||
def export_excel(self, request): |
|||
""" |
|||
Export users to an Excel (.xlsx) file. |
|||
Accepts user_ids in POST body for selective bulk export, |
|||
or uses current queryset filters if no specific IDs are provided. |
|||
""" |
|||
queryset = self.get_queryset() |
|||
user_ids = request.data.get('user_ids', None) if request.method == 'POST' else None |
|||
|
|||
if user_ids and isinstance(user_ids, list): |
|||
queryset = queryset.filter(id__in=user_ids) |
|||
|
|||
headers = [ |
|||
"ID", |
|||
"Full Name", |
|||
"Email", |
|||
"Phone Number", |
|||
"Date Joined", |
|||
"Last Login" |
|||
] |
|||
|
|||
rows = [] |
|||
for user in queryset: |
|||
rows.append([ |
|||
user.id, |
|||
user.fullname or "Unnamed", |
|||
user.email, |
|||
user.phone_number or "-", |
|||
user.date_joined, |
|||
user.last_login or "-" |
|||
]) |
|||
|
|||
timestamp = timezone.now().strftime("%Y%m%d_%H%M") |
|||
filename = f"users_export_{timestamp}.xlsx" |
|||
return export_to_excel_response(filename=filename, headers=headers, rows=rows, sheet_title="Users") |
|||
|
|||
def perform_destroy(self, instance): |
|||
instance.soft_delete() |
|||
|
|||
def get_queryset(self): |
|||
queryset = User.objects.filter(email__isnull=False, deleted_at__isnull=True).exclude(email='') |
|||
|
|||
|
|||
# Handle Search |
|||
search_query = self.request.query_params.get('search', None) |
|||
if search_query: |
|||
queryset = queryset.filter( |
|||
Q(fullname__icontains=search_query) | |
|||
Q(email__icontains=search_query) | |
|||
Q(phone_number__icontains=search_query) |
|||
) |
|||
|
|||
# Handle is_active filter |
|||
is_active_param = self.request.query_params.get('is_active', None) |
|||
if is_active_param is not None: |
|||
if is_active_param.lower() == 'true': |
|||
queryset = queryset.filter(is_active=True) |
|||
elif is_active_param.lower() == 'false': |
|||
queryset = queryset.filter(is_active=False) |
|||
|
|||
# Handle gender filter |
|||
gender_param = self.request.query_params.get('gender', None) |
|||
if gender_param: |
|||
queryset = queryset.filter(gender=gender_param) |
|||
|
|||
# Handle user type filter |
|||
user_type_param = self.request.query_params.get('user_type', None) |
|||
if user_type_param: |
|||
if user_type_param == 'student': |
|||
queryset = queryset.filter(Q(user_type='student') | Q(user_type='client')) |
|||
else: |
|||
queryset = queryset.filter(user_type=user_type_param) |
|||
|
|||
|
|||
# Handle Ordering |
|||
ordering_param = self.request.query_params.get('ordering', '-date_joined') |
|||
allowed_orderings = [ |
|||
'date_joined', '-date_joined', |
|||
'last_login', '-last_login', |
|||
'fullname', '-fullname' |
|||
] |
|||
if ordering_param in allowed_orderings: |
|||
queryset = queryset.order_by(ordering_param) |
|||
else: |
|||
queryset = queryset.order_by('-date_joined') |
|||
|
|||
return queryset.distinct() |
|||
|
|||
|
|||
class AdminUserDirectoryViewSet(ModelViewSet): |
|||
""" |
|||
Separate, dedicated endpoint for accounts/users list page. |
|||
Filters users who have an email, are not deleted (deleted_at is null). |
|||
""" |
|||
serializer_class = AdminUserSerializer |
|||
permission_classes = [IsAuthenticated, IsSuperAdminOrReadOnlyForProfessor] |
|||
authentication_classes = [TokenAuthentication] |
|||
pagination_class = StandardResultsSetPagination |
|||
filter_backends = [] |
|||
|
|||
@action(detail=False, methods=['post', 'get']) |
|||
def export_excel(self, request): |
|||
""" |
|||
Export users to an Excel (.xlsx) file. |
|||
Accepts user_ids in POST body for selective bulk export, |
|||
or uses current queryset filters if no specific IDs are provided. |
|||
""" |
|||
queryset = self.get_queryset() |
|||
user_ids = request.data.get('user_ids', None) if request.method == 'POST' else None |
|||
|
|||
if user_ids and isinstance(user_ids, list): |
|||
queryset = queryset.filter(id__in=user_ids) |
|||
|
|||
headers = [ |
|||
"ID", |
|||
"Full Name", |
|||
"Email", |
|||
"Phone Number", |
|||
"Role", |
|||
"Date Joined", |
|||
"Last Login" |
|||
] |
|||
|
|||
role_map = { |
|||
'professor': 'Professor', |
|||
'client': 'Student', |
|||
'student': 'Student', |
|||
'admin': 'Admin', |
|||
'super_admin': 'Super Admin', |
|||
'consultant': 'Consultant', |
|||
} |
|||
|
|||
rows = [] |
|||
for user in queryset: |
|||
role_label = role_map.get(user.user_type, user.user_type or "Student") |
|||
rows.append([ |
|||
user.id, |
|||
user.fullname or "Unnamed", |
|||
user.email, |
|||
user.phone_number or "-", |
|||
role_label, |
|||
user.date_joined, |
|||
user.last_login or "-" |
|||
]) |
|||
|
|||
timestamp = timezone.now().strftime("%Y%m%d_%H%M") |
|||
filename = f"users_export_{timestamp}.xlsx" |
|||
return export_to_excel_response(filename=filename, headers=headers, rows=rows, sheet_title="Users") |
|||
|
|||
def perform_destroy(self, instance): |
|||
instance.soft_delete() |
|||
|
|||
|
|||
def get_queryset(self): |
|||
# Filter users who have an email and are not soft-deleted |
|||
queryset = User.objects.filter(email__isnull=False, deleted_at__isnull=True).exclude(email='') |
|||
|
|||
|
|||
# Handle Search |
|||
search_query = self.request.query_params.get('search', None) |
|||
if search_query: |
|||
queryset = queryset.filter( |
|||
Q(fullname__icontains=search_query) | |
|||
Q(email__icontains=search_query) | |
|||
Q(phone_number__icontains=search_query) |
|||
) |
|||
|
|||
# Handle is_active filter |
|||
is_active_param = self.request.query_params.get('is_active', None) |
|||
if is_active_param is not None: |
|||
if is_active_param.lower() == 'true': |
|||
queryset = queryset.filter(is_active=True) |
|||
elif is_active_param.lower() == 'false': |
|||
queryset = queryset.filter(is_active=False) |
|||
|
|||
# Handle user type filter |
|||
user_type_param = self.request.query_params.get('user_type', None) |
|||
if user_type_param: |
|||
if user_type_param == 'student': |
|||
queryset = queryset.filter(Q(user_type='student') | Q(user_type='client')) |
|||
else: |
|||
queryset = queryset.filter(user_type=user_type_param) |
|||
|
|||
# Handle Ordering |
|||
ordering_param = self.request.query_params.get('ordering', '-date_joined') |
|||
allowed_orderings = [ |
|||
'date_joined', '-date_joined', |
|||
'last_login', '-last_login', |
|||
'fullname', '-fullname' |
|||
] |
|||
if ordering_param in allowed_orderings: |
|||
queryset = queryset.order_by(ordering_param) |
|||
else: |
|||
queryset = queryset.order_by('-date_joined') |
|||
|
|||
return queryset.distinct() |
|||
@ -0,0 +1,28 @@ |
|||
from django.contrib import admin |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from unfold.admin import ModelAdmin |
|||
from unfold.decorators import display |
|||
|
|||
from .models import AppVersion, SupportMessage |
|||
from utils.admin import project_admin_site |
|||
|
|||
|
|||
@admin.register(AppVersion) |
|||
class AppVersionAdmin(ModelAdmin): |
|||
list_display = ['version', 'app_type', 'is_active', 'downloads_count', 'created_at'] |
|||
list_filter = ['app_type', 'is_active'] |
|||
search_fields = ['version', 'description'] |
|||
ordering = ['-created_at'] |
|||
|
|||
|
|||
@admin.register(SupportMessage) |
|||
class SupportMessageAdmin(ModelAdmin): |
|||
list_display = ['sender_name', 'sender_email', 'subject', 'is_resolved', 'created_at'] |
|||
list_filter = ['is_resolved', 'created_at'] |
|||
search_fields = ['sender_name', 'sender_email', 'subject', 'message'] |
|||
ordering = ['-created_at'] |
|||
|
|||
|
|||
# Register to Unfold custom admin site |
|||
project_admin_site.register(AppVersion, AppVersionAdmin) |
|||
project_admin_site.register(SupportMessage, SupportMessageAdmin) |
|||
@ -0,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class ApiConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.api' |
|||
@ -0,0 +1,42 @@ |
|||
from functools import wraps |
|||
from django.http import HttpResponseForbidden |
|||
from django.contrib.auth.models import AnonymousUser |
|||
from django.views.decorators.csrf import csrf_exempt |
|||
from rest_framework.authtoken.models import Token |
|||
|
|||
|
|||
def swagger_auth_required(view_func): |
|||
""" |
|||
Decorator that requires either admin authentication or valid swagger token |
|||
""" |
|||
@csrf_exempt |
|||
@wraps(view_func) |
|||
def _wrapped_view(request, *args, **kwargs): |
|||
# Check if user is admin |
|||
if request.user and request.user.is_authenticated and request.user.is_staff: |
|||
return view_func(request, *args, **kwargs) |
|||
|
|||
# Check swagger token in session |
|||
swagger_token = request.session.get('swagger_token') |
|||
if swagger_token: |
|||
try: |
|||
token_obj = Token.objects.get(key=swagger_token) |
|||
if token_obj.user.is_active: |
|||
return view_func(request, *args, **kwargs) |
|||
except Token.DoesNotExist: |
|||
pass |
|||
|
|||
# Check Authorization header |
|||
auth_header = request.META.get('HTTP_AUTHORIZATION', '') |
|||
if auth_header.startswith('Token '): |
|||
token = auth_header.split(' ')[1] |
|||
try: |
|||
token_obj = Token.objects.get(key=token) |
|||
if token_obj.user.is_active: |
|||
return view_func(request, *args, **kwargs) |
|||
except Token.DoesNotExist: |
|||
pass |
|||
|
|||
return HttpResponseForbidden("Access denied. Admin authentication or valid token required.") |
|||
|
|||
return _wrapped_view |
|||
@ -0,0 +1,52 @@ |
|||
import django.core.validators |
|||
from django.db import migrations, models |
|||
import phonenumber_field.modelfields |
|||
import utils.validators |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
initial = True |
|||
|
|||
dependencies = [ |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.CreateModel( |
|||
name='AppVersion', |
|||
fields=[ |
|||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('version', models.CharField(help_text='Application version in format X.Y.Z (e.g., 1.0.0)', max_length=20, unique=True, validators=[django.core.validators.RegexValidator(message='Version must be in format X.Y.Z (e.g., 1.0.0)', regex='^\\d+\\.\\d+\\.\\d+$')], verbose_name='Version')), |
|||
('apk_file', models.FileField(blank=True, help_text='Application APK / binary file', null=True, upload_to='app_versions/', verbose_name='APK File')), |
|||
('description', models.TextField(blank=True, help_text='Release notes and changes for this version', verbose_name='Description')), |
|||
('app_type', models.CharField(choices=[('google_play', 'Google Play'), ('app_store', 'Apple App Store'), ('direct', 'Direct Download')], default='google_play', max_length=20, verbose_name='App Distribution Platform')), |
|||
('downloads_count', models.PositiveBigIntegerField(default=0, verbose_name='Downloads Count')), |
|||
('is_active', models.BooleanField(default=True, help_text='Is this version currently active?', verbose_name='Active')), |
|||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
|||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'App Version', |
|||
'verbose_name_plural': 'App Versions', |
|||
'ordering': ['-created_at'], |
|||
}, |
|||
), |
|||
migrations.CreateModel( |
|||
name='SupportMessage', |
|||
fields=[ |
|||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('sender_name', models.CharField(max_length=255, verbose_name='Sender Name')), |
|||
('sender_email', models.EmailField(max_length=254, verbose_name='Sender Email')), |
|||
('subject', models.CharField(max_length=255, verbose_name='Subject')), |
|||
('sender_phone', phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='e.g., +1 555 1234567', max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='Sender Phone')), |
|||
('message', models.TextField(verbose_name='Message')), |
|||
('is_resolved', models.BooleanField(default=False, verbose_name='Resolved')), |
|||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Support Message', |
|||
'verbose_name_plural': 'Support Messages', |
|||
'ordering': ['-created_at'], |
|||
}, |
|||
), |
|||
] |
|||
@ -0,0 +1,125 @@ |
|||
from django.db import models |
|||
from django.conf import settings |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from django.core.validators import RegexValidator |
|||
from phonenumber_field.modelfields import PhoneNumberField |
|||
from utils.validators import validate_possible_number |
|||
|
|||
|
|||
class AppVersion(models.Model): |
|||
""" |
|||
Model for storing mobile application releases and APK downloads |
|||
""" |
|||
class AppType(models.TextChoices): |
|||
GOOGLE_PLAY = 'google_play', _('Google Play') |
|||
APP_STORE = 'app_store', _('Apple App Store') |
|||
DIRECT = 'direct', _('Direct Download') |
|||
|
|||
version = models.CharField( |
|||
max_length=20, |
|||
unique=True, |
|||
validators=[ |
|||
RegexValidator( |
|||
regex=r'^\d+\.\d+\.\d+$', |
|||
message='Version must be in format X.Y.Z (e.g., 1.0.0)' |
|||
) |
|||
], |
|||
verbose_name=_('Version'), |
|||
help_text=_('Application version in format X.Y.Z (e.g., 1.0.0)') |
|||
) |
|||
|
|||
apk_file = models.FileField( |
|||
upload_to='app_versions/', |
|||
verbose_name=_('APK File'), |
|||
help_text=_('Application APK / binary file'), |
|||
null=True, |
|||
blank=True, |
|||
) |
|||
|
|||
description = models.TextField( |
|||
verbose_name=_('Description'), |
|||
help_text=_('Release notes and changes for this version'), |
|||
blank=True |
|||
) |
|||
|
|||
app_type = models.CharField( |
|||
max_length=20, |
|||
choices=AppType.choices, |
|||
default=AppType.GOOGLE_PLAY, |
|||
verbose_name=_('App Distribution Platform') |
|||
) |
|||
|
|||
downloads_count = models.PositiveBigIntegerField( |
|||
default=0, |
|||
verbose_name=_('Downloads Count') |
|||
) |
|||
|
|||
is_active = models.BooleanField( |
|||
default=True, |
|||
verbose_name=_('Active'), |
|||
help_text=_('Is this version currently active?') |
|||
) |
|||
|
|||
created_at = models.DateTimeField( |
|||
auto_now_add=True, |
|||
verbose_name=_('Created At') |
|||
) |
|||
|
|||
updated_at = models.DateTimeField( |
|||
auto_now=True, |
|||
verbose_name=_('Updated At') |
|||
) |
|||
|
|||
class Meta: |
|||
verbose_name = _('App Version') |
|||
verbose_name_plural = _('App Versions') |
|||
ordering = ['-created_at'] |
|||
|
|||
def __str__(self): |
|||
return f'Version {self.version} ({self.get_app_type_display()})' |
|||
|
|||
@classmethod |
|||
def get_latest_active(cls): |
|||
return cls.objects.filter(is_active=True).order_by('-created_at').first() |
|||
|
|||
|
|||
class SupportMessage(models.Model): |
|||
""" |
|||
Model for user contact & support inquiries |
|||
""" |
|||
sender_name = models.CharField( |
|||
max_length=255, |
|||
verbose_name=_('Sender Name'), |
|||
) |
|||
sender_email = models.EmailField( |
|||
verbose_name=_('Sender Email'), |
|||
) |
|||
subject = models.CharField( |
|||
max_length=255, |
|||
verbose_name=_('Subject'), |
|||
) |
|||
sender_phone = PhoneNumberField( |
|||
validators=[validate_possible_number], |
|||
blank=True, |
|||
null=True, |
|||
verbose_name=_('Sender Phone'), |
|||
) |
|||
message = models.TextField( |
|||
verbose_name=_('Message'), |
|||
) |
|||
is_resolved = models.BooleanField( |
|||
default=False, |
|||
verbose_name=_('Resolved'), |
|||
) |
|||
created_at = models.DateTimeField( |
|||
auto_now_add=True, |
|||
verbose_name=_('Created At'), |
|||
) |
|||
|
|||
class Meta: |
|||
verbose_name = _('Support Message') |
|||
verbose_name_plural = _('Support Messages') |
|||
ordering = ['-created_at'] |
|||
|
|||
def __str__(self): |
|||
return f'{self.sender_name} - {self.subject}' |
|||
@ -0,0 +1,71 @@ |
|||
from rest_framework import permissions |
|||
from rest_framework.authtoken.models import Token |
|||
from django.contrib.auth.models import AnonymousUser |
|||
|
|||
|
|||
class SwaggerTokenPermission(permissions.BasePermission): |
|||
""" |
|||
Custom permission for Swagger that allows access to authenticated users via token |
|||
or admin users via session authentication |
|||
""" |
|||
|
|||
def has_permission(self, request, view): |
|||
# Check if user is admin (for session-based access) |
|||
if request.user and request.user.is_authenticated and request.user.is_staff: |
|||
return True |
|||
|
|||
# Check for token in session (from our custom auth system) |
|||
swagger_token = request.session.get('swagger_token') |
|||
if swagger_token: |
|||
try: |
|||
token_obj = Token.objects.get(key=swagger_token) |
|||
if token_obj.user.is_active: |
|||
return True |
|||
except Token.DoesNotExist: |
|||
pass |
|||
|
|||
# Check for Authorization header |
|||
auth_header = request.META.get('HTTP_AUTHORIZATION', '') |
|||
if auth_header.startswith('Token '): |
|||
token = auth_header.split(' ')[1] |
|||
try: |
|||
token_obj = Token.objects.get(key=token) |
|||
if token_obj.user.is_active: |
|||
return True |
|||
except Token.DoesNotExist: |
|||
pass |
|||
|
|||
return False |
|||
|
|||
|
|||
class IsAdminOrSwaggerToken(permissions.BasePermission): |
|||
""" |
|||
Permission that allows access to admin users or users with valid swagger token |
|||
""" |
|||
|
|||
def has_permission(self, request, view): |
|||
# Allow admin users |
|||
if request.user and request.user.is_authenticated and request.user.is_staff: |
|||
return True |
|||
|
|||
# Check swagger token in session |
|||
swagger_token = request.session.get('swagger_token') |
|||
if swagger_token: |
|||
try: |
|||
token_obj = Token.objects.get(key=swagger_token) |
|||
return token_obj.user.is_active |
|||
except Token.DoesNotExist: |
|||
pass |
|||
|
|||
return False |
|||
|
|||
|
|||
class IsProfessorUser(permissions.BasePermission): |
|||
""" |
|||
Permission that allows access only to professors or admin users. |
|||
""" |
|||
|
|||
def has_permission(self, request, view): |
|||
if not (request.user and request.user.is_authenticated): |
|||
return False |
|||
return request.user.user_type == 'professor' or request.user.is_staff |
|||
@ -0,0 +1,36 @@ |
|||
from rest_framework import serializers |
|||
from .models import AppVersion, SupportMessage |
|||
|
|||
|
|||
class AppVersionSerializer(serializers.ModelSerializer): |
|||
apk_file = serializers.FileField(read_only=True) |
|||
|
|||
class Meta: |
|||
model = AppVersion |
|||
fields = [ |
|||
'id', |
|||
'version', |
|||
'apk_file', |
|||
'description', |
|||
'app_type', |
|||
'downloads_count', |
|||
'is_active', |
|||
'created_at', |
|||
'updated_at', |
|||
] |
|||
read_only_fields = ['id', 'created_at', 'updated_at'] |
|||
|
|||
|
|||
class SupportMessageCreateSerializer(serializers.ModelSerializer): |
|||
class Meta: |
|||
model = SupportMessage |
|||
fields = [ |
|||
'id', |
|||
'sender_name', |
|||
'sender_email', |
|||
'subject', |
|||
'sender_phone', |
|||
'message', |
|||
'created_at', |
|||
] |
|||
read_only_fields = ['id', 'created_at'] |
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -0,0 +1,8 @@ |
|||
from django.urls import path |
|||
from .views import HealthCheckView, AppVersionView, SupportMessageCreateView |
|||
|
|||
urlpatterns = [ |
|||
path('health/', HealthCheckView.as_view(), name='health-check'), |
|||
path('version/', AppVersionView.as_view(), name='app-version'), |
|||
path('contact-us/', SupportMessageCreateView.as_view(), name='contact-us'), |
|||
] |
|||
@ -0,0 +1,2 @@ |
|||
# Legacy views - moved to views/api_views.py for better organization |
|||
from .views.api_views import HomeView, CountryView, CommentListAPIView |
|||
@ -0,0 +1,14 @@ |
|||
from .api_views import HealthCheckView, HomeView, AppVersionView, SupportMessageCreateView |
|||
from .swagger_views import CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth |
|||
from .admin_dashboard import AdminDashboardStatsView |
|||
|
|||
__all__ = [ |
|||
'HealthCheckView', |
|||
'HomeView', |
|||
'AppVersionView', |
|||
'SupportMessageCreateView', |
|||
'CustomSwaggerView', |
|||
'SwaggerTokenAuthView', |
|||
'clear_swagger_auth', |
|||
'AdminDashboardStatsView', |
|||
] |
|||
@ -0,0 +1,61 @@ |
|||
from rest_framework.views import APIView |
|||
from rest_framework.response import Response |
|||
from rest_framework.permissions import IsAuthenticated, IsAdminUser |
|||
from django.utils import timezone |
|||
from datetime import timedelta |
|||
from django.db.models import Count |
|||
from django.db.models.functions import TruncDate |
|||
from rest_framework.authentication import TokenAuthentication |
|||
|
|||
from apps.account.models import User, LoginHistory |
|||
from apps.api.models import AppVersion, SupportMessage |
|||
|
|||
|
|||
class AdminDashboardStatsView(APIView): |
|||
authentication_classes = [TokenAuthentication] |
|||
permission_classes = [IsAuthenticated, IsAdminUser] |
|||
|
|||
def get(self, request): |
|||
now = timezone.now() |
|||
thirty_days_ago = now - timedelta(days=30) |
|||
seven_days_ago = now - timedelta(days=7) |
|||
|
|||
# 1. User metrics |
|||
total_users = User.objects.count() |
|||
active_users = User.objects.filter(is_active=True).count() |
|||
new_users_30d = User.objects.filter(date_joined__gte=thirty_days_ago).count() |
|||
staff_users = User.objects.filter(is_staff=True).count() |
|||
|
|||
# 2. Login activity |
|||
logins_7d = LoginHistory.objects.filter(at_time__gte=seven_days_ago).count() |
|||
login_chart_qs = ( |
|||
LoginHistory.objects.filter(at_time__gte=seven_days_ago) |
|||
.annotate(date=TruncDate('at_time')) |
|||
.values('date') |
|||
.annotate(count=Count('id')) |
|||
.order_by('date') |
|||
) |
|||
login_chart = [ |
|||
{"date": entry['date'].strftime('%Y-%m-%d'), "count": entry['count']} |
|||
for entry in login_chart_qs |
|||
] |
|||
|
|||
# 3. System info |
|||
latest_version = AppVersion.get_latest_active() |
|||
unresolved_support = SupportMessage.objects.filter(is_resolved=False).count() |
|||
|
|||
data = { |
|||
"summary": { |
|||
"total_users": total_users, |
|||
"active_users": active_users, |
|||
"new_users_30d": new_users_30d, |
|||
"staff_users": staff_users, |
|||
"logins_7d": logins_7d, |
|||
"unresolved_support": unresolved_support, |
|||
"latest_app_version": latest_version.version if latest_version else None, |
|||
}, |
|||
"charts": { |
|||
"logins_by_day": login_chart, |
|||
} |
|||
} |
|||
return Response(data) |
|||
@ -0,0 +1,74 @@ |
|||
from rest_framework import status |
|||
from rest_framework.views import APIView |
|||
from rest_framework.generics import CreateAPIView, GenericAPIView |
|||
from rest_framework.response import Response |
|||
from rest_framework.permissions import AllowAny |
|||
from django.utils import timezone |
|||
from drf_yasg.utils import swagger_auto_schema |
|||
from drf_yasg import openapi |
|||
|
|||
from apps.api.models import AppVersion, SupportMessage |
|||
from apps.api.serializers import AppVersionSerializer, SupportMessageCreateSerializer |
|||
|
|||
|
|||
class HealthCheckView(APIView): |
|||
""" |
|||
Health check endpoint returning system status and current timestamp |
|||
""" |
|||
permission_classes = [AllowAny] |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Check API server health status", |
|||
responses={ |
|||
200: openapi.Response( |
|||
description="Server is healthy", |
|||
examples={ |
|||
"application/json": { |
|||
"status": "healthy", |
|||
"timestamp": "2026-09-13T12:00:00Z", |
|||
"version": "1.0.0" |
|||
} |
|||
} |
|||
) |
|||
} |
|||
) |
|||
def get(self, request): |
|||
return Response({ |
|||
"status": "healthy", |
|||
"timestamp": timezone.now().isoformat(), |
|||
"version": "1.0.0", |
|||
}, status=status.HTTP_200_OK) |
|||
|
|||
|
|||
class AppVersionView(APIView): |
|||
""" |
|||
Returns latest active application version |
|||
""" |
|||
permission_classes = [AllowAny] |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Get the latest active mobile application version", |
|||
responses={ |
|||
200: AppVersionSerializer(), |
|||
404: openapi.Response("No active version found") |
|||
} |
|||
) |
|||
def get(self, request): |
|||
version = AppVersion.get_latest_active() |
|||
if not version: |
|||
return Response({"detail": "No active version found."}, status=status.HTTP_404_NOT_FOUND) |
|||
serializer = AppVersionSerializer(version, context={'request': request}) |
|||
return Response(serializer.data) |
|||
|
|||
|
|||
class SupportMessageCreateView(CreateAPIView): |
|||
""" |
|||
Submit a user contact or support inquiry |
|||
""" |
|||
permission_classes = [AllowAny] |
|||
serializer_class = SupportMessageCreateSerializer |
|||
queryset = SupportMessage.objects.all() |
|||
|
|||
|
|||
# HomeView alias for backward compatibility |
|||
HomeView = HealthCheckView |
|||
@ -0,0 +1,83 @@ |
|||
from django.shortcuts import render, redirect |
|||
from django.views import View |
|||
from django.contrib import messages |
|||
from django.contrib.admin.views.decorators import staff_member_required |
|||
from django.utils.decorators import method_decorator |
|||
from django.views.decorators.csrf import csrf_exempt |
|||
from django.urls import reverse |
|||
from rest_framework.authtoken.models import Token |
|||
|
|||
@method_decorator([staff_member_required, csrf_exempt], name='dispatch') |
|||
class CustomSwaggerView(View): |
|||
""" |
|||
Custom Swagger UI view with authentication banner |
|||
Requires admin login to access |
|||
""" |
|||
def get(self, request): |
|||
# Generate dynamic swagger spec URL based on current language |
|||
try: |
|||
swagger_spec_url = reverse('schema-json', kwargs={'format': '.json'}) |
|||
except: |
|||
# Fallback to hardcoded URL if reverse fails |
|||
swagger_spec_url = '/en/swagger.json' |
|||
|
|||
context = { |
|||
'swagger_spec_url': swagger_spec_url, |
|||
'request': request, |
|||
} |
|||
return render(request, 'swagger/ui.html', context) |
|||
|
|||
@method_decorator(staff_member_required, name='dispatch') |
|||
class SwaggerTokenAuthView(View): |
|||
""" |
|||
Token authentication management for Swagger |
|||
""" |
|||
def get(self, request): |
|||
context = { |
|||
'current_token': request.session.get('swagger_token'), |
|||
'user_info': request.session.get('swagger_user_info'), |
|||
} |
|||
return render(request, 'swagger/auth.html', context) |
|||
|
|||
def post(self, request): |
|||
token = request.POST.get('token', '').strip() |
|||
|
|||
if not token or len(token) != 40: |
|||
messages.error(request, 'Token must be exactly 40 characters long') |
|||
return redirect('swagger-token-auth') |
|||
|
|||
try: |
|||
token_obj = Token.objects.get(key=token) |
|||
user = token_obj.user |
|||
|
|||
if not user.is_active: |
|||
messages.error(request, 'User account is not active') |
|||
return redirect('swagger-token-auth') |
|||
|
|||
request.session['swagger_token'] = token |
|||
request.session['swagger_user_info'] = { |
|||
'id': user.id, |
|||
'email': user.email, |
|||
'fullname': getattr(user, 'fullname', user.email), |
|||
'is_staff': user.is_staff, |
|||
'is_superuser': user.is_superuser, |
|||
'user_type': 'User' |
|||
} |
|||
|
|||
messages.success(request, f'Successfully authenticated as {user.email}') |
|||
return redirect('schema-swagger-ui') |
|||
|
|||
except Token.DoesNotExist: |
|||
messages.error(request, 'Invalid token') |
|||
return redirect('swagger-token-auth') |
|||
|
|||
@staff_member_required |
|||
def clear_swagger_auth(request): |
|||
"""Clear swagger authentication from session""" |
|||
if 'swagger_token' in request.session: |
|||
del request.session['swagger_token'] |
|||
if 'swagger_user_info' in request.session: |
|||
del request.session['swagger_user_info'] |
|||
|
|||
messages.success(request, 'Successfully logged out from Swagger') |
|||
return redirect('swagger-token-auth') |
|||
@ -0,0 +1,29 @@ |
|||
{ |
|||
"token_hmac_secret_key": "super_secret_centrifugo_token_key_hmac_256", |
|||
"api_key": "centrifugo_internal_api_access_key", |
|||
"allowed_origins": [ |
|||
"https://shiadeepconnect.org", |
|||
"http://localhost:5173", |
|||
"http://localhost:3000", |
|||
"http://127.0.0.1:5173", |
|||
"http://127.0.0.1:3000" |
|||
], |
|||
"namespaces": [ |
|||
{ |
|||
"name": "chat", |
|||
"presence": true, |
|||
"history_size": 100, |
|||
"history_ttl": "7d", |
|||
"publish": false, |
|||
"anonymous": false |
|||
}, |
|||
{ |
|||
"name": "notifications", |
|||
"presence": false, |
|||
"history_size": 20, |
|||
"history_ttl": "24h", |
|||
"publish": false, |
|||
"anonymous": false |
|||
} |
|||
] |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
# __init__.py |
|||
from __future__ import absolute_import, unicode_literals |
|||
|
|||
# This will make sure the app is always imported when |
|||
# Django starts so that shared_task will use this app. |
|||
from .celery import app as celery_app |
|||
|
|||
__all__ = ('celery_app',) |
|||
@ -0,0 +1,16 @@ |
|||
""" |
|||
ASGI config for backend project. |
|||
|
|||
It exposes the ASGI callable as a module-level variable named ``application``. |
|||
|
|||
For more information on this file, see |
|||
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ |
|||
""" |
|||
|
|||
import os |
|||
|
|||
from django.core.asgi import get_asgi_application |
|||
|
|||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') |
|||
|
|||
application = get_asgi_application() |
|||
@ -0,0 +1,22 @@ |
|||
import os |
|||
|
|||
import environ |
|||
|
|||
from celery import Celery |
|||
|
|||
env = environ.Env() |
|||
environ.Env.read_env(os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env')) |
|||
|
|||
# Set the default Django settings module for the 'celery' program. |
|||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') |
|||
|
|||
app = Celery('config') |
|||
# Using a string here means the worker doesn't have to serialize |
|||
# the configuration object to child processes. |
|||
# - namespace='CELERY' means all celery-related configuration keys |
|||
# should have a `CELERY_` prefix. |
|||
app.config_from_object('django.conf:settings', namespace='CELERY') |
|||
|
|||
# Load task modules from all registered Django apps. |
|||
app.autodiscover_tasks() |
|||
|
|||
@ -0,0 +1,66 @@ |
|||
from rest_framework.authtoken.models import Token |
|||
from django.contrib.auth import get_user_model |
|||
from django.shortcuts import redirect |
|||
from django.urls import reverse |
|||
from django.contrib import messages |
|||
|
|||
User = get_user_model() |
|||
|
|||
|
|||
def enhanced_auth_middleware(get_response): |
|||
""" |
|||
Enhanced middleware for API authentication with admin restriction |
|||
Handles custom documentation system authentication |
|||
""" |
|||
def middleware(request): |
|||
# Define protected paths that require staff access |
|||
protected_paths = ["/swagger", "/redoc", "/docs"] |
|||
is_protected_path = any(path in request.path for path in protected_paths) |
|||
|
|||
if is_protected_path: |
|||
# Check if user is authenticated and is staff |
|||
if request.user.is_authenticated and request.user.is_staff: |
|||
# Handle swagger token authentication from session |
|||
if 'swagger_token' in request.session: |
|||
token = request.session['swagger_token'] |
|||
# Validate the token still exists and is valid |
|||
try: |
|||
token_obj = Token.objects.get(key=token) |
|||
if token_obj.user.is_active: |
|||
request.META['HTTP_AUTHORIZATION'] = f"Token {token}" |
|||
else: |
|||
# Token user is inactive, clear session |
|||
del request.session['swagger_token'] |
|||
if 'swagger_user_info' in request.session: |
|||
del request.session['swagger_user_info'] |
|||
except Token.DoesNotExist: |
|||
# Token doesn't exist, clear session |
|||
del request.session['swagger_token'] |
|||
if 'swagger_user_info' in request.session: |
|||
del request.session['swagger_user_info'] |
|||
|
|||
# If no swagger token in session, provide default admin token for basic access |
|||
elif not request.META.get('HTTP_AUTHORIZATION'): |
|||
# Create or get token for the current admin user |
|||
token, _ = Token.objects.get_or_create(user=request.user) |
|||
request.META['HTTP_AUTHORIZATION'] = f"Token {token.key}" |
|||
|
|||
else: |
|||
# User is not authenticated or not staff |
|||
# For swagger-auth paths, allow access (they handle their own auth) |
|||
if '/swagger-auth/' not in request.path: |
|||
# Redirect to admin login for other protected paths |
|||
messages.warning(request, 'You must be logged in as a staff member to access API documentation.') |
|||
from django.utils.translation import get_language |
|||
language = get_language() or 'en' |
|||
return redirect(f"/{language}/admin/login/?next={request.path}") |
|||
|
|||
# For non-protected API paths, handle normal authentication |
|||
elif "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: |
|||
if request.user.is_authenticated and request.user.is_staff: |
|||
token, _ = Token.objects.get_or_create(user=request.user) |
|||
request.META['HTTP_AUTHORIZATION'] = f"Token {token.key}" |
|||
|
|||
return get_response(request) |
|||
|
|||
return middleware |
|||
@ -0,0 +1,14 @@ |
|||
def language_middleware(get_response): |
|||
""" |
|||
Middleware that allows overriding the active request language |
|||
via the 'language_code' query parameter. |
|||
""" |
|||
def middleware(request): |
|||
language_code = request.GET.get('language_code') |
|||
if language_code: |
|||
request.LANGUAGE_CODE = language_code |
|||
|
|||
response = get_response(request) |
|||
return response |
|||
|
|||
return middleware |
|||
@ -0,0 +1,22 @@ |
|||
""" |
|||
Middleware utilities and helpers |
|||
""" |
|||
|
|||
|
|||
def get_admin_namespace(request): |
|||
""" |
|||
Determine the admin namespace based on the request domain. |
|||
Returns the appropriate admin namespace for use in reverse() calls. |
|||
|
|||
Usage: |
|||
from config.middleware import get_admin_namespace |
|||
admin_ns = get_admin_namespace(request) |
|||
url = reverse(f'{admin_ns}:model_changelist') |
|||
""" |
|||
host = request.get_host() |
|||
|
|||
# Check if the request is from Dovoodi domain |
|||
if 'dovodi' in host or 'dovoodi' in host: |
|||
return 'dovoodi_admin' |
|||
else: |
|||
return 'imam_javad_admin' |
|||
@ -0,0 +1,15 @@ |
|||
from redis import Redis, ConnectionPool |
|||
|
|||
from config.settings import base as settings |
|||
|
|||
|
|||
|
|||
pool = ConnectionPool.from_url(url= settings.REDIS_URL,max_connections=100) |
|||
|
|||
|
|||
class RedisConfig: |
|||
|
|||
def __init__(self): |
|||
self.redis = Redis(connection_pool=pool, decode_responses=True) |
|||
|
|||
|
|||
@ -0,0 +1,379 @@ |
|||
""" |
|||
Django base settings for project template. |
|||
Built on Django 5.0+, Django REST Framework, Celery, and Django Unfold. |
|||
""" |
|||
import os |
|||
from pathlib import Path |
|||
from django.templatetags.static import static |
|||
from django.urls import reverse_lazy |
|||
import environ |
|||
from django.utils.translation import gettext_lazy as _ |
|||
import sentry_sdk |
|||
from utils.admin import admin_url_generator |
|||
|
|||
# Build paths inside the project: BASE_DIR / 'subdir'. |
|||
BASE_DIR = Path(__file__).resolve().parent.parent.parent |
|||
|
|||
env = environ.Env() |
|||
env_file = os.path.join(BASE_DIR, '.env') |
|||
if os.path.exists(env_file): |
|||
environ.Env.read_env(env_file) |
|||
|
|||
# Core Security & Debug |
|||
SECRET_KEY = env('DJANGO_SECRET_KEY', default='django-insecure-template-secret-key-replace-in-production') |
|||
DEBUG = env.bool('DJANGO_DEBUG', default=True) |
|||
|
|||
ALLOWED_HOSTS = [host.strip() for host in env('DJANGO_ALLOWED_HOSTS', default='127.0.0.1,localhost,*').split(',') if host.strip()] |
|||
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in env('DJANGO_TRUSTED_ORIGINS', default='http://localhost:8000,http://127.0.0.1:8000').split(',') if origin.strip()] |
|||
|
|||
X_FRAME_OPTIONS = 'SAMEORIGIN' |
|||
|
|||
# Application definition |
|||
LOCAL_APPS = [ |
|||
'apps.account.apps.AccountConfig', |
|||
'apps.api.apps.ApiConfig', |
|||
'dynamic_preferences', |
|||
] |
|||
|
|||
THIRD_PARTY_APPS = [ |
|||
'rest_framework', |
|||
'rest_framework.authtoken', |
|||
'rest_framework_simplejwt', |
|||
'drf_spectacular', |
|||
'drf_spectacular_sidecar', |
|||
'drf_yasg', |
|||
'phonenumber_field', |
|||
'corsheaders', |
|||
'django_filters', |
|||
'django_celery_beat', |
|||
] |
|||
|
|||
INSTALLED_APPS = [ |
|||
"unfold", |
|||
"unfold.contrib.filters", |
|||
"unfold.contrib.forms", |
|||
"unfold.contrib.inlines", |
|||
"whitenoise.runserver_nostatic", |
|||
'django.contrib.admin', |
|||
'django.contrib.auth', |
|||
'django.contrib.contenttypes', |
|||
'django.contrib.sessions', |
|||
'django.contrib.messages', |
|||
'django.contrib.staticfiles', |
|||
'django.contrib.humanize', |
|||
'django.contrib.sitemaps', |
|||
*THIRD_PARTY_APPS, |
|||
*LOCAL_APPS, |
|||
] |
|||
|
|||
AUTHENTICATION_BACKENDS = [ |
|||
'django.contrib.auth.backends.ModelBackend', |
|||
'apps.account.custom_user_login.CustomLoginBackend', |
|||
] |
|||
|
|||
AUTH_USER_MODEL = "account.User" |
|||
|
|||
MIDDLEWARE = [ |
|||
'django.middleware.security.SecurityMiddleware', |
|||
'django.middleware.gzip.GZipMiddleware', |
|||
"whitenoise.middleware.WhiteNoiseMiddleware", |
|||
'django.contrib.sessions.middleware.SessionMiddleware', |
|||
'corsheaders.middleware.CorsMiddleware', |
|||
'django.middleware.locale.LocaleMiddleware', |
|||
'django.middleware.common.CommonMiddleware', |
|||
'django.middleware.csrf.CsrfViewMiddleware', |
|||
'django.contrib.auth.middleware.AuthenticationMiddleware', |
|||
'django.contrib.messages.middleware.MessageMiddleware', |
|||
'django.middleware.clickjacking.XFrameOptionsMiddleware', |
|||
'config.language_code_middleware.language_middleware', |
|||
'config.enhanced_auth_middleware.enhanced_auth_middleware', |
|||
] |
|||
|
|||
ROOT_URLCONF = 'config.urls' |
|||
APPEND_SLASH = True |
|||
|
|||
TEMPLATES = [ |
|||
{ |
|||
'BACKEND': 'django.template.backends.django.DjangoTemplates', |
|||
'DIRS': [ |
|||
BASE_DIR / 'templates', |
|||
], |
|||
'APP_DIRS': True, |
|||
'OPTIONS': { |
|||
'context_processors': [ |
|||
'django.template.context_processors.debug', |
|||
'django.template.context_processors.request', |
|||
'django.contrib.auth.context_processors.auth', |
|||
'django.contrib.messages.context_processors.messages', |
|||
'django.template.context_processors.i18n', |
|||
"utils.admin.variables", |
|||
], |
|||
}, |
|||
}, |
|||
] |
|||
|
|||
WSGI_APPLICATION = 'config.wsgi.application' |
|||
ASGI_APPLICATION = 'config.asgi.application' |
|||
|
|||
# Database configuration |
|||
DATABASES = { |
|||
'default': { |
|||
'ENGINE': 'django.db.backends.postgresql', |
|||
'NAME': env('POSTGRES_DB', default='app_db'), |
|||
'USER': env('POSTGRES_USER', default='postgres'), |
|||
'PASSWORD': env('POSTGRES_PASSWORD', default='postgres'), |
|||
'HOST': env('POSTGRES_HOST', default='postgres'), |
|||
'PORT': env('POSTGRES_PORT', default='5432'), |
|||
'ATOMIC_REQUESTS': True, |
|||
}, |
|||
} |
|||
|
|||
# Redis & Caching |
|||
REDIS_URL = env('REDIS_URL', default='redis://redis:6379/0') |
|||
CACHES = { |
|||
'default': { |
|||
"BACKEND": "django_redis.cache.RedisCache", |
|||
"LOCATION": REDIS_URL, |
|||
"OPTIONS": { |
|||
"CLIENT_CLASS": "django_redis.client.DefaultClient", |
|||
} |
|||
} |
|||
} |
|||
|
|||
# Celery Task Queue |
|||
CELERY_BROKER_URL = REDIS_URL |
|||
CELERY_RESULT_BACKEND = REDIS_URL |
|||
CELERY_ACCEPT_CONTENT = ['application/json'] |
|||
CELERY_TIMEZONE = 'UTC' |
|||
CELERY_BROKER_TRANSPORT = 'redis' |
|||
CELERY_TASK_SERIALIZER = 'json' |
|||
CELERY_RESULT_SERIALIZER = 'json' |
|||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True |
|||
|
|||
from celery.schedules import crontab |
|||
CELERY_BEAT_SCHEDULE = { |
|||
'cleanup_expired_tokens_daily': { |
|||
'task': 'cleanup_expired_tokens_task', |
|||
'schedule': crontab(minute=0, hour=3), |
|||
}, |
|||
} |
|||
|
|||
# Password validation |
|||
AUTH_PASSWORD_VALIDATORS = [ |
|||
{ |
|||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', |
|||
'OPTIONS': { |
|||
'min_length': 6, |
|||
} |
|||
}, |
|||
] |
|||
|
|||
# Django REST Framework |
|||
REST_FRAMEWORK = { |
|||
'DEFAULT_PAGINATION_CLASS': 'utils.pagination.StandardResultsSetPagination', |
|||
'PAGE_SIZE': 20, |
|||
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], |
|||
'DEFAULT_AUTHENTICATION_CLASSES': [ |
|||
'rest_framework_simplejwt.authentication.JWTAuthentication', |
|||
'rest_framework.authentication.TokenAuthentication', |
|||
'rest_framework.authentication.SessionAuthentication', |
|||
], |
|||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', |
|||
'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', |
|||
} |
|||
|
|||
# SimpleJWT Authentication Settings |
|||
from datetime import timedelta |
|||
SIMPLE_JWT = { |
|||
'ACCESS_TOKEN_LIFETIME': timedelta(days=1), |
|||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), |
|||
'ROTATE_REFRESH_TOKENS': True, |
|||
'BLACKLIST_AFTER_ROTATION': False, |
|||
'UPDATE_LAST_LOGIN': True, |
|||
'ALGORITHM': 'HS256', |
|||
'SIGNING_KEY': SECRET_KEY, |
|||
'AUTH_HEADER_TYPES': ('Bearer',), |
|||
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', |
|||
'USER_ID_FIELD': 'id', |
|||
'USER_ID_CLAIM': 'user_id', |
|||
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), |
|||
} |
|||
|
|||
# drf-spectacular OpenAPI 3.0 Documentation Settings |
|||
SPECTACULAR_SETTINGS = { |
|||
'TITLE': 'API سامانه تعاملات بینالملل', |
|||
'DESCRIPTION': 'مستندات نقاط پایانی سامانه تعاملات بینالملل آستان قدس رضوی (فاز دوم)', |
|||
'VERSION': '2.0.0', |
|||
'SERVE_INCLUDE_SCHEMA': False, |
|||
'COMPONENT_SPLIT_REQUEST': True, |
|||
'SWAGGER_UI_DIST': 'SIDECAR', |
|||
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', |
|||
'REDOC_DIST': 'SIDECAR', |
|||
'SECURITY': [{ |
|||
'jwtAuth': [] |
|||
}], |
|||
'SECURITY_SCHEMES': { |
|||
'jwtAuth': { |
|||
'type': 'apiKey', |
|||
'in': 'header', |
|||
'name': 'Authorization', |
|||
'description': 'توکن JWT را به این صورت وارد کنید: Bearer <JWT_TOKEN>' |
|||
} |
|||
}, |
|||
'SWAGGER_UI_SETTINGS': { |
|||
'deepLinking': True, |
|||
'persistAuthorization': True, |
|||
'displayOperationId': True, |
|||
}, |
|||
} |
|||
|
|||
# Centrifugo Real-Time Messaging Settings |
|||
CENTRIFUGO_API_URL = env('CENTRIFUGO_API_URL', default='http://centrifugo:8000/api') |
|||
CENTRIFUGO_SECRET = env('CENTRIFUGO_SECRET', default='super_secret_centrifugo_token_key_hmac_256') |
|||
CENTRIFUGO_API_KEY = env('CENTRIFUGO_API_KEY', default='centrifugo_internal_api_access_key') |
|||
|
|||
CORS_ALLOW_ALL_ORIGINS = True |
|||
CORS_ALLOW_CREDENTIALS = True |
|||
CORS_ALLOWED_ORIGINS = [ |
|||
'http://localhost:5173', |
|||
'http://127.0.0.1:5173', |
|||
'http://localhost:3000', |
|||
'http://127.0.0.1:3000', |
|||
'https://shiadeepconnect.org', |
|||
] |
|||
|
|||
# Internationalization |
|||
LANGUAGE_CODE = 'en' |
|||
TIME_ZONE = 'UTC' |
|||
USE_I18N = True |
|||
USE_L10N = True |
|||
USE_TZ = True |
|||
|
|||
LANGUAGES = [ |
|||
('fa', _('Persian')), |
|||
('en', _('English')), |
|||
('ar', _('Arabic')), |
|||
('ur', _('Urdu')), |
|||
('ru', _('Russian')), |
|||
] |
|||
|
|||
LOCALE_PATHS = [ |
|||
os.path.join(BASE_DIR, 'locale'), |
|||
] |
|||
|
|||
# Static & Media Files |
|||
STATIC_URL = '/static/' |
|||
MEDIA_URL = '/media/' |
|||
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] |
|||
STATIC_ROOT = BASE_DIR / 'staticfiles' |
|||
MEDIA_ROOT = BASE_DIR / 'media' |
|||
|
|||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' |
|||
|
|||
PHONENUMBER_DEFAULT_REGION = env("PHONENUMBER_DEFAULT_REGION", default="US") |
|||
PHONENUMBER_DB_FORMAT = 'INTERNATIONAL' |
|||
PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL' |
|||
|
|||
# Sessions |
|||
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" |
|||
LOGIN_URL = "admin:login" |
|||
LOGIN_REDIRECT_URL = reverse_lazy("home") |
|||
|
|||
# Unfold Admin Interface Settings |
|||
UNFOLD = { |
|||
"SITE_TITLE": _("Admin Portal"), |
|||
"SITE_HEADER": _("Admin Portal"), |
|||
"SITE_SUBHEADER": _("Management Console"), |
|||
"SITE_SYMBOL": "speed", |
|||
"ALLOW_UNICODE_SLUGS": True, |
|||
"SHOW_HISTORY": True, |
|||
"SHOW_LANGUAGES": True, |
|||
"ENVIRONMENT": "utils.environment_callback", |
|||
"DASHBOARD_CALLBACK": "utils.admin.dashboard_callback", |
|||
"SHOW_BACK_BUTTON": True, |
|||
"THEME": "light", |
|||
"STYLES": [ |
|||
lambda request: static("css/styles.css"), |
|||
], |
|||
"TABS": [ |
|||
{ |
|||
"page": "accounts", |
|||
"models": ["account.user", "auth.group"], |
|||
"items": [ |
|||
{ |
|||
"title": _("Users"), |
|||
"icon": "people", |
|||
"link": lambda request: admin_url_generator(request, "account_user_changelist"), |
|||
}, |
|||
{ |
|||
"title": _("Groups"), |
|||
"icon": "shield", |
|||
"link": lambda request: admin_url_generator(request, "auth_group_changelist"), |
|||
}, |
|||
], |
|||
}, |
|||
], |
|||
"SIDEBAR": { |
|||
"show_search": True, |
|||
"show_all_applications": True, |
|||
"navigation": [ |
|||
{ |
|||
"title": _("Overview"), |
|||
"separator": False, |
|||
"items": [ |
|||
{ |
|||
"title": _("Dashboard"), |
|||
"icon": "dashboard", |
|||
"link": lambda request: admin_url_generator(request, "index"), |
|||
}, |
|||
], |
|||
}, |
|||
{ |
|||
"title": _("User Management"), |
|||
"separator": True, |
|||
"items": [ |
|||
{ |
|||
"title": _("All Users"), |
|||
"icon": "people", |
|||
"link": lambda request: admin_url_generator(request, "account_user_changelist"), |
|||
}, |
|||
{ |
|||
"title": _("Groups & Permissions"), |
|||
"icon": "shield", |
|||
"link": lambda request: admin_url_generator(request, "auth_group_changelist"), |
|||
"permission": lambda request: request.user.is_staff, |
|||
}, |
|||
], |
|||
}, |
|||
{ |
|||
"title": _("System Settings"), |
|||
"separator": True, |
|||
"items": [ |
|||
{ |
|||
"title": _("Global Preferences"), |
|||
"icon": "tune", |
|||
"link": lambda request: admin_url_generator(request, "dynamic_preferences_globalpreferencemodel_changelist"), |
|||
}, |
|||
{ |
|||
"title": _("App Versions"), |
|||
"icon": "system_update", |
|||
"link": lambda request: admin_url_generator(request, "api_appversion_changelist"), |
|||
}, |
|||
{ |
|||
"title": _("Support Inquiries"), |
|||
"icon": "mail", |
|||
"link": lambda request: admin_url_generator(request, "api_supportmessage_changelist"), |
|||
}, |
|||
], |
|||
}, |
|||
], |
|||
}, |
|||
} |
|||
|
|||
# Sentry SDK Error Tracking (Configurable via ENV) |
|||
SENTRY_DSN = env('SENTRY_DSN', default='') |
|||
if SENTRY_DSN: |
|||
sentry_sdk.init( |
|||
dsn=SENTRY_DSN, |
|||
send_default_pii=True, |
|||
) |
|||
@ -0,0 +1,24 @@ |
|||
from .base import * |
|||
|
|||
# DJANGO_REDIS_IGNORE_EXCEPTIONS = True |
|||
DEBUG = True |
|||
|
|||
CORS_ALLOW_ALL_ORIGINS = True |
|||
|
|||
# Explicitly enable Unfold Studio in development mode |
|||
UNFOLD_STUDIO_ENABLE_SAVE = True |
|||
UNFOLD_STUDIO_ENABLE_FILEUPLOAD = True |
|||
UNFOLD_STUDIO_ALWAYS_OPEN = True |
|||
# Allow all authenticated users to access the studio in development mode |
|||
UNFOLD_STUDIO_PERMISSION = lambda request: request.user.is_authenticated |
|||
|
|||
# CACHES = { |
|||
# 'default': { |
|||
# "BACKEND": "django.core.cache.backends.dummy.DummyCache", |
|||
# }, |
|||
# 'memory': { |
|||
# 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', |
|||
# 'LOCATION': 'unique-snowflake', |
|||
# 'TIMEOUT': 5000, |
|||
# }, |
|||
# } |
|||
@ -0,0 +1,50 @@ |
|||
from .base import * |
|||
|
|||
DEBUG = False |
|||
|
|||
CORS_ALLOW_ALL_ORIGINS = False |
|||
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in env('CORS_ALLOWED_ORIGINS', default='').split(',') if origin.strip()] |
|||
|
|||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') |
|||
SESSION_COOKIE_SECURE = True |
|||
CSRF_COOKIE_SECURE = True |
|||
SECURE_BROWSER_XSS_FILTER = True |
|||
SECURE_CONTENT_TYPE_NOSNIFF = True |
|||
|
|||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = [ |
|||
'rest_framework.renderers.JSONRenderer', |
|||
] |
|||
|
|||
LOGGING = { |
|||
"version": 1, |
|||
"disable_existing_loggers": False, |
|||
"formatters": { |
|||
"django.server": { |
|||
"()": "django.utils.log.ServerFormatter", |
|||
"format": "[{server_time}] {message}", |
|||
"style": "{", |
|||
} |
|||
}, |
|||
"handlers": { |
|||
"console": { |
|||
"level": "INFO", |
|||
"class": "logging.StreamHandler", |
|||
}, |
|||
"django.server": { |
|||
"level": "INFO", |
|||
"class": "logging.StreamHandler", |
|||
"formatter": "django.server", |
|||
}, |
|||
}, |
|||
"loggers": { |
|||
"django": { |
|||
"handlers": ["console"], |
|||
"level": "INFO", |
|||
}, |
|||
"django.server": { |
|||
"handlers": ["django.server"], |
|||
"level": "INFO", |
|||
"propagate": False, |
|||
}, |
|||
}, |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
from .base import * # noqa |
|||
|
|||
DATABASES['default'] = { |
|||
'ENGINE': 'django.db.backends.sqlite3', |
|||
'NAME': ':memory:', |
|||
} |
|||
|
|||
PASSWORD_HASHERS = [ |
|||
'django.contrib.auth.hashers.MD5PasswordHasher', |
|||
] |
|||
|
|||
MEDIA_ROOT = BASE_DIR / 'test_media' |
|||
@ -0,0 +1,118 @@ |
|||
""" |
|||
Main URL Configuration for the Django Backend Starter Template. |
|||
""" |
|||
import os |
|||
import re |
|||
import mimetypes |
|||
from django.contrib import admin |
|||
from django.urls import path, include, re_path |
|||
from django.conf import settings |
|||
from django.conf.urls.i18n import i18n_patterns |
|||
from django.http import StreamingHttpResponse, HttpResponse, Http404 |
|||
from django.views.static import serve as django_static_serve |
|||
|
|||
from django.views.generic import RedirectView |
|||
from drf_spectacular.views import ( |
|||
SpectacularAPIView, |
|||
SpectacularRedocView, |
|||
SpectacularSwaggerView, |
|||
) |
|||
from rest_framework_simplejwt.views import ( |
|||
TokenObtainPairView, |
|||
TokenRefreshView, |
|||
TokenVerifyView, |
|||
) |
|||
from rest_framework import permissions |
|||
|
|||
from utils.admin import project_admin_site, HomeView |
|||
|
|||
# API v1 Patterns |
|||
api_v1_patterns = [ |
|||
# JWT Authentication Endpoints (matching swagger-api-guide.md) |
|||
path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), |
|||
path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), |
|||
path('auth/token/verify/', TokenVerifyView.as_view(), name='token_verify'), |
|||
|
|||
path('', include('apps.api.urls')), |
|||
path('account/', include('apps.account.urls')), |
|||
path('settings/', include('dynamic_preferences.urls')), |
|||
] |
|||
|
|||
urlpatterns = [ |
|||
# Language prefix routing for Admin |
|||
path("i18n/", include("django.conf.urls.i18n")), |
|||
|
|||
# REST API Endpoints |
|||
path("api/v1/", include(api_v1_patterns)), |
|||
|
|||
# OpenAPI 3.0 & Interactive Documentation (matching swagger-api-guide.md) |
|||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"), |
|||
path("api/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), |
|||
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), |
|||
|
|||
# Backward compatibility redirect |
|||
path("swagger/", RedirectView.as_view(url="/api/schema/swagger-ui/", permanent=False)), |
|||
] |
|||
|
|||
# Admin URLs wrapped in i18n |
|||
urlpatterns += i18n_patterns( |
|||
path("admin/", project_admin_site.urls), |
|||
prefix_default_language=True, |
|||
) |
|||
|
|||
# Static & Media streaming for development mode |
|||
if settings.DEBUG: |
|||
def ranged_static_serve(request, path, document_root=None, **kwargs): |
|||
fullpath = os.path.join(document_root, path) |
|||
if not os.path.exists(fullpath) or os.path.isdir(fullpath): |
|||
raise Http404("File not found") |
|||
|
|||
range_header = request.META.get('HTTP_RANGE', '').strip() |
|||
if not range_header: |
|||
return django_static_serve(request, path, document_root=document_root, **kwargs) |
|||
|
|||
size = os.path.getsize(fullpath) |
|||
content_type, encoding = mimetypes.guess_type(fullpath) |
|||
content_type = content_type or 'application/octet-stream' |
|||
|
|||
match = re.match(r'bytes=(\d+)-(\d*)', range_header) |
|||
if not match: |
|||
return HttpResponse("Invalid Range Header", status=400) |
|||
|
|||
first_byte, last_byte = match.groups() |
|||
first_byte = int(first_byte) if first_byte else 0 |
|||
last_byte = int(last_byte) if last_byte else size - 1 |
|||
|
|||
if first_byte >= size: |
|||
return HttpResponse("Requested Range Not Satisfiable", status=416) |
|||
|
|||
if last_byte >= size: |
|||
last_byte = size - 1 |
|||
|
|||
length = last_byte - first_byte + 1 |
|||
|
|||
def file_iterator(file_path, offset, bytes_to_read, chunk_size=8192): |
|||
with open(file_path, 'rb') as f: |
|||
f.seek(offset) |
|||
remaining = bytes_to_read |
|||
while remaining > 0: |
|||
to_read = min(chunk_size, remaining) |
|||
data = f.read(to_read) |
|||
if not data: |
|||
break |
|||
yield data |
|||
remaining -= len(data) |
|||
|
|||
response = StreamingHttpResponse( |
|||
file_iterator(fullpath, first_byte, length), |
|||
status=206, |
|||
content_type=content_type |
|||
) |
|||
response['Content-Range'] = f'bytes {first_byte}-{last_byte}/{size}' |
|||
response['Content-Length'] = str(length) |
|||
response['Accept-Ranges'] = 'bytes' |
|||
return response |
|||
|
|||
urlpatterns += [ |
|||
re_path(r'^%s(?P<path>.*)$' % re.escape(settings.MEDIA_URL.lstrip('/')), ranged_static_serve, {'document_root': settings.MEDIA_ROOT}), |
|||
] |
|||
@ -0,0 +1,16 @@ |
|||
""" |
|||
WSGI config for backend project. |
|||
|
|||
It exposes the WSGI callable as a module-level variable named ``application``. |
|||
|
|||
For more information on this file, see |
|||
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ |
|||
""" |
|||
|
|||
import os |
|||
|
|||
from django.core.wsgi import get_wsgi_application |
|||
|
|||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') |
|||
|
|||
application = get_wsgi_application() |
|||
@ -0,0 +1,91 @@ |
|||
version: '3.8' |
|||
|
|||
services: |
|||
web: |
|||
container_name: dovodi_web |
|||
restart: unless-stopped |
|||
build: |
|||
context: . |
|||
dockerfile: Dockerfile.prod |
|||
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=4 --threads=4 --worker-class=gthread --max-requests=1000 --max-requests-jitter=100 --timeout 180 |
|||
volumes: |
|||
# - static_volume:/usr/src/app/static |
|||
- media_volume:/usr/src/app/media |
|||
- staticfiles_volume:/usr/src/app/staticfiles |
|||
ports: |
|||
- "127.0.0.1:8024:8000" |
|||
env_file: |
|||
- .env.prod |
|||
depends_on: |
|||
- postgres |
|||
links: |
|||
- postgres |
|||
networks: |
|||
- backend_imam-javad |
|||
|
|||
postgres: |
|||
container_name: dovodi_db |
|||
ports: |
|||
- "5513:5432" |
|||
restart: unless-stopped |
|||
image: postgres:14.0 |
|||
volumes: |
|||
- postgres_data:/var/lib/postgresql/data/ |
|||
env_file: |
|||
- .env.prod |
|||
networks: |
|||
- backend_imam-javad |
|||
# dovodi_redis: |
|||
# container_name: dovodi_redis |
|||
# image: redis:alpine |
|||
# env_file: .env.prod |
|||
# volumes: |
|||
# - redis_data:/data |
|||
# networks: |
|||
# - imam-javad |
|||
# imam-javad_celery: |
|||
# container_name: imam-javad_celery |
|||
# build: |
|||
# context: . |
|||
# dockerfile: Dockerfile.prod |
|||
# env_file: .env.prod |
|||
# command: celery -A config worker -l info |
|||
# volumes: |
|||
# # - .:/usr/src/app/ |
|||
# - staticfiles_volume:/usr/src/app/staticfiles |
|||
# - media_volume:/usr/src/app/media |
|||
# - logs_volume:/usr/src/app/logs |
|||
|
|||
# depends_on: |
|||
# - imam-javad_redis |
|||
# networks: |
|||
# - imam-javad |
|||
|
|||
|
|||
# imam-javad_celery-beat: |
|||
# container_name: imam-javad_celery_beat |
|||
# build: |
|||
# context: . |
|||
# dockerfile: Dockerfile.prod |
|||
# env_file: .env.prod |
|||
# command: celery -A config beat -l info |
|||
# volumes: |
|||
# # - .:/usr/src/app/ |
|||
# - logs_volume:/usr/src/app/logs |
|||
# depends_on: |
|||
# - imam-javad_redis |
|||
# networks: |
|||
# - imam-javad |
|||
|
|||
|
|||
|
|||
volumes: |
|||
staticfiles_volume: # Volume برای فایلهای static |
|||
media_volume: |
|||
postgres_data: # static_volume: |
|||
|
|||
# redis_data: |
|||
|
|||
networks: |
|||
backend_imam-javad: |
|||
external: true |
|||
@ -0,0 +1,76 @@ |
|||
version: '3.8' |
|||
|
|||
services: |
|||
web: |
|||
build: . |
|||
command: python manage.py runserver 0.0.0.0:8000 |
|||
volumes: |
|||
- .:/usr/src/app |
|||
- static_volume:/usr/src/app/staticfiles |
|||
- media_volume:/usr/src/app/media |
|||
ports: |
|||
- "8000:8000" |
|||
env_file: |
|||
- .env.dev |
|||
depends_on: |
|||
- postgres |
|||
- redis |
|||
networks: |
|||
- app_network |
|||
|
|||
celery_worker: |
|||
build: . |
|||
command: celery -A config worker -l info |
|||
volumes: |
|||
- .:/usr/src/app |
|||
env_file: |
|||
- .env.dev |
|||
depends_on: |
|||
- postgres |
|||
- redis |
|||
networks: |
|||
- app_network |
|||
|
|||
postgres: |
|||
image: postgres:15-alpine |
|||
ports: |
|||
- "5432:5432" |
|||
volumes: |
|||
- postgres_data:/var/lib/postgresql/data |
|||
env_file: |
|||
- .env.dev |
|||
networks: |
|||
- app_network |
|||
|
|||
redis: |
|||
image: redis:7-alpine |
|||
ports: |
|||
- "6379:6379" |
|||
volumes: |
|||
- redis_data:/data |
|||
networks: |
|||
- app_network |
|||
|
|||
centrifugo: |
|||
image: centrifugo/centrifugo:v5 |
|||
container_name: sch_centrifugo |
|||
restart: always |
|||
command: centrifugo --config=/centrifugo/config.json |
|||
volumes: |
|||
- ./centrifugo/config.json:/centrifugo/config.json |
|||
ports: |
|||
- "8001:8000" |
|||
depends_on: |
|||
- redis |
|||
networks: |
|||
- app_network |
|||
|
|||
volumes: |
|||
postgres_data: |
|||
redis_data: |
|||
static_volume: |
|||
media_volume: |
|||
|
|||
networks: |
|||
app_network: |
|||
driver: bridge |
|||
@ -0,0 +1,2 @@ |
|||
__version__ = "1.14.0" |
|||
default_app_config = "dynamic_preferences.apps.DynamicPreferencesConfig" |
|||
@ -0,0 +1,139 @@ |
|||
from ajaxdatatable.admin import AjaxDatatable |
|||
from django.contrib import admin |
|||
from django import forms |
|||
|
|||
from .settings import preferences_settings |
|||
from .registries import global_preferences_registry |
|||
from .models import GlobalPreferenceModel |
|||
from .forms import GlobalSinglePreferenceForm, SinglePerInstancePreferenceForm |
|||
from django.utils.translation import gettext_lazy as _ |
|||
|
|||
from unfold.admin import ModelAdmin, TabularInline |
|||
from utils.admin import project_admin_site |
|||
|
|||
class SectionFilter(admin.AllValuesFieldListFilter): |
|||
def __init__(self, field, request, params, model, model_admin, field_path): |
|||
super(SectionFilter, self).__init__( |
|||
field, request, params, model, model_admin, field_path |
|||
) |
|||
parent_model, reverse_path = admin.utils.reverse_field_path(model, field_path) |
|||
if model == parent_model: |
|||
queryset = model_admin.get_queryset |
|||
else: |
|||
queryset = parent_model._default_manager.all() |
|||
self.registries = [] |
|||
registry_name_set = set() |
|||
for preferenceModel in queryset.distinct(): |
|||
l = len(registry_name_set) |
|||
registry_name_set.add(preferenceModel.registry.__class__.__name__) |
|||
if len(registry_name_set) != l: |
|||
self.registries.append(preferenceModel.registry) |
|||
|
|||
def choices(self, changelist): |
|||
choices = super(SectionFilter, self).choices(changelist) |
|||
for choice in choices: |
|||
display = choice["display"] |
|||
try: |
|||
for registry in self.registries: |
|||
display = registry.section_objects[display].verbose_name |
|||
choice["display"] = display |
|||
except (KeyError): |
|||
pass |
|||
yield choice |
|||
|
|||
|
|||
# Change DynamicPreferenceAdmin to inherit from unfold's ModelAdmin |
|||
class DynamicPreferenceAdmin(ModelAdmin): |
|||
list_display = ( |
|||
"verbose_name", |
|||
"help_text", |
|||
) |
|||
fields = ("raw_value", "default_value",) |
|||
readonly_fields = ("default_value",) |
|||
change_form_template = "dynamic_preferences/dyna_change_form.html" |
|||
|
|||
# Unfold specific settings |
|||
search_fields = ["name", "section"] |
|||
list_filter = ["section"] |
|||
|
|||
@admin.display(description=_('Verbose name')) |
|||
def verbose_name(self, obj): |
|||
return obj.verbose_name |
|||
|
|||
@admin.display(description=_('Help text')) |
|||
def help_text(self, obj): |
|||
return obj.help_text |
|||
|
|||
def has_add_permission(self, request): |
|||
# if "root@admin" in request.user.username: |
|||
# return True |
|||
return False |
|||
|
|||
def has_delete_permission(self, request, obj=None): |
|||
if "root@admin" in request.user.email: |
|||
return True |
|||
return False |
|||
|
|||
if preferences_settings.ADMIN_ENABLE_CHANGELIST_FORM: |
|||
def get_changelist_form(self, request, **kwargs): |
|||
return self.changelist_form |
|||
|
|||
def default_value(self, obj): |
|||
return obj.preference.default |
|||
|
|||
default_value.short_description = _("Default Value") |
|||
|
|||
def section_name(self, obj): |
|||
try: |
|||
return obj.registry.section_objects[obj.section].verbose_name |
|||
except KeyError: |
|||
pass |
|||
return obj.section |
|||
|
|||
section_name.short_description = _("Section Name") |
|||
|
|||
def save_model(self, request, obj, form, change): |
|||
pref = form.instance |
|||
manager = pref.registry.manager() |
|||
manager.update_db_pref(pref.section, pref.name, form.cleaned_data["raw_value"]) |
|||
|
|||
|
|||
class GlobalPreferenceAdmin(DynamicPreferenceAdmin): |
|||
form = GlobalSinglePreferenceForm |
|||
changelist_form = GlobalSinglePreferenceForm |
|||
|
|||
# Unfold specific customizations |
|||
list_display_links = ["verbose_name"] |
|||
|
|||
# You can add unfold specific features like: |
|||
show_facets = True # Enable faceted filtering |
|||
|
|||
# Optional: Add custom actions |
|||
actions = ["reset_to_default"] |
|||
|
|||
def reset_to_default(self, request, queryset): |
|||
for pref in queryset: |
|||
manager = pref.registry.manager() |
|||
manager.update_db_pref(pref.section, pref.name, pref.preference.default) |
|||
reset_to_default.short_description = _("Reset selected preferences to default values") |
|||
|
|||
def get_queryset(self, *args, **kwargs): |
|||
# Instanciate default prefs |
|||
manager = global_preferences_registry.manager() |
|||
manager.all() |
|||
return super(GlobalPreferenceAdmin, self).get_queryset(*args, **kwargs) |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
project_admin_site.register(GlobalPreferenceModel, GlobalPreferenceAdmin) |
|||
|
|||
|
|||
class PerInstancePreferenceAdmin(DynamicPreferenceAdmin): |
|||
list_display = ("instance",) + DynamicPreferenceAdmin.list_display |
|||
fields = ("instance",) + DynamicPreferenceAdmin.fields |
|||
raw_id_fields = ("instance",) |
|||
form = SinglePerInstancePreferenceForm |
|||
changelist_form = SinglePerInstancePreferenceForm |
|||
list_select_related = True |
|||
@ -0,0 +1,71 @@ |
|||
from rest_framework import serializers |
|||
from dynamic_preferences.models import GlobalPreferenceModel |
|||
|
|||
|
|||
class PreferenceValueField(serializers.Field): |
|||
def get_attribute(self, o): |
|||
return o |
|||
|
|||
def to_representation(self, o): |
|||
return o.preference.api_repr(o.value) |
|||
|
|||
def to_internal_value(self, data): |
|||
return data |
|||
|
|||
|
|||
class PreferenceSerializer(serializers.Serializer): |
|||
|
|||
section = serializers.CharField(read_only=True) |
|||
name = serializers.CharField(read_only=True) |
|||
identifier = serializers.SerializerMethodField() |
|||
default = serializers.SerializerMethodField() |
|||
value = PreferenceValueField() |
|||
verbose_name = serializers.SerializerMethodField() |
|||
help_text = serializers.SerializerMethodField() |
|||
additional_data = serializers.SerializerMethodField() |
|||
field = serializers.SerializerMethodField() |
|||
|
|||
class Meta: |
|||
fields = [ |
|||
"default", |
|||
"value", |
|||
"verbose_name", |
|||
"help_text", |
|||
] |
|||
|
|||
def get_default(self, o): |
|||
return o.preference.api_repr(o.preference.get("default")) |
|||
|
|||
def get_verbose_name(self, o): |
|||
return o.preference.get("verbose_name") |
|||
|
|||
def get_identifier(self, o): |
|||
return o.preference.identifier() |
|||
|
|||
def get_help_text(self, o): |
|||
return o.preference.get("help_text") |
|||
|
|||
def get_additional_data(self, o): |
|||
return o.preference.get_api_additional_data() |
|||
|
|||
def get_field(self, o): |
|||
return o.preference.get_api_field_data() |
|||
|
|||
def validate_value(self, value): |
|||
""" |
|||
We call validation from the underlying form field |
|||
""" |
|||
field = self.instance.preference.setup_field() |
|||
value = field.to_python(value) |
|||
field.validate(value) |
|||
field.run_validators(value) |
|||
return value |
|||
|
|||
def update(self, instance, validated_data): |
|||
instance.value = validated_data["value"] |
|||
instance.save() |
|||
return instance |
|||
|
|||
|
|||
class GlobalPreferenceSerializer(PreferenceSerializer): |
|||
pass |
|||
@ -0,0 +1,179 @@ |
|||
from django.db import transaction |
|||
from django.db.models import Q |
|||
|
|||
from rest_framework import mixins |
|||
from rest_framework import viewsets |
|||
from rest_framework import permissions |
|||
from rest_framework.response import Response |
|||
from rest_framework.decorators import action |
|||
from rest_framework.generics import get_object_or_404 |
|||
|
|||
from dynamic_preferences import models |
|||
from dynamic_preferences import exceptions |
|||
from dynamic_preferences.settings import preferences_settings |
|||
|
|||
from . import serializers |
|||
|
|||
|
|||
class PreferenceViewSet( |
|||
mixins.UpdateModelMixin, |
|||
mixins.ListModelMixin, |
|||
mixins.RetrieveModelMixin, |
|||
viewsets.GenericViewSet, |
|||
): |
|||
""" |
|||
- list preferences |
|||
- detail given preference |
|||
- batch update preferences |
|||
- update a single preference |
|||
""" |
|||
|
|||
def get_queryset(self): |
|||
""" |
|||
We just ensure preferences are actually populated before fetching |
|||
from db |
|||
""" |
|||
self.init_preferences() |
|||
queryset = super(PreferenceViewSet, self).get_queryset() |
|||
|
|||
section = self.request.query_params.get("section") |
|||
if section: |
|||
queryset = queryset.filter(section=section) |
|||
|
|||
return queryset |
|||
|
|||
def get_manager(self): |
|||
return self.queryset.model.registry.manager() |
|||
|
|||
def init_preferences(self): |
|||
manager = self.get_manager() |
|||
manager.all() |
|||
|
|||
def get_object(self): |
|||
""" |
|||
Returns the object the view is displaying. |
|||
You may want to override this if you need to provide non-standard |
|||
queryset lookups. Eg if objects are referenced using multiple |
|||
keyword arguments in the url conf. |
|||
""" |
|||
queryset = self.filter_queryset(self.get_queryset()) |
|||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field |
|||
identifier = self.kwargs[lookup_url_kwarg] |
|||
section, name = self.get_section_and_name(identifier) |
|||
filter_kwargs = {"section": section, "name": name} |
|||
obj = get_object_or_404(queryset, **filter_kwargs) |
|||
|
|||
# May raise a permission denied |
|||
self.check_object_permissions(self.request, obj) |
|||
|
|||
return obj |
|||
|
|||
def get_section_and_name(self, identifier): |
|||
try: |
|||
section, name = identifier.split(preferences_settings.SECTION_KEY_SEPARATOR) |
|||
except ValueError: |
|||
# no section given |
|||
section, name = None, identifier |
|||
|
|||
return section, name |
|||
|
|||
@action(detail=False, methods=["post"]) |
|||
@transaction.atomic |
|||
def bulk(self, request, *args, **kwargs): |
|||
""" |
|||
Update multiple preferences at once |
|||
|
|||
this is a long method because we ensure everything is valid |
|||
before actually persisting the changes |
|||
""" |
|||
manager = self.get_manager() |
|||
errors = {} |
|||
preferences = [] |
|||
payload = request.data |
|||
|
|||
# first, we check updated preferences actually exists in the registry |
|||
try: |
|||
for identifier, value in payload.items(): |
|||
try: |
|||
preferences.append(self.queryset.model.registry.get(identifier)) |
|||
except exceptions.NotFoundInRegistry: |
|||
errors[identifier] = "invalid preference" |
|||
except (TypeError, AttributeError): |
|||
return Response("invalid payload", status=400) |
|||
|
|||
if errors: |
|||
return Response(errors, status=400) |
|||
|
|||
# now, we generate an optimized Q objects to retrieve all matching |
|||
# preferences at once from database |
|||
queries = [Q(section=p.section.name, name=p.name) for p in preferences] |
|||
|
|||
query = queries[0] |
|||
for q in queries[1:]: |
|||
query |= q |
|||
preferences_qs = self.get_queryset().filter(query) |
|||
|
|||
# next, we generate a serializer for each database preference |
|||
serializer_objects = [] |
|||
for p in preferences_qs: |
|||
s = self.get_serializer_class()( |
|||
p, data={"value": payload[p.preference.identifier()]} |
|||
) |
|||
serializer_objects.append(s) |
|||
|
|||
validation_errors = {} |
|||
|
|||
# we check if any serializer is invalid |
|||
for s in serializer_objects: |
|||
if s.is_valid(): |
|||
continue |
|||
validation_errors[s.instance.preference.identifier()] = s.errors |
|||
|
|||
if validation_errors: |
|||
return Response(validation_errors, status=400) |
|||
|
|||
for s in serializer_objects: |
|||
s.save() |
|||
|
|||
return Response( |
|||
[s.data for s in serializer_objects], |
|||
status=200, |
|||
) |
|||
|
|||
|
|||
class GlobalPreferencePermission(permissions.DjangoModelPermissions): |
|||
perms_map = { |
|||
"GET": ["%(app_label)s.change_%(model_name)s"], |
|||
"OPTIONS": ["%(app_label)s.change_%(model_name)s"], |
|||
"HEAD": ["%(app_label)s.change_%(model_name)s"], |
|||
"POST": ["%(app_label)s.change_%(model_name)s"], |
|||
"PUT": ["%(app_label)s.change_%(model_name)s"], |
|||
"PATCH": ["%(app_label)s.change_%(model_name)s"], |
|||
"DELETE": ["%(app_label)s.change_%(model_name)s"], |
|||
} |
|||
|
|||
|
|||
class GlobalPreferencesViewSet(PreferenceViewSet): |
|||
queryset = models.GlobalPreferenceModel.objects.all() |
|||
serializer_class = serializers.GlobalPreferenceSerializer |
|||
permission_classes = [GlobalPreferencePermission] |
|||
|
|||
|
|||
class PerInstancePreferenceViewSet(PreferenceViewSet): |
|||
def get_manager(self): |
|||
return self.queryset.model.registry.manager( |
|||
instance=self.get_related_instance() |
|||
) |
|||
|
|||
def get_queryset(self): |
|||
return ( |
|||
super(PerInstancePreferenceViewSet, self) |
|||
.get_queryset() |
|||
.filter(instance=self.get_related_instance()) |
|||
) |
|||
|
|||
def get_related_instance(self): |
|||
""" |
|||
Override this to the instance bound to the preferences |
|||
""" |
|||
raise NotImplementedError |
|||
@ -0,0 +1,25 @@ |
|||
from django.apps import AppConfig, apps |
|||
from django.conf import settings |
|||
from django.utils.translation import gettext_lazy as _ |
|||
from .registries import preference_models, global_preferences_registry |
|||
from .settings import preferences_settings |
|||
|
|||
|
|||
class DynamicPreferencesConfig(AppConfig): |
|||
name = "dynamic_preferences" |
|||
verbose_name = _("Settings") |
|||
default_auto_field = "django.db.models.AutoField" |
|||
icon = 'mi-settings' |
|||
|
|||
def ready(self): |
|||
if preferences_settings.ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION: |
|||
GlobalPreferenceModel = self.get_model("GlobalPreferenceModel") |
|||
|
|||
preference_models.register( |
|||
GlobalPreferenceModel, global_preferences_registry |
|||
) |
|||
|
|||
# This will load all dynamic_preferences_registry.py files under |
|||
# installed apps |
|||
app_names = [app.name for app in apps.app_configs.values()] |
|||
global_preferences_registry.autodiscover(app_names) |
|||
@ -0,0 +1,108 @@ |
|||
import json |
|||
from django import forms |
|||
from django.utils.translation import gettext_lazy as _ |
|||
|
|||
from dynamic_preferences.preferences import Section |
|||
from dynamic_preferences.registries import global_preferences_registry |
|||
from dynamic_preferences.types import ( |
|||
BasePreferenceType, |
|||
BaseSerializer, |
|||
LongStringPreference, |
|||
StringPreference, |
|||
BooleanPreference, |
|||
) |
|||
from unfold.contrib.forms.widgets import WysiwygWidget |
|||
from unfold.widgets import UnfoldAdminTextareaWidget |
|||
|
|||
|
|||
# --------------------------------------------------------- |
|||
# Preference Types & Custom Serializers |
|||
# --------------------------------------------------------- |
|||
|
|||
class EditorPreferences(LongStringPreference): |
|||
widget = WysiwygWidget(attrs={'class': 'editor-field'}) |
|||
|
|||
|
|||
class EditorTextPreferences(LongStringPreference): |
|||
widget = UnfoldAdminTextareaWidget(attrs={'class': 'editor-field', 'rows': 15}) |
|||
|
|||
|
|||
class JsonSerializer(BaseSerializer): |
|||
@classmethod |
|||
def serialize(cls, value, **kwargs): |
|||
return json.dumps(value, ensure_ascii=False) |
|||
|
|||
@classmethod |
|||
def to_python(cls, value, **kwargs): |
|||
if isinstance(value, str) and len(value.strip()) > 0: |
|||
try: |
|||
return json.loads(value) |
|||
except json.JSONDecodeError: |
|||
return {} |
|||
return value if isinstance(value, dict) else {} |
|||
|
|||
|
|||
# --------------------------------------------------------- |
|||
# Sections |
|||
# --------------------------------------------------------- |
|||
|
|||
general_section = Section('general', verbose_name=_('General Settings')) |
|||
contact_section = Section('contact', verbose_name=_('Contact & Support')) |
|||
content_section = Section('content', verbose_name=_('Content Pages')) |
|||
|
|||
|
|||
# --------------------------------------------------------- |
|||
# Registered Preferences |
|||
# --------------------------------------------------------- |
|||
|
|||
@global_preferences_registry.register |
|||
class SiteName(StringPreference): |
|||
section = general_section |
|||
name = 'site_name' |
|||
verbose_name = _('Site Name') |
|||
default = 'My Application' |
|||
required = True |
|||
|
|||
|
|||
@global_preferences_registry.register |
|||
class MaintenanceMode(BooleanPreference): |
|||
section = general_section |
|||
name = 'maintenance_mode' |
|||
verbose_name = _('Maintenance Mode') |
|||
default = False |
|||
|
|||
|
|||
@global_preferences_registry.register |
|||
class ContactEmail(StringPreference): |
|||
section = contact_section |
|||
name = 'contact_email' |
|||
verbose_name = _('Support Email') |
|||
default = '[email protected]' |
|||
required = False |
|||
|
|||
|
|||
@global_preferences_registry.register |
|||
class SupportPhone(StringPreference): |
|||
section = contact_section |
|||
name = 'support_phone' |
|||
verbose_name = _('Support Phone Number') |
|||
default = '+1 555 1234567' |
|||
required = False |
|||
|
|||
|
|||
@global_preferences_registry.register |
|||
class AboutUs(EditorPreferences): |
|||
section = content_section |
|||
name = 'about_us' |
|||
verbose_name = _('About Us Page') |
|||
default = '<h2>About Us</h2><p>Welcome to our application.</p>' |
|||
required = False |
|||
|
|||
|
|||
@global_preferences_registry.register |
|||
class TermsAndConditions(EditorPreferences): |
|||
section = content_section |
|||
name = 'terms_and_conditions' |
|||
verbose_name = _('Terms & Conditions') |
|||
default = '<h2>Terms and Conditions</h2><p>Standard terms and conditions.</p>' |
|||
required = False |
|||
@ -0,0 +1,32 @@ |
|||
class DynamicPreferencesException(Exception): |
|||
detail_default = "An exception occurred with django-dynamic-preferences" |
|||
|
|||
def __init__(self, detail=None): |
|||
if detail is not None: |
|||
self.detail = str(detail) |
|||
else: |
|||
self.detail = str(self.detail_default) |
|||
|
|||
def __str__(self): |
|||
return self.detail |
|||
|
|||
|
|||
class MissingDefault(DynamicPreferencesException): |
|||
detail_default = "You must provide a default value for all preferences" |
|||
|
|||
|
|||
class NotFoundInRegistry(DynamicPreferencesException, KeyError): |
|||
detail_default = "Preference with this name/section not found in registry" |
|||
|
|||
|
|||
class DoesNotExist(DynamicPreferencesException): |
|||
detail_default = "Cannot retrieve preference value, ensure the preference is correctly registered and database is synced" |
|||
|
|||
|
|||
class CachedValueNotFound(DynamicPreferencesException): |
|||
detail_default = "Cached value not found" |
|||
|
|||
|
|||
class MissingModel(DynamicPreferencesException): |
|||
detail_default = 'You must define a model choice through "model" \ |
|||
or "queryset" attribute' |
|||
@ -0,0 +1,152 @@ |
|||
from six import string_types |
|||
from django import forms |
|||
from django.core.exceptions import ValidationError |
|||
from collections import OrderedDict |
|||
|
|||
from .registries import global_preferences_registry |
|||
from .models import GlobalPreferenceModel |
|||
from .exceptions import NotFoundInRegistry |
|||
|
|||
|
|||
class AbstractSinglePreferenceForm(forms.ModelForm): |
|||
class Meta: |
|||
fields = ("section", "name", "raw_value") |
|||
|
|||
def __init__(self, *args, **kwargs): |
|||
|
|||
self.instance = kwargs.get("instance") |
|||
initial = {} |
|||
if self.instance: |
|||
initial["raw_value"] = self.instance.value |
|||
kwargs["initial"] = initial |
|||
super(AbstractSinglePreferenceForm, self).__init__(*args, **kwargs) |
|||
|
|||
if self.instance.name: |
|||
self.fields["raw_value"] = self.instance.preference.setup_field() |
|||
|
|||
def clean(self): |
|||
cleaned_data = super(AbstractSinglePreferenceForm, self).clean() |
|||
try: |
|||
self.instance.name, self.instance.section = ( |
|||
cleaned_data["name"], |
|||
cleaned_data["section"], |
|||
) |
|||
except KeyError: # changelist form |
|||
pass |
|||
try: |
|||
self.instance.preference |
|||
except NotFoundInRegistry: |
|||
raise ValidationError(NotFoundInRegistry.detail_default) |
|||
return self.cleaned_data |
|||
|
|||
def save(self, *args, **kwargs): |
|||
self.instance.value = self.cleaned_data["raw_value"] |
|||
return super(AbstractSinglePreferenceForm, self).save(*args, **kwargs) |
|||
|
|||
|
|||
class SinglePerInstancePreferenceForm(AbstractSinglePreferenceForm): |
|||
class Meta: |
|||
fields = ("instance",) + AbstractSinglePreferenceForm.Meta.fields |
|||
|
|||
def clean(self): |
|||
cleaned_data = super(AbstractSinglePreferenceForm, self).clean() |
|||
try: |
|||
self.instance.name, self.instance.section = ( |
|||
cleaned_data["name"], |
|||
cleaned_data["section"], |
|||
) |
|||
except KeyError: # changelist form |
|||
pass |
|||
i = cleaned_data.get("instance") |
|||
if i: |
|||
self.instance.instance = i |
|||
try: |
|||
self.instance.preference |
|||
except NotFoundInRegistry: |
|||
raise ValidationError(NotFoundInRegistry.detail_default) |
|||
return self.cleaned_data |
|||
|
|||
|
|||
class GlobalSinglePreferenceForm(AbstractSinglePreferenceForm): |
|||
class Meta: |
|||
model = GlobalPreferenceModel |
|||
fields = AbstractSinglePreferenceForm.Meta.fields |
|||
|
|||
|
|||
def preference_form_builder(form_base_class, preferences=[], **kwargs): |
|||
""" |
|||
Return a form class for updating preferences |
|||
:param form_base_class: a Form class used as the base. Must have a ``registry` attribute |
|||
:param preferences: a list of :py:class: |
|||
:param section: a section where the form builder will load preferences |
|||
""" |
|||
registry = form_base_class.registry |
|||
preferences_obj = [] |
|||
if len(preferences) > 0: |
|||
# Preferences have been selected explicitly |
|||
for pref in preferences: |
|||
if isinstance(pref, string_types): |
|||
preferences_obj.append(registry.get(name=pref)) |
|||
elif type(pref) == tuple: |
|||
preferences_obj.append(registry.get(name=pref[0], section=pref[1])) |
|||
else: |
|||
raise NotImplementedError( |
|||
"The data you provide can't be converted to a Preference object" |
|||
) |
|||
elif kwargs.get("section", None): |
|||
# Try to use section param |
|||
preferences_obj = registry.preferences(section=kwargs.get("section", None)) |
|||
|
|||
else: |
|||
# display all preferences in the form |
|||
preferences_obj = registry.preferences() |
|||
|
|||
fields = OrderedDict() |
|||
instances = [] |
|||
if "model" in kwargs: |
|||
# backward compat, see #212 |
|||
manager_kwargs = kwargs.get("model") |
|||
else: |
|||
manager_kwargs = {"instance": kwargs.get("instance", None)} |
|||
manager = registry.manager(**manager_kwargs) |
|||
|
|||
for preference in preferences_obj: |
|||
f = preference.field |
|||
instance = manager.get_db_pref( |
|||
section=preference.section.name, name=preference.name |
|||
) |
|||
f.initial = instance.value |
|||
fields[preference.identifier()] = f |
|||
instances.append(instance) |
|||
|
|||
form_class = type("Custom" + form_base_class.__name__, (form_base_class,), {}) |
|||
form_class.base_fields = fields |
|||
form_class.preferences = preferences_obj |
|||
form_class.instances = instances |
|||
form_class.manager = manager |
|||
return form_class |
|||
|
|||
|
|||
def global_preference_form_builder(preferences=[], **kwargs): |
|||
""" |
|||
A shortcut :py:func:`preference_form_builder(GlobalPreferenceForm, preferences, **kwargs)` |
|||
""" |
|||
return preference_form_builder(GlobalPreferenceForm, preferences, **kwargs) |
|||
|
|||
|
|||
class PreferenceForm(forms.Form): |
|||
|
|||
registry = None |
|||
|
|||
def update_preferences(self, **kwargs): |
|||
for instance in self.instances: |
|||
self.manager.update_db_pref( |
|||
instance.preference.section.name, |
|||
instance.preference.name, |
|||
self.cleaned_data[instance.preference.identifier()], |
|||
) |
|||
|
|||
|
|||
class GlobalPreferenceForm(PreferenceForm): |
|||
|
|||
registry = global_preferences_registry |
|||
@ -0,0 +1,95 @@ |
|||
# SOME DESCRIPTIVE TITLE. |
|||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER |
|||
# This file is distributed under the same license as the PACKAGE package. |
|||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
|||
# |
|||
msgid "" |
|||
msgstr "" |
|||
"Project-Id-Version: \n" |
|||
"Report-Msgid-Bugs-To: \n" |
|||
"POT-Creation-Date: 2026-05-20 08:25+0330\n" |
|||
"PO-Revision-Date: 2018-11-09 17:15+0100\n" |
|||
"Last-Translator: \n" |
|||
"Language-Team: \n" |
|||
"Language: ar\n" |
|||
"MIME-Version: 1.0\n" |
|||
"Content-Type: text/plain; charset=UTF-8\n" |
|||
"Content-Transfer-Encoding: 8bit\n" |
|||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " |
|||
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" |
|||
"X-Generator: Poedit 2.1.1\n" |
|||
|
|||
#: .\dynamic_preferences\admin.py:59 |
|||
#, fuzzy |
|||
#| msgid "Verbose Name" |
|||
msgid "Verbose name" |
|||
msgstr "اسم مطول" |
|||
|
|||
#: .\dynamic_preferences\admin.py:63 |
|||
#, fuzzy |
|||
#| msgid "Help Text" |
|||
msgid "Help text" |
|||
msgstr "نص المساعدة" |
|||
|
|||
#: .\dynamic_preferences\admin.py:84 |
|||
msgid "Default Value" |
|||
msgstr "القيمة الافتراضية" |
|||
|
|||
#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 |
|||
msgid "Section Name" |
|||
msgstr "إسم القسم" |
|||
|
|||
#: .\dynamic_preferences\admin.py:118 |
|||
msgid "Reset selected preferences to default values" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\apps.py:10 |
|||
msgid "Settings" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:34 |
|||
msgid "Name" |
|||
msgstr "الاسم" |
|||
|
|||
#: .\dynamic_preferences\models.py:37 |
|||
msgid "Raw Value" |
|||
msgstr "القيمة الأولية" |
|||
|
|||
#: .\dynamic_preferences\models.py:51 |
|||
msgid "Verbose Name" |
|||
msgstr "اسم مطول" |
|||
|
|||
#: .\dynamic_preferences\models.py:57 |
|||
msgid "Help Text" |
|||
msgstr "نص المساعدة" |
|||
|
|||
#: .\dynamic_preferences\models.py:94 |
|||
msgid "Global preference" |
|||
msgstr "التفضيل العام" |
|||
|
|||
#: .\dynamic_preferences\models.py:95 |
|||
msgid "Global preferences" |
|||
msgstr "التفضيل العام" |
|||
|
|||
#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 |
|||
msgid "Submit" |
|||
msgstr "إرسال" |
|||
|
|||
#: .\dynamic_preferences\users\apps.py:11 |
|||
msgid "Preferences - Users" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\users\models.py:14 |
|||
#, fuzzy |
|||
#| msgid "Global preference" |
|||
msgid "user preference" |
|||
msgstr "التفضيل العام" |
|||
|
|||
#: .\dynamic_preferences\users\models.py:15 |
|||
#, fuzzy |
|||
#| msgid "Global preferences" |
|||
msgid "user preferences" |
|||
msgstr "التفضيل العام" |
|||
|
|||
#~ msgid "Dynamic Preferences" |
|||
#~ msgstr "التفضيلات الديناميكية" |
|||
@ -0,0 +1,82 @@ |
|||
# SOME DESCRIPTIVE TITLE. |
|||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER |
|||
# This file is distributed under the same license as the PACKAGE package. |
|||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
|||
# |
|||
#, fuzzy |
|||
msgid "" |
|||
msgstr "" |
|||
"Project-Id-Version: PACKAGE VERSION\n" |
|||
"Report-Msgid-Bugs-To: \n" |
|||
"POT-Creation-Date: 2026-05-20 08:25+0330\n" |
|||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" |
|||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" |
|||
"Language-Team: LANGUAGE <[email protected]>\n" |
|||
"Language: \n" |
|||
"MIME-Version: 1.0\n" |
|||
"Content-Type: text/plain; charset=UTF-8\n" |
|||
"Content-Transfer-Encoding: 8bit\n" |
|||
"Plural-Forms: nplurals=2; plural=(n != 1);\n" |
|||
#: .\dynamic_preferences\admin.py:59 |
|||
msgid "Verbose name" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\admin.py:63 |
|||
msgid "Help text" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\admin.py:84 |
|||
msgid "Default Value" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 |
|||
msgid "Section Name" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\admin.py:118 |
|||
msgid "Reset selected preferences to default values" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\apps.py:10 |
|||
msgid "Settings" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:34 |
|||
msgid "Name" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:37 |
|||
msgid "Raw Value" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:51 |
|||
msgid "Verbose Name" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:57 |
|||
msgid "Help Text" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:94 |
|||
msgid "Global preference" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:95 |
|||
msgid "Global preferences" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 |
|||
msgid "Submit" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\users\apps.py:11 |
|||
msgid "Preferences - Users" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\users\models.py:14 |
|||
msgid "user preference" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\users\models.py:15 |
|||
msgid "user preferences" |
|||
msgstr "" |
|||
@ -0,0 +1,82 @@ |
|||
# SOME DESCRIPTIVE TITLE. |
|||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER |
|||
# This file is distributed under the same license as the PACKAGE package. |
|||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
|||
# |
|||
#, fuzzy |
|||
msgid "" |
|||
msgstr "" |
|||
"Project-Id-Version: PACKAGE VERSION\n" |
|||
"Report-Msgid-Bugs-To: \n" |
|||
"POT-Creation-Date: 2026-05-20 08:25+0330\n" |
|||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" |
|||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" |
|||
"Language-Team: LANGUAGE <[email protected]>\n" |
|||
"Language: \n" |
|||
"MIME-Version: 1.0\n" |
|||
"Content-Type: text/plain; charset=UTF-8\n" |
|||
"Content-Transfer-Encoding: 8bit\n" |
|||
"Plural-Forms: nplurals=2; plural=(n != 1);\n" |
|||
#: .\dynamic_preferences\admin.py:59 |
|||
msgid "Verbose name" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\admin.py:63 |
|||
msgid "Help text" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\admin.py:84 |
|||
msgid "Default Value" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 |
|||
msgid "Section Name" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\admin.py:118 |
|||
msgid "Reset selected preferences to default values" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\apps.py:10 |
|||
msgid "Settings" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:34 |
|||
msgid "Name" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:37 |
|||
msgid "Raw Value" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:51 |
|||
msgid "Verbose Name" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:57 |
|||
msgid "Help Text" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:94 |
|||
msgid "Global preference" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:95 |
|||
msgid "Global preferences" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 |
|||
msgid "Submit" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\users\apps.py:11 |
|||
msgid "Preferences - Users" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\users\models.py:14 |
|||
msgid "user preference" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\users\models.py:15 |
|||
msgid "user preferences" |
|||
msgstr "" |
|||
@ -0,0 +1,90 @@ |
|||
# SOME DESCRIPTIVE TITLE. |
|||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER |
|||
# This file is distributed under the same license as the PACKAGE package. |
|||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. |
|||
# |
|||
msgid "" |
|||
msgstr "" |
|||
"Project-Id-Version: \n" |
|||
"Report-Msgid-Bugs-To: \n" |
|||
"POT-Creation-Date: 2026-05-20 08:25+0330\n" |
|||
"PO-Revision-Date: 2018-11-09 17:14+0100\n" |
|||
"Last-Translator: \n" |
|||
"Language-Team: \n" |
|||
"Language: fr\n" |
|||
"MIME-Version: 1.0\n" |
|||
"Content-Type: text/plain; charset=UTF-8\n" |
|||
"Content-Transfer-Encoding: 8bit\n" |
|||
"Plural-Forms: nplurals=2; plural=(n > 1);\n" |
|||
"X-Generator: Poedit 2.1.1\n" |
|||
|
|||
#: .\dynamic_preferences\admin.py:59 |
|||
#, fuzzy |
|||
#| msgid "Verbose Name" |
|||
msgid "Verbose name" |
|||
msgstr "Bezeichnung" |
|||
|
|||
#: .\dynamic_preferences\admin.py:63 |
|||
#, fuzzy |
|||
#| msgid "Help Text" |
|||
msgid "Help text" |
|||
msgstr "Hilfetext" |
|||
|
|||
#: .\dynamic_preferences\admin.py:84 |
|||
msgid "Default Value" |
|||
msgstr "Standardwert" |
|||
|
|||
#: .\dynamic_preferences\admin.py:93 .\dynamic_preferences\models.py:30 |
|||
msgid "Section Name" |
|||
msgstr "Abschnitt" |
|||
|
|||
#: .\dynamic_preferences\admin.py:118 |
|||
msgid "Reset selected preferences to default values" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\apps.py:10 |
|||
msgid "Settings" |
|||
msgstr "" |
|||
|
|||
#: .\dynamic_preferences\models.py:34 |
|||
msgid "Name" |
|||
msgstr "Name" |
|||
|
|||
#: .\dynamic_preferences\models.py:37 |
|||
msgid "Raw Value" |
|||
msgstr "Wert" |
|||
|
|||
#: .\dynamic_preferences\models.py:51 |
|||
msgid "Verbose Name" |
|||
msgstr "Bezeichnung" |
|||
|
|||
#: .\dynamic_preferences\models.py:57 |
|||
msgid "Help Text" |
|||
msgstr "Hilfetext" |
|||
|
|||
#: .\dynamic_preferences\models.py:94 |
|||
msgid "Global preference" |
|||
msgstr "Globale Einstellung" |
|||
|
|||
#: .\dynamic_preferences\models.py:95 |
|||
msgid "Global preferences" |
|||
msgstr "Globale Einstellungen" |
|||
|
|||
#: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 |
|||
msgid "Submit" |
|||
msgstr "Absenden" |
|||
|
|||
#: .\dynamic_preferences\users\apps.py:11 |
|||
msgid "Preferences - Users" |
|||
msgstr "Einstellungen - Benutzer" |
|||
|
|||
#: .\dynamic_preferences\users\models.py:14 |
|||
msgid "user preference" |
|||
msgstr "Benutzer Einstellung" |
|||
|
|||
#: .\dynamic_preferences\users\models.py:15 |
|||
msgid "user preferences" |
|||
msgstr "Benutzer Einstellungen" |
|||
|
|||
#~ msgid "Dynamic Preferences" |
|||
#~ msgstr "Dynamische Einstellungen" |
|||
Some files were not shown because too many files changed in this diff
Write
Preview
Loading…
Cancel
Save
Reference in new issue