initial commit
This commit is contained in:
31
.coveragerc
Normal file
31
.coveragerc
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[run]
|
||||||
|
branch = True
|
||||||
|
source =
|
||||||
|
users
|
||||||
|
api
|
||||||
|
utils
|
||||||
|
payments
|
||||||
|
communications
|
||||||
|
gallery
|
||||||
|
events
|
||||||
|
blog
|
||||||
|
config
|
||||||
|
omit =
|
||||||
|
*/migrations/*
|
||||||
|
*/tests/*
|
||||||
|
*/__init__.py
|
||||||
|
config/settings/*
|
||||||
|
config/urls.py
|
||||||
|
config/wsgi.py
|
||||||
|
config/asgi.py
|
||||||
|
|
||||||
|
[report]
|
||||||
|
skip_empty = True
|
||||||
|
show_missing = True
|
||||||
|
precision = 2
|
||||||
|
|
||||||
|
[html]
|
||||||
|
directory = htmlcov
|
||||||
|
|
||||||
|
[xml]
|
||||||
|
output = coverage.xml
|
||||||
60
.env.sample
Normal file
60
.env.sample
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Gunicorn
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_THREADS=2
|
||||||
|
GUNICORN_TIMEOUT=120
|
||||||
|
|
||||||
|
# Django
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.production
|
||||||
|
SECRET_KEY=replace-me
|
||||||
|
DEBUG=False
|
||||||
|
ALLOWED_HOSTS=api-host.example
|
||||||
|
DJANGO_HOST=https://api-host.example
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_ENGINE=django.db.backends.postgresql
|
||||||
|
DB_NAME=app
|
||||||
|
DB_USER=app
|
||||||
|
DB_PASSWORD=change-me
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Redis / Celery
|
||||||
|
REDIS_PASSWORD=change-me
|
||||||
|
REDIS_URL=redis://:change-me@redis:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://:change-me@redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://:change-me@redis:6379/1
|
||||||
|
|
||||||
|
# Email
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
EMAIL_HOST=smtp.example.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_HOST_USER=smtp-user
|
||||||
|
EMAIL_HOST_PASSWORD=smtp-password
|
||||||
|
DEFAULT_FROM_EMAIL=noreply@example.com
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=replace-me
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_LIFETIME=3600
|
||||||
|
JWT_REFRESH_TOKEN_LIFETIME=86400
|
||||||
|
|
||||||
|
# Frontend integration
|
||||||
|
CORS_ALLOWED_ORIGINS=https://frontend-host.example
|
||||||
|
FRONTEND_ROOT=https://frontend-host.example
|
||||||
|
FRONTEND_PASSWORD_RESET_PAGE=https://frontend-host.example/reset-password
|
||||||
|
FRONTEND_CALLBACK_URL=https://frontend-host.example/payments/result
|
||||||
|
|
||||||
|
# ZarinPal
|
||||||
|
ZARINPAL_MERCHANT_ID=merchant-id
|
||||||
|
ZARINPAL_USE_SANDBOX=False
|
||||||
|
ZARINPAL_CALLBACK_URL=https://api-host.example/api/payments/callback
|
||||||
|
|
||||||
|
# Optional test overrides
|
||||||
|
TEST_DB_ENGINE=django.db.backends.sqlite3
|
||||||
|
TEST_DB_NAME=db.test.sqlite3
|
||||||
|
TEST_DB_USER=
|
||||||
|
TEST_DB_PASSWORD=
|
||||||
|
TEST_DB_HOST=
|
||||||
|
TEST_DB_PORT=
|
||||||
|
|
||||||
51
.env.test
Normal file
51
.env.test
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Django
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.test
|
||||||
|
SECRET_KEY=test-secret-key
|
||||||
|
DEBUG=False
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,testserver
|
||||||
|
DJANGO_HOST=http://localhost:8000
|
||||||
|
|
||||||
|
# Gunicorn
|
||||||
|
GUNICORN_WORKERS=2
|
||||||
|
GUNICORN_THREADS=2
|
||||||
|
GUNICORN_TIMEOUT=120
|
||||||
|
|
||||||
|
# Test database
|
||||||
|
TEST_DB_ENGINE=django.db.backends.sqlite3
|
||||||
|
TEST_DB_NAME=db.test.sqlite3
|
||||||
|
TEST_DB_USER=
|
||||||
|
TEST_DB_PASSWORD=
|
||||||
|
TEST_DB_HOST=
|
||||||
|
TEST_DB_PORT=
|
||||||
|
|
||||||
|
# Redis / Celery
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
||||||
|
|
||||||
|
# Email
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
|
||||||
|
EMAIL_HOST=localhost
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=False
|
||||||
|
EMAIL_HOST_USER=
|
||||||
|
EMAIL_HOST_PASSWORD=
|
||||||
|
DEFAULT_FROM_EMAIL=noreply@example.com
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=test-jwt-key
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_LIFETIME=3600
|
||||||
|
JWT_REFRESH_TOKEN_LIFETIME=86400
|
||||||
|
|
||||||
|
# Frontend integration
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
FRONTEND_ROOT=http://localhost:3000
|
||||||
|
FRONTEND_PASSWORD_RESET_PAGE=http://localhost:3000/reset-password
|
||||||
|
FRONTEND_CALLBACK_URL=http://localhost:3000/payments/result
|
||||||
|
|
||||||
|
# ZarinPal
|
||||||
|
ZARINPAL_MERCHANT_ID=sandbox-merchant
|
||||||
|
ZARINPAL_USE_SANDBOX=True
|
||||||
|
ZARINPAL_CALLBACK_URL=http://localhost:8000/api/payments/callback
|
||||||
129
.github/workflows/backend.yml
vendored
Normal file
129
.github/workflows/backend.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
name: Backend CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: app
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_USER: app
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U app -d app"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
env:
|
||||||
|
SECRET_KEY: github-ci-secret-key
|
||||||
|
DEBUG: "False"
|
||||||
|
DJANGO_SETTINGS_MODULE: config.settings.test
|
||||||
|
ALLOWED_HOSTS: localhost,127.0.0.1,testserver
|
||||||
|
DJANGO_HOST: http://localhost:8000
|
||||||
|
DB_ENGINE: django.db.backends.postgresql
|
||||||
|
DB_NAME: app
|
||||||
|
DB_USER: app
|
||||||
|
DB_PASSWORD: password
|
||||||
|
DB_HOST: localhost
|
||||||
|
DB_PORT: "5432"
|
||||||
|
TEST_DB_ENGINE: django.db.backends.postgresql
|
||||||
|
TEST_DB_NAME: app
|
||||||
|
TEST_DB_USER: app
|
||||||
|
TEST_DB_PASSWORD: password
|
||||||
|
TEST_DB_HOST: localhost
|
||||||
|
TEST_DB_PORT: "5432"
|
||||||
|
REDIS_PASSWORD: ""
|
||||||
|
REDIS_URL: redis://localhost:6379/0
|
||||||
|
CELERY_BROKER_URL: redis://localhost:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://localhost:6379/1
|
||||||
|
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
||||||
|
EMAIL_HOST: localhost
|
||||||
|
EMAIL_PORT: "1025"
|
||||||
|
EMAIL_USE_TLS: "False"
|
||||||
|
EMAIL_HOST_USER: ""
|
||||||
|
EMAIL_HOST_PASSWORD: ""
|
||||||
|
DEFAULT_FROM_EMAIL: noreply@example.com
|
||||||
|
CORS_ALLOWED_ORIGINS: http://localhost:3000
|
||||||
|
FRONTEND_ROOT: http://localhost:3000
|
||||||
|
FRONTEND_PASSWORD_RESET_PAGE: http://localhost:3000/reset-password
|
||||||
|
FRONTEND_CALLBACK_URL: http://localhost:3000/payments/result
|
||||||
|
JWT_SECRET_KEY: test-jwt-key
|
||||||
|
JWT_ALGORITHM: HS256
|
||||||
|
JWT_ACCESS_TOKEN_LIFETIME: "3600"
|
||||||
|
JWT_REFRESH_TOKEN_LIFETIME: "86400"
|
||||||
|
ZARINPAL_MERCHANT_ID: sandbox-merchant
|
||||||
|
ZARINPAL_USE_SANDBOX: "True"
|
||||||
|
ZARINPAL_CALLBACK_URL: http://localhost:8000/api/payments/callback
|
||||||
|
PYTHON_VERSION: "3.12"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install coverage
|
||||||
|
|
||||||
|
- name: Prepare environment file
|
||||||
|
run: cp .env.test .env
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: python manage.py migrate --noinput
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: |
|
||||||
|
coverage run --rcfile=.coveragerc manage.py test --settings=config.settings.test --verbosity 2
|
||||||
|
coverage report -m
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Deploy backend services
|
||||||
|
uses: appleboy/ssh-action@v1.2.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd "${{ secrets.DEPLOY_PATH }}/backend/guilan-ace-backend"
|
||||||
|
git fetch --prune origin
|
||||||
|
git checkout "${{ vars.BACKEND_BRANCH || 'main' }}"
|
||||||
|
git pull --ff-only origin "${{ vars.BACKEND_BRANCH || 'main' }}"
|
||||||
|
cd "${{ secrets.DEPLOY_PATH }}"
|
||||||
|
docker compose up -d --build web worker beat
|
||||||
|
docker image prune -f
|
||||||
139
.gitignore
vendored
Normal file
139
.gitignore
vendored
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Django #
|
||||||
|
*.log
|
||||||
|
*.pot
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
db.sqlite3
|
||||||
|
db.test.sqlite3
|
||||||
|
media
|
||||||
|
|
||||||
|
# Backup files #
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# If you are using PyCharm #
|
||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
|
# AWS User-specific
|
||||||
|
.idea/**/aws.xml
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
out/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Python #
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.whl
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
.pytest_cache/
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery
|
||||||
|
celerybeat-schedule.*
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Sublime Text #
|
||||||
|
*.tmlanguage.cache
|
||||||
|
*.tmPreferences.cache
|
||||||
|
*.stTheme.cache
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# sftp configuration file
|
||||||
|
sftp-config.json
|
||||||
|
|
||||||
|
# Package control specific files Package
|
||||||
|
Control.last-run
|
||||||
|
Control.ca-list
|
||||||
|
Control.ca-bundle
|
||||||
|
Control.system-ca-bundle
|
||||||
|
GitHub.sublime-settings
|
||||||
|
|
||||||
|
# Visual Studio Code #
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history
|
||||||
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Guilan ACE Backend
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Django 5 with Ninja API routers
|
||||||
|
- PostgreSQL, Redis, Celery, Gunicorn
|
||||||
|
- Prometheus instrumentation through `django-prometheus`
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
```text
|
||||||
|
guilan-ace-backend/
|
||||||
|
apps/
|
||||||
|
users/
|
||||||
|
blog/
|
||||||
|
gallery/
|
||||||
|
events/
|
||||||
|
communications/
|
||||||
|
payments/
|
||||||
|
certificates/
|
||||||
|
core/
|
||||||
|
config/
|
||||||
|
static/
|
||||||
|
templates/
|
||||||
|
manage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\\Scripts\\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.sample .env
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
```bash
|
||||||
|
python manage.py test --settings=config.settings.test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Domain routers live under `apps/<domain>/api`.
|
||||||
|
- Shared auth helpers live in `core/authentication.py`.
|
||||||
|
- Shared base models, admin helpers, choices, and template tags live under `core/`.
|
||||||
|
|
||||||
0
apps/__init__.py
Normal file
0
apps/__init__.py
Normal file
159
apps/blog/admin.py
Normal file
159
apps/blog/admin.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
from simplemde.widgets import SimpleMDEEditor
|
||||||
|
|
||||||
|
from apps.blog.models import Category, Tag, Post, Comment, Like
|
||||||
|
from apps.blog.resources import PostResource, CategoryResource
|
||||||
|
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
|
||||||
|
@admin.register(Category)
|
||||||
|
class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = CategoryResource
|
||||||
|
list_display = ('name', 'slug', 'created_at', 'is_deleted')
|
||||||
|
list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter)
|
||||||
|
search_fields = ('name', 'description')
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('name', 'slug', 'description')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
actions = BaseModelAdmin.actions + ['restore_categories']
|
||||||
|
|
||||||
|
def restore_categories(self, request, queryset):
|
||||||
|
for category in queryset:
|
||||||
|
category.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} categories.")
|
||||||
|
restore_categories.short_description = "Restore selected categories"
|
||||||
|
|
||||||
|
@admin.register(Tag)
|
||||||
|
class TagAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
list_display = ('name', 'slug', 'created_at', 'is_deleted')
|
||||||
|
list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter)
|
||||||
|
search_fields = ('name',)
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('name', 'slug')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostAdminForm(forms.ModelForm):
|
||||||
|
content = forms.CharField(widget=SimpleMDEEditor())
|
||||||
|
excerpt = forms.CharField(widget=SimpleMDEEditor())
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Post
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Post)
|
||||||
|
class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
form = PostAdminForm
|
||||||
|
resource_class = PostResource
|
||||||
|
list_display = ('title', 'author', 'status', 'category', 'is_featured', 'published_at', 'created_at')
|
||||||
|
list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter)
|
||||||
|
search_fields = ('title', 'content', 'author__username')
|
||||||
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
|
filter_horizontal = ('tags',)
|
||||||
|
date_hierarchy = 'published_at'
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('title', 'slug', 'content', 'excerpt', 'featured_image')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('author', 'category', 'tags', 'status', 'is_featured', 'published_at')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ('deleted_at',)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + ['make_published', 'make_draft', 'make_featured', 'restore_posts']
|
||||||
|
|
||||||
|
def make_published(self, request, queryset):
|
||||||
|
queryset.update(status='published')
|
||||||
|
self.message_user(request, f"Published {queryset.count()} posts.")
|
||||||
|
make_published.short_description = "Mark selected posts as published"
|
||||||
|
|
||||||
|
def make_draft(self, request, queryset):
|
||||||
|
queryset.update(status='draft')
|
||||||
|
self.message_user(request, f"Marked {queryset.count()} posts as draft.")
|
||||||
|
make_draft.short_description = "Mark selected posts as draft"
|
||||||
|
|
||||||
|
def make_featured(self, request, queryset):
|
||||||
|
queryset.update(is_featured=True)
|
||||||
|
self.message_user(request, f"Featured {queryset.count()} posts.")
|
||||||
|
make_featured.short_description = "Mark selected posts as featured"
|
||||||
|
|
||||||
|
def restore_posts(self, request, queryset):
|
||||||
|
for post in queryset:
|
||||||
|
post.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} posts.")
|
||||||
|
restore_posts.short_description = "Restore selected posts"
|
||||||
|
|
||||||
|
@admin.register(Comment)
|
||||||
|
class CommentAdmin(BaseModelAdmin):
|
||||||
|
list_display = ('author', 'post', 'content_preview', 'is_approved', 'created_at')
|
||||||
|
list_filter = ('is_approved', 'created_at', 'post', SoftDeleteListFilter)
|
||||||
|
search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title')
|
||||||
|
readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('post', 'author', 'content')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('is_approved', 'created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
actions = BaseModelAdmin.actions + ['approve_comments', 'disapprove_comments']
|
||||||
|
|
||||||
|
def content_preview(self, obj):
|
||||||
|
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
||||||
|
content_preview.short_description = 'Content Preview'
|
||||||
|
|
||||||
|
def approve_comments(self, request, queryset):
|
||||||
|
queryset.update(is_approved=True)
|
||||||
|
self.message_user(request, f"Approved {queryset.count()} comments.")
|
||||||
|
approve_comments.short_description = "Approve selected comments"
|
||||||
|
|
||||||
|
def disapprove_comments(self, request, queryset):
|
||||||
|
queryset.update(is_approved=False)
|
||||||
|
self.message_user(request, f"Disapproved {queryset.count()} comments.")
|
||||||
|
disapprove_comments.short_description = "Disapprove selected comments"
|
||||||
|
|
||||||
|
@admin.register(Like)
|
||||||
|
class LikeAdmin(BaseModelAdmin):
|
||||||
|
list_display = ('user', 'post', 'created_at')
|
||||||
|
list_filter = ('created_at', 'post')
|
||||||
|
search_fields = ('user__username', 'post__title')
|
||||||
0
apps/blog/api/__init__.py
Normal file
0
apps/blog/api/__init__.py
Normal file
87
apps/blog/api/schemas.py
Normal file
87
apps/blog/api/schemas.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Blog API schemas."""
|
||||||
|
|
||||||
|
from ninja import Schema, ModelSchema
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from apps.blog.models import Category, Tag, Comment
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySchema(ModelSchema):
|
||||||
|
class Config:
|
||||||
|
model = Category
|
||||||
|
model_fields = ['id', 'name', 'slug', 'description']
|
||||||
|
|
||||||
|
class TagSchema(ModelSchema):
|
||||||
|
class Config:
|
||||||
|
model = Tag
|
||||||
|
model_fields = ['id', 'name', 'slug']
|
||||||
|
|
||||||
|
class AuthorSchema(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
profile_picture: Optional[str] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_profile_picture(obj, context):
|
||||||
|
request = context['request']
|
||||||
|
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.profile_picture.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
class PostListSchema(Schema):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
slug: str
|
||||||
|
excerpt: str
|
||||||
|
author: AuthorSchema
|
||||||
|
featured_image: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
published_at: Optional[datetime] = None
|
||||||
|
category: Optional[CategorySchema] = None
|
||||||
|
tags: List[TagSchema]
|
||||||
|
is_featured: bool
|
||||||
|
created_at: datetime
|
||||||
|
reading_time: int
|
||||||
|
|
||||||
|
class PostDetailSchema(PostListSchema):
|
||||||
|
content: str
|
||||||
|
content_html: str
|
||||||
|
|
||||||
|
class PostCreateSchema(Schema):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
excerpt: Optional[str] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
tag_ids: Optional[List[int]] = []
|
||||||
|
status: str = "draft"
|
||||||
|
is_featured: bool = False
|
||||||
|
|
||||||
|
class CommentSchema(ModelSchema):
|
||||||
|
author: AuthorSchema
|
||||||
|
replies: List['CommentSchema'] = []
|
||||||
|
post_id: int
|
||||||
|
post_title: str
|
||||||
|
post_slug: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Comment
|
||||||
|
model_fields = ['id', 'content', 'created_at', 'is_approved']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_post_id(obj):
|
||||||
|
return obj.post_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_post_title(obj):
|
||||||
|
return obj.post.title
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_post_slug(obj):
|
||||||
|
return obj.post.slug
|
||||||
|
|
||||||
|
class CommentCreateSchema(Schema):
|
||||||
|
content: str
|
||||||
|
parent_id: Optional[int] = None
|
||||||
304
apps/blog/api/views.py
Normal file
304
apps/blog/api/views.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Q, Prefetch
|
||||||
|
|
||||||
|
from ninja import Router, Query
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from apps.users.models import User
|
||||||
|
from apps.blog.api.schemas import (
|
||||||
|
CategorySchema,
|
||||||
|
CommentCreateSchema,
|
||||||
|
CommentSchema,
|
||||||
|
PostCreateSchema,
|
||||||
|
PostDetailSchema,
|
||||||
|
PostListSchema,
|
||||||
|
TagSchema,
|
||||||
|
)
|
||||||
|
from apps.blog.models import Post, Category, Tag, Comment, Like
|
||||||
|
from core.api.schemas import ErrorSchema, MessageSchema
|
||||||
|
from core.authentication import jwt_auth
|
||||||
|
|
||||||
|
blog_router = Router()
|
||||||
|
|
||||||
|
# Post endpoints
|
||||||
|
@blog_router.get("/posts", response=List[PostListSchema])
|
||||||
|
def list_posts(
|
||||||
|
request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
category: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
featured: Optional[bool] = None,
|
||||||
|
author: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""List published posts with filtering and pagination"""
|
||||||
|
queryset = Post.objects.filter(status=Post.StatusChoices.PUBLISHED).select_related(
|
||||||
|
'author', 'category'
|
||||||
|
).prefetch_related('tags')
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if category:
|
||||||
|
queryset = queryset.filter(category__slug=category)
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
queryset = queryset.filter(tags__slug=tag)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(title__icontains=search) |
|
||||||
|
Q(content__icontains=search) |
|
||||||
|
Q(excerpt__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
if featured is not None:
|
||||||
|
queryset = queryset.filter(is_featured=featured)
|
||||||
|
|
||||||
|
if author:
|
||||||
|
queryset = queryset.filter(author__username=author)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
posts = queryset[offset:offset + limit]
|
||||||
|
|
||||||
|
return posts
|
||||||
|
|
||||||
|
@blog_router.get("/posts/{slug}", response=PostDetailSchema)
|
||||||
|
def get_post(request, slug: str):
|
||||||
|
"""Get single post by slug"""
|
||||||
|
post = get_object_or_404(
|
||||||
|
Post.objects.select_related('author', 'category').prefetch_related('tags'),
|
||||||
|
slug=slug,
|
||||||
|
status=Post.StatusChoices.PUBLISHED
|
||||||
|
)
|
||||||
|
return post
|
||||||
|
|
||||||
|
@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def create_post(request, data: PostCreateSchema):
|
||||||
|
"""Create a new post (committee members only)"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
if not (user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "Only committee members can create posts"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = Post.objects.create(
|
||||||
|
title=data.title,
|
||||||
|
content=data.content,
|
||||||
|
excerpt=data.excerpt,
|
||||||
|
author=user,
|
||||||
|
category_id=data.category_id,
|
||||||
|
status=data.status,
|
||||||
|
is_featured=data.is_featured
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.tag_ids:
|
||||||
|
post.tags.set(data.tag_ids)
|
||||||
|
|
||||||
|
return 201, post
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to create post", "details": str(e)}
|
||||||
|
|
||||||
|
@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def update_post(request, slug: str, data: PostCreateSchema):
|
||||||
|
"""Update a post (author or committee only)"""
|
||||||
|
user = request.auth
|
||||||
|
post = get_object_or_404(Post, slug=slug)
|
||||||
|
|
||||||
|
if not (post.author == user or user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "You can only edit your own posts"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for field, value in data.dict(exclude_unset=True).items():
|
||||||
|
if field == 'tag_ids':
|
||||||
|
if value:
|
||||||
|
post.tags.set(value)
|
||||||
|
elif field == 'category_id':
|
||||||
|
post.category_id = value
|
||||||
|
else:
|
||||||
|
setattr(post, field, value)
|
||||||
|
|
||||||
|
post.save()
|
||||||
|
return 200, post
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to update post", "details": str(e)}
|
||||||
|
|
||||||
|
@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def delete_post(request, slug: str):
|
||||||
|
"""Soft delete a post owned by the requester or committee."""
|
||||||
|
user = request.auth
|
||||||
|
post = get_object_or_404(Post, slug=slug)
|
||||||
|
|
||||||
|
if not (post.author == user or user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "You can only delete your own posts"}
|
||||||
|
|
||||||
|
post.delete()
|
||||||
|
return 200, {"message": "Post deleted successfully"}
|
||||||
|
|
||||||
|
@blog_router.get("/deleted/posts", response=List[PostListSchema], auth=jwt_auth)
|
||||||
|
def list_deleted_posts(request):
|
||||||
|
"""List all soft-deleted posts (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Post.deleted_objects.all().select_related('author', 'category').prefetch_related('tags')
|
||||||
|
|
||||||
|
@blog_router.post("deleted/posts/{post_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_post(request, post_id: int):
|
||||||
|
"""Restore a soft-deleted post (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
post = Post.deleted_objects.get(id=post_id)
|
||||||
|
post.restore()
|
||||||
|
return 200, {"message": f"Post '{post.title}' restored successfully."}
|
||||||
|
except Post.DoesNotExist:
|
||||||
|
return 400, {"error": "Post not found or not soft-deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Comment endpoints
|
||||||
|
@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema])
|
||||||
|
def list_comments(request, slug: str):
|
||||||
|
"""List approved comments for a post"""
|
||||||
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
|
||||||
|
comments = Comment.objects.filter(
|
||||||
|
post=post,
|
||||||
|
is_approved=True,
|
||||||
|
parent=None
|
||||||
|
).select_related('author').prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
'replies',
|
||||||
|
queryset=Comment.objects.filter(is_approved=True).select_related('author')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return comments
|
||||||
|
|
||||||
|
@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def create_comment(request, slug: str, data: CommentCreateSchema):
|
||||||
|
"""Create a comment on a post"""
|
||||||
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
try:
|
||||||
|
comment = Comment.objects.create(
|
||||||
|
post=post,
|
||||||
|
author=user,
|
||||||
|
content=data.content,
|
||||||
|
parent_id=data.parent_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return 201, comment
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to create comment", "details": str(e)}
|
||||||
|
|
||||||
|
@blog_router.get("/deleted/comments", response=List[CommentSchema], auth=jwt_auth)
|
||||||
|
def list_deleted_comments(request):
|
||||||
|
"""List all soft-deleted comments (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Comment.deleted_objects.all().select_related('author', 'post')
|
||||||
|
|
||||||
|
@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_comment(request, comment_id: int):
|
||||||
|
"""Restore a soft-deleted comment (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
comment = Comment.deleted_objects.get(id=comment_id)
|
||||||
|
comment.restore()
|
||||||
|
return 200, {"message": f"Comment by {comment.author.username} restored successfully."}
|
||||||
|
except Comment.DoesNotExist:
|
||||||
|
return 400, {"error": "Comment not found or not soft-deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Like endpoints
|
||||||
|
@blog_router.post("/posts/{slug}/like", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def toggle_like(request, slug: str):
|
||||||
|
"""Toggle like on a post"""
|
||||||
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
like, created = Like.objects.get_or_create(post=post, user=user)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
like.delete()
|
||||||
|
return 200, {"message": "Post unliked"}
|
||||||
|
|
||||||
|
return 200, {"message": "Post liked"}
|
||||||
|
|
||||||
|
@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema})
|
||||||
|
def get_likes_count(request, slug: str):
|
||||||
|
"""Get likes count for a post"""
|
||||||
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
count = post.likes.count()
|
||||||
|
return {"message": f"{count}"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Category endpoints
|
||||||
|
@blog_router.get("/categories", response=List[CategorySchema])
|
||||||
|
def list_categories(request):
|
||||||
|
"""List all categories"""
|
||||||
|
return Category.objects.all()
|
||||||
|
|
||||||
|
@blog_router.get("/categories/{slug}", response=CategorySchema)
|
||||||
|
def get_category(request, slug: str):
|
||||||
|
"""Get single category by slug"""
|
||||||
|
return get_object_or_404(Category, slug=slug)
|
||||||
|
|
||||||
|
@blog_router.get("/deleted/categories", response=List[CategorySchema], auth=jwt_auth)
|
||||||
|
def list_deleted_categories(request):
|
||||||
|
"""List all soft-deleted categories (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Category.deleted_objects.all()
|
||||||
|
|
||||||
|
@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_category(request, category_id: int):
|
||||||
|
"""Restore a soft-deleted category (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
category = Category.deleted_objects.get(id=category_id)
|
||||||
|
category.restore()
|
||||||
|
return 200, {"message": f"Category '{category.name}' restored successfully."}
|
||||||
|
except Category.DoesNotExist:
|
||||||
|
return 400, {"error": "Category not found or not soft-deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Tag endpoints
|
||||||
|
@blog_router.get("/tags", response=List[TagSchema])
|
||||||
|
def list_tags(request):
|
||||||
|
"""List all tags"""
|
||||||
|
return Tag.objects.all()
|
||||||
|
|
||||||
|
@blog_router.get("/tags/{slug}", response=TagSchema)
|
||||||
|
def get_tag(request, slug: str):
|
||||||
|
"""Get single tag by slug"""
|
||||||
|
return get_object_or_404(Tag, slug=slug)
|
||||||
|
|
||||||
|
@blog_router.get("/deleted/tags", response=List[TagSchema], auth=jwt_auth)
|
||||||
|
def list_deleted_tags(request):
|
||||||
|
"""List all soft-deleted tags (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Tag.all_objects.all()
|
||||||
|
|
||||||
|
@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_tag(request, tag_id: int):
|
||||||
|
"""Restore a soft-deleted tag (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
tag = Tag.deleted_objects.get(id=tag_id)
|
||||||
|
tag.restore()
|
||||||
|
return 200, {"message": f"Tag '{tag.name}' restored successfully."}
|
||||||
|
except Tag.DoesNotExist:
|
||||||
|
return 400, {"error": "Tag not found or not soft-deleted."}
|
||||||
6
apps/blog/apps.py
Normal file
6
apps/blog/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BlogConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.blog"
|
||||||
672
apps/blog/fixtures/blog.json
Normal file
672
apps/blog/fixtures/blog.json
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"name": "هوش مصنوعی",
|
||||||
|
"slug": "artificial-intelligence",
|
||||||
|
"description": "مقالات مربوط به هوش مصنوعی و یادگیری ماشین",
|
||||||
|
"created_at": "2024-01-01T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"name": "برنامهنویسی وب",
|
||||||
|
"slug": "web-programming",
|
||||||
|
"description": "آموزشها و مقالات مربوط به توسعه وب",
|
||||||
|
"created_at": "2024-01-02T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-02T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"name": "امنیت سایبری",
|
||||||
|
"slug": "cybersecurity",
|
||||||
|
"description": "مطالب مربوط به امنیت اطلاعات و سایبری",
|
||||||
|
"created_at": "2024-01-03T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-03T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"name": "علم داده",
|
||||||
|
"slug": "data-science",
|
||||||
|
"description": "مقالات مربوط به تحلیل داده و علم داده",
|
||||||
|
"created_at": "2024-01-04T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-04T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"name": "اپلیکیشن موبایل",
|
||||||
|
"slug": "mobile-app",
|
||||||
|
"description": "توسعه اپلیکیشنهای موبایل",
|
||||||
|
"created_at": "2024-01-05T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-05T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"name": "شبکه کامپیوتری",
|
||||||
|
"slug": "computer-networks",
|
||||||
|
"description": "مطالب مربوط به شبکههای کامپیوتری",
|
||||||
|
"created_at": "2024-01-06T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-06T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"name": "بازیسازی",
|
||||||
|
"slug": "game-development",
|
||||||
|
"description": "آموزش و مقالات مربوط به توسعه بازی",
|
||||||
|
"created_at": "2024-01-07T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-07T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"name": "طراحی UI/UX",
|
||||||
|
"slug": "ui-ux-design",
|
||||||
|
"description": "طراحی رابط کاربری و تجربه کاربری",
|
||||||
|
"created_at": "2024-01-08T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-08T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"name": "اخبار انجمن",
|
||||||
|
"slug": "association-news",
|
||||||
|
"description": "اخبار و اطلاعیههای انجمن علمی",
|
||||||
|
"created_at": "2024-01-09T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-09T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.category",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"name": "مسابقات برنامهنویسی",
|
||||||
|
"slug": "programming-contests",
|
||||||
|
"description": "اطلاعات مربوط به مسابقات برنامهنویسی",
|
||||||
|
"created_at": "2024-01-10T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-10T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"name": "پایتون",
|
||||||
|
"slug": "python",
|
||||||
|
"created_at": "2024-01-01T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-01T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"name": "جاوااسکریپت",
|
||||||
|
"slug": "javascript",
|
||||||
|
"created_at": "2024-01-02T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-02T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"name": "ریاکت",
|
||||||
|
"slug": "react",
|
||||||
|
"created_at": "2024-01-03T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-03T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"name": "جنگو",
|
||||||
|
"slug": "django",
|
||||||
|
"created_at": "2024-01-04T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-04T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"name": "یادگیری عمیق",
|
||||||
|
"slug": "deep-learning",
|
||||||
|
"created_at": "2024-01-05T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-05T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"name": "تنسورفلو",
|
||||||
|
"slug": "tensorflow",
|
||||||
|
"created_at": "2024-01-06T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-06T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"name": "کیبرنتیز",
|
||||||
|
"slug": "kubernetes",
|
||||||
|
"created_at": "2024-01-07T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-07T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"name": "داکر",
|
||||||
|
"slug": "docker",
|
||||||
|
"created_at": "2024-01-08T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-08T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"name": "گیت",
|
||||||
|
"slug": "git",
|
||||||
|
"created_at": "2024-01-09T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-09T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"name": "لینوکس",
|
||||||
|
"slug": "linux",
|
||||||
|
"created_at": "2024-01-10T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-10T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 11,
|
||||||
|
"fields": {
|
||||||
|
"name": "الگوریتم",
|
||||||
|
"slug": "algorithm",
|
||||||
|
"created_at": "2024-01-11T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-11T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.tag",
|
||||||
|
"pk": 12,
|
||||||
|
"fields": {
|
||||||
|
"name": "ساختمان داده",
|
||||||
|
"slug": "data-structure",
|
||||||
|
"created_at": "2024-01-12T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-12T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"title": "مقدمهای بر یادگیری ماشین با پایتون",
|
||||||
|
"slug": "introduction-to-machine-learning-with-python",
|
||||||
|
"content": "# مقدمهای بر یادگیری ماشین با پایتون\n\nیادگیری ماشین یکی از مهمترین شاخههای هوش مصنوعی است که امروزه کاربردهای فراوانی در صنایع مختلف دارد.\n\n## کتابخانههای مهم\n\n- **Scikit-learn**: برای الگوریتمهای کلاسیک\n- **TensorFlow**: برای یادگیری عمیق\n- **Pandas**: برای پردازش داده\n- **NumPy**: برای محاسبات عددی\n\n## مثال ساده\n\n```python\nfrom sklearn.linear_model import LinearRegression\nimport numpy as np\n\n# دادههای نمونه\nX = np.array([[1], [2], [3], [4]])\ny = np.array([2, 4, 6, 8])\n\n# ایجاد مدل\nmodel = LinearRegression()\nmodel.fit(X, y)\n\n# پیشبینی\nprint(model.predict([[5]]))\n```\n\nاین مثال ساده نشان میدهد که چگونه میتوان با استفاده از کتابخانه Scikit-learn یک مدل رگرسیون خطی ایجاد کرد.",
|
||||||
|
"excerpt": "آموزش مقدماتی یادگیری ماشین با استفاده از زبان پایتون و کتابخانههای محبوب",
|
||||||
|
"author": 1,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-01-15T10:00:00Z",
|
||||||
|
"category": 1,
|
||||||
|
"is_featured": true,
|
||||||
|
"created_at": "2024-01-15T09:00:00Z",
|
||||||
|
"updated_at": "2024-01-15T09:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"title": "ساخت API با Django REST Framework",
|
||||||
|
"slug": "building-api-with-django-rest-framework",
|
||||||
|
"content": "# ساخت API با Django REST Framework\n\nDjango REST Framework یکی از قدرتمندترین ابزارها برای ساخت API در پایتون است.\n\n## نصب و راهاندازی\n\n```bash\npip install djangorestframework\n```\n\n## ایجاد Serializer\n\n```python\nfrom rest_framework import serializers\nfrom .models import Post\n\nclass PostSerializer(serializers.ModelSerializer):\n class Meta:\n model = Post\n fields = '__all__'\n```\n\n## ایجاد ViewSet\n\n```python\nfrom rest_framework import viewsets\nfrom .models import Post\nfrom .serializers import PostSerializer\n\nclass PostViewSet(viewsets.ModelViewSet):\n queryset = Post.objects.all()\n serializer_class = PostSerializer\n```\n\nبا این روش میتوانید به راحتی API های قدرتمند و قابل اعتماد بسازید.",
|
||||||
|
"excerpt": "آموزش گام به گام ساخت API با استفاده از Django REST Framework",
|
||||||
|
"author": 2,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-01-20T14:30:00Z",
|
||||||
|
"category": 2,
|
||||||
|
"is_featured": false,
|
||||||
|
"created_at": "2024-01-20T13:30:00Z",
|
||||||
|
"updated_at": "2024-01-20T13:30:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"title": "امنیت در اپلیکیشنهای وب",
|
||||||
|
"slug": "web-application-security",
|
||||||
|
"content": "# امنیت در اپلیکیشنهای وب\n\nامنیت یکی از مهمترین جنبههای توسعه اپلیکیشنهای وب است.\n\n## تهدیدات رایج\n\n- **SQL Injection**: تزریق کد SQL مخرب\n- **XSS**: اجرای اسکریپت مخرب در مرورگر\n- **CSRF**: درخواست جعلی بین سایتی\n- **Authentication Bypass**: دور زدن احراز هویت\n\n## راههای محافظت\n\n```python\n# استفاده از ORM برای جلوگیری از SQL Injection\nUser.objects.filter(username=username)\n\n# Escape کردن خروجی HTML\nfrom django.utils.html import escape\nsafe_content = escape(user_input)\n\n# استفاده از CSRF Token\n{% csrf_token %}\n```\n\nهمیشه امنیت را در اولویت قرار دهید.",
|
||||||
|
"excerpt": "بررسی تهدیدات امنیتی رایج در اپلیکیشنهای وب و راههای مقابله با آنها",
|
||||||
|
"author": 3,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-01-25T16:00:00Z",
|
||||||
|
"category": 3,
|
||||||
|
"is_featured": true,
|
||||||
|
"created_at": "2024-01-25T15:00:00Z",
|
||||||
|
"updated_at": "2024-01-25T15:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"title": "تحلیل داده با Pandas",
|
||||||
|
"slug": "data-analysis-with-pandas",
|
||||||
|
"content": "# تحلیل داده با Pandas\n\nPandas یکی از قدرتمندترین کتابخانههای پایتون برای تحلیل داده است.\n\n## خواندن داده\n\n```python\nimport pandas as pd\n\n# خواندن از CSV\ndf = pd.read_csv('data.csv')\n\n# خواندن از Excel\ndf = pd.read_excel('data.xlsx')\n\n# خواندن از JSON\ndf = pd.read_json('data.json')\n```\n\n## عملیات پایه\n\n```python\n# نمایش اطلاعات کلی\nprint(df.info())\nprint(df.describe())\n\n# فیلتر کردن\nfiltered_df = df[df['age'] > 25]\n\n# گروهبندی\ngrouped = df.groupby('category').mean()\n```\n\nPandas ابزاری قدرتمند برای تحلیل داده است.",
|
||||||
|
"excerpt": "آموزش کار با کتابخانه Pandas برای تحلیل و پردازش داده در پایتون",
|
||||||
|
"author": 4,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-02-01T11:00:00Z",
|
||||||
|
"category": 4,
|
||||||
|
"is_featured": false,
|
||||||
|
"created_at": "2024-02-01T10:00:00Z",
|
||||||
|
"updated_at": "2024-02-01T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"title": "توسعه اپلیکیشن موبایل با React Native",
|
||||||
|
"slug": "mobile-app-development-with-react-native",
|
||||||
|
"content": "# توسعه اپلیکیشن موبایل با React Native\n\nReact Native امکان توسعه اپلیکیشنهای موبایل کراس پلتفرم را فراهم میکند.\n\n## مزایا\n\n- **کراس پلتفرم**: یک کد برای iOS و Android\n- **Performance**: عملکرد نزدیک به Native\n- **Hot Reload**: تغییرات فوری\n- **Community**: جامعه بزرگ و فعال\n\n## شروع پروژه\n\n```bash\nnpx react-native init MyApp\ncd MyApp\nnpx react-native run-android\n```\n\n## کامپوننت ساده\n\n```jsx\nimport React from 'react';\nimport { View, Text, StyleSheet } from 'react-native';\n\nconst App = () => {\n return (\n <View style={styles.container}>\n <Text style={styles.title}>سلام دنیا!</Text>\n </View>\n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n justifyContent: 'center',\n alignItems: 'center',\n },\n title: {\n fontSize: 24,\n fontWeight: 'bold',\n },\n});\n\nexport default App;\n```",
|
||||||
|
"excerpt": "راهنمای شروع توسعه اپلیکیشن موبایل با React Native",
|
||||||
|
"author": 5,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-02-05T13:30:00Z",
|
||||||
|
"category": 5,
|
||||||
|
"is_featured": false,
|
||||||
|
"created_at": "2024-02-05T12:30:00Z",
|
||||||
|
"updated_at": "2024-02-05T12:30:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"title": "مبانی شبکههای کامپیوتری",
|
||||||
|
"slug": "computer-networks-fundamentals",
|
||||||
|
"content": "# مبانی شبکههای کامپیوتری\n\nشبکههای کامپیوتری پایه و اساس ارتباطات مدرن هستند.\n\n## مدل OSI\n\n1. **Physical Layer**: لایه فیزیکی\n2. **Data Link Layer**: لایه پیوند داده\n3. **Network Layer**: لایه شبکه\n4. **Transport Layer**: لایه انتقال\n5. **Session Layer**: لایه جلسه\n6. **Presentation Layer**: لایه ارائه\n7. **Application Layer**: لایه کاربرد\n\n## پروتکلهای مهم\n\n- **TCP/IP**: پروتکل اصلی اینترنت\n- **HTTP/HTTPS**: انتقال صفحات وب\n- **FTP**: انتقال فایل\n- **SMTP**: ارسال ایمیل\n- **DNS**: تبدیل نام دامنه\n\n## مثال ساده با Python\n\n```python\nimport socket\n\n# ایجاد سوکت\ns = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n\n# اتصال به سرور\ns.connect(('google.com', 80))\n\n# ارسال درخواست HTTP\nrequest = \"GET / HTTP/1.1\\r\\nHost: google.com\\r\\n\\r\\n\"\ns.send(request.encode())\n\n# دریافت پاسخ\nresponse = s.recv(1024)\nprint(response.decode())\n\ns.close()\n```",
|
||||||
|
"excerpt": "آشنایی با مفاهیم پایه شبکههای کامپیوتری و پروتکلهای مهم",
|
||||||
|
"author": 6,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-02-10T15:00:00Z",
|
||||||
|
"category": 6,
|
||||||
|
"is_featured": false,
|
||||||
|
"created_at": "2024-02-10T14:00:00Z",
|
||||||
|
"updated_at": "2024-02-10T14:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"title": "ساخت بازی با Unity",
|
||||||
|
"slug": "game-development-with-unity",
|
||||||
|
"content": "# ساخت بازی با Unity\n\nUnity یکی از محبوبترین موتورهای بازیسازی است.\n\n## ویژگیهای Unity\n\n- **کراس پلتفرم**: انتشار در پلتفرمهای مختلف\n- **Visual Scripting**: برنامهنویسی بصری\n- **Asset Store**: فروشگاه منابع\n- **Community**: جامعه بزرگ\n\n## اسکریپت ساده C#\n\n```csharp\nusing UnityEngine;\n\npublic class PlayerController : MonoBehaviour\n{\n public float speed = 5.0f;\n \n void Update()\n {\n float horizontal = Input.GetAxis(\"Horizontal\");\n float vertical = Input.GetAxis(\"Vertical\");\n \n Vector3 movement = new Vector3(horizontal, 0, vertical);\n transform.Translate(movement * speed * Time.deltaTime);\n }\n}\n```\n\n## مراحل ساخت بازی\n\n1. **طراحی**: ایده و مفهوم بازی\n2. **Prototyping**: نمونه اولیه\n3. **Development**: توسعه اصلی\n4. **Testing**: تست و رفع باگ\n5. **Publishing**: انتشار بازی\n\nUnity ابزاری قدرتمند برای ساخت بازی است.",
|
||||||
|
"excerpt": "راهنمای شروع بازیسازی با موتور Unity",
|
||||||
|
"author": 7,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-02-15T12:00:00Z",
|
||||||
|
"category": 7,
|
||||||
|
"is_featured": true,
|
||||||
|
"created_at": "2024-02-15T11:00:00Z",
|
||||||
|
"updated_at": "2024-02-15T11:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"title": "اصول طراحی UI/UX",
|
||||||
|
"slug": "ui-ux-design-principles",
|
||||||
|
"content": "# اصول طراحی UI/UX\n\nطراحی رابط کاربری و تجربه کاربری نقش مهمی در موفقیت محصولات دیجیتال دارد.\n\n## اصول UI\n\n- **Consistency**: یکنواختی در طراحی\n- **Hierarchy**: سلسله مراتب بصری\n- **Contrast**: تضاد مناسب\n- **Alignment**: تراز بندی صحیح\n- **Proximity**: قرارگیری عناصر مرتبط\n\n## اصول UX\n\n- **Usability**: قابلیت استفاده\n- **Accessibility**: دسترسیپذیری\n- **User-Centered**: محوریت کاربر\n- **Feedback**: بازخورد مناسب\n- **Error Prevention**: جلوگیری از خطا\n\n## ابزارهای طراحی\n\n- **Figma**: طراحی رابط کاربری\n- **Adobe XD**: پروتوتایپ سازی\n- **Sketch**: طراحی برای Mac\n- **InVision**: همکاری تیمی\n\n## فرآیند طراحی\n\n1. **Research**: تحقیق و بررسی\n2. **Wireframing**: طراحی اسکلت\n3. **Prototyping**: نمونهسازی\n4. **Testing**: تست با کاربران\n5. **Iteration**: بهبود مداوم",
|
||||||
|
"excerpt": "آشنایی با اصول و مبانی طراحی رابط کاربری و تجربه کاربری",
|
||||||
|
"author": 8,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-02-20T14:30:00Z",
|
||||||
|
"category": 8,
|
||||||
|
"is_featured": false,
|
||||||
|
"created_at": "2024-02-20T13:30:00Z",
|
||||||
|
"updated_at": "2024-02-20T13:30:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"title": "اطلاعیه برگزاری مسابقه برنامهنویسی",
|
||||||
|
"slug": "programming-contest-announcement",
|
||||||
|
"content": "# اطلاعیه برگزاری مسابقه برنامهنویسی\n\nانجمن علمی مهندسی کامپیوتر دانشگاه برگزاری مسابقه برنامهنویسی بهاری را اعلام میکند.\n\n## جزئیات مسابقه\n\n- **تاریخ**: ۲۲ مارس ۲۰۲۴\n- **زمان**: ۹ صبح تا ۱۲ ظهر\n- **مکان**: آزمایشگاه کامپیوتر شماره ۱\n- **مدت زمان**: ۳ ساعت\n- **تعداد مسائل**: ۸ مسئله\n\n## جوایز\n\n- **نفر اول**: ۵ میلیون تومان\n- **نفر دوم**: ۳ میلیون تومان\n- **نفر سوم**: ۲ میلیون تومان\n\n## قوانین\n\n- مسابقه به صورت انفرادی برگزار میشود\n- زبانهای مجاز: C++, Java, Python\n- استفاده از اینترنت ممنوع است\n- ثبت نام تا ۲۰ مارس ادامه دارد\n\n## ثبت نام\n\nبرای ثبت نام به دفتر انجمن مراجعه کنید یا از طریق وبسایت اقدام نمایید.\n\nمنتظر حضور گرم شما هستیم!",
|
||||||
|
"excerpt": "اطلاعیه برگزاری مسابقه برنامهنویسی بهاری انجمن علمی",
|
||||||
|
"author": 1,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-02-25T10:00:00Z",
|
||||||
|
"category": 9,
|
||||||
|
"is_featured": true,
|
||||||
|
"created_at": "2024-02-25T09:00:00Z",
|
||||||
|
"updated_at": "2024-02-25T09:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.post",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"title": "نتایج مسابقه ACM ICPC منطقهای",
|
||||||
|
"slug": "acm-icpc-regional-results",
|
||||||
|
"content": "# نتایج مسابقه ACM ICPC منطقهای\n\nتیمهای دانشگاه ما در مسابقه ACM ICPC منطقهای عملکرد درخشانی داشتند.\n\n## نتایج تیمها\n\n### تیم Alpha\n- **اعضا**: علی احمدی، سارا محمدی، رضا کریمی\n- **رتبه**: ۵ منطقهای\n- **مسائل حل شده**: ۷ از ۱۲\n\n### تیم Beta\n- **اعضا**: مریم حسینی، حسن زارع، زهرا صفری\n- **رتبه**: ۱۲ منطقهای\n- **مسائل حل شده**: ۵ از ۱۲\n\n### تیم Gamma\n- **اعضا**: محمد رحمانی، فاطمه مرادی، امیر قربانی\n- **رتبه**: ۱۸ منطقهای\n- **مسائل حل شده**: ۴ از ۱۲\n\n## تبریک و تشکر\n\nاز تمامی شرکتکنندگان تشکر میکنیم و امیدواریم سال آینده نتایج بهتری کسب کنیم.\n\n## آمادهسازی برای سال آینده\n\nبرای آمادهسازی تیمهای سال آینده، کارگاههای تمرینی برگزار خواهد شد.",
|
||||||
|
"excerpt": "گزارش عملکرد تیمهای دانشگاه در مسابقه ACM ICPC منطقهای",
|
||||||
|
"author": 2,
|
||||||
|
"status": "published",
|
||||||
|
"published_at": "2024-03-01T16:00:00Z",
|
||||||
|
"category": 10,
|
||||||
|
"is_featured": false,
|
||||||
|
"created_at": "2024-03-01T15:00:00Z",
|
||||||
|
"updated_at": "2024-03-01T15:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"post": 1,
|
||||||
|
"author": 3,
|
||||||
|
"content": "مقاله بسیار مفیدی بود. ممنون از نویسنده",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-01-16T10:00:00Z",
|
||||||
|
"updated_at": "2024-01-16T10:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"post": 1,
|
||||||
|
"author": 4,
|
||||||
|
"content": "آیا میتوانید مثالهای بیشتری ارائه دهید؟",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-01-17T11:00:00Z",
|
||||||
|
"updated_at": "2024-01-17T11:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"post": 2,
|
||||||
|
"author": 5,
|
||||||
|
"content": "Django REST Framework واقعاً قدرتمند است",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-01-21T09:00:00Z",
|
||||||
|
"updated_at": "2024-01-21T09:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"post": 3,
|
||||||
|
"author": 6,
|
||||||
|
"content": "امنیت واقعاً مهم است. مقاله خوبی بود",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-01-26T12:00:00Z",
|
||||||
|
"updated_at": "2024-01-26T12:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"post": 4,
|
||||||
|
"author": 7,
|
||||||
|
"content": "Pandas برای تحلیل داده عالی است",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-02-02T14:00:00Z",
|
||||||
|
"updated_at": "2024-02-02T14:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"post": 5,
|
||||||
|
"author": 8,
|
||||||
|
"content": "React Native گزینه خوبی برای موبایل است",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-02-06T15:00:00Z",
|
||||||
|
"updated_at": "2024-02-06T15:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"post": 6,
|
||||||
|
"author": 9,
|
||||||
|
"content": "شبکه پایه همه چیز است",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-02-11T16:00:00Z",
|
||||||
|
"updated_at": "2024-02-11T16:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"post": 7,
|
||||||
|
"author": 10,
|
||||||
|
"content": "Unity برای شروع بازیسازی عالی است",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-02-16T13:00:00Z",
|
||||||
|
"updated_at": "2024-02-16T13:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"post": 8,
|
||||||
|
"author": 11,
|
||||||
|
"content": "طراحی UI/UX خیلی مهم است",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-02-21T17:00:00Z",
|
||||||
|
"updated_at": "2024-02-21T17:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.comment",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"post": 9,
|
||||||
|
"author": 12,
|
||||||
|
"content": "حتماً در مسابقه شرکت میکنم",
|
||||||
|
"is_approved": true,
|
||||||
|
"created_at": "2024-02-26T11:00:00Z",
|
||||||
|
"updated_at": "2024-02-26T11:00:00Z",
|
||||||
|
"is_deleted": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"post": 1,
|
||||||
|
"user": 3,
|
||||||
|
"created_at": "2024-01-16T10:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"post": 1,
|
||||||
|
"user": 4,
|
||||||
|
"created_at": "2024-01-17T11:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"post": 1,
|
||||||
|
"user": 5,
|
||||||
|
"created_at": "2024-01-18T12:00:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"post": 2,
|
||||||
|
"user": 6,
|
||||||
|
"created_at": "2024-01-21T09:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"post": 2,
|
||||||
|
"user": 7,
|
||||||
|
"created_at": "2024-01-22T10:00:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"post": 3,
|
||||||
|
"user": 8,
|
||||||
|
"created_at": "2024-01-26T12:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"post": 3,
|
||||||
|
"user": 9,
|
||||||
|
"created_at": "2024-01-27T13:00:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"post": 4,
|
||||||
|
"user": 10,
|
||||||
|
"created_at": "2024-02-02T14:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"post": 5,
|
||||||
|
"user": 11,
|
||||||
|
"created_at": "2024-02-06T15:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"post": 6,
|
||||||
|
"user": 12,
|
||||||
|
"created_at": "2024-02-11T16:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 11,
|
||||||
|
"fields": {
|
||||||
|
"post": 7,
|
||||||
|
"user": 1,
|
||||||
|
"created_at": "2024-02-16T13:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "blog.like",
|
||||||
|
"pk": 12,
|
||||||
|
"fields": {
|
||||||
|
"post": 8,
|
||||||
|
"user": 2,
|
||||||
|
"created_at": "2024-02-21T17:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
89
apps/blog/migrations/0001_initial.py
Normal file
89
apps/blog/migrations/0001_initial.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Category',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=100, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Categories',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Comment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('content', models.TextField()),
|
||||||
|
('is_approved', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Like',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Post',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=200, unique=True)),
|
||||||
|
('content', models.TextField(help_text='Content in Markdown format')),
|
||||||
|
('excerpt', models.TextField(blank=True, max_length=300)),
|
||||||
|
('featured_image', models.ImageField(blank=True, null=True, upload_to='blog/featured/')),
|
||||||
|
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=10)),
|
||||||
|
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('is_featured', models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Tag',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('name', models.CharField(max_length=50, unique=True)),
|
||||||
|
('slug', models.SlugField(blank=True, unique=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
78
apps/blog/migrations/0002_initial.py
Normal file
78
apps/blog/migrations/0002_initial.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('blog', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='comment',
|
||||||
|
name='author',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='comment',
|
||||||
|
name='parent',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='blog.comment'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='like',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='post',
|
||||||
|
name='author',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='post',
|
||||||
|
name='category',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to='blog.category'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='like',
|
||||||
|
name='post',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='blog.post'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='comment',
|
||||||
|
name='post',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.post'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='post',
|
||||||
|
name='tags',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='posts', to='blog.tag'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='like',
|
||||||
|
index=models.Index(fields=['post'], name='blog_like_post_id_c95f0b_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='like',
|
||||||
|
unique_together={('post', 'user')},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='comment',
|
||||||
|
index=models.Index(fields=['post', 'is_approved'], name='blog_commen_post_id_7710b1_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='post',
|
||||||
|
index=models.Index(fields=['status', 'published_at'], name='blog_post_status_5b2843_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='post',
|
||||||
|
index=models.Index(fields=['is_featured'], name='blog_post_is_feat_837e2e_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/blog/migrations/__init__.py
Normal file
0
apps/blog/migrations/__init__.py
Normal file
137
apps/blog/models.py
Normal file
137
apps/blog/models.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
from core.models import BaseModel
|
||||||
|
|
||||||
|
class Category(BaseModel):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(max_length=100, unique=True, blank=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "Categories"
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
class Tag(BaseModel):
|
||||||
|
name = models.CharField(max_length=50, unique=True)
|
||||||
|
slug = models.SlugField(max_length=50, unique=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
class Post(BaseModel):
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
DRAFT = 'draft', 'Draft'
|
||||||
|
PUBLISHED = 'published', 'Published'
|
||||||
|
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
slug = models.SlugField(max_length=200, unique=True, blank=True)
|
||||||
|
content = models.TextField(help_text="Content in Markdown format")
|
||||||
|
excerpt = models.TextField(max_length=300, blank=True)
|
||||||
|
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts')
|
||||||
|
featured_image = models.ImageField(upload_to='blog/featured/', null=True, blank=True)
|
||||||
|
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
||||||
|
published_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='posts')
|
||||||
|
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
|
||||||
|
is_featured = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', 'published_at']),
|
||||||
|
models.Index(fields=['is_featured']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.title)
|
||||||
|
|
||||||
|
# Auto-generate excerpt if not provided
|
||||||
|
if not self.excerpt and self.content:
|
||||||
|
# Convert markdown to plain text for excerpt
|
||||||
|
plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra'])
|
||||||
|
# Remove HTML tags and truncate
|
||||||
|
import re
|
||||||
|
plain_text = re.sub('<[^<]+?>', '', plain_text)
|
||||||
|
self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text
|
||||||
|
|
||||||
|
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
|
||||||
|
self.published_at = timezone.now()
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_html(self):
|
||||||
|
"""Convert markdown content to HTML"""
|
||||||
|
return markdown.markdown(
|
||||||
|
self.content,
|
||||||
|
extensions=[
|
||||||
|
'markdown.extensions.extra',
|
||||||
|
'markdown.extensions.codehilite',
|
||||||
|
'markdown.extensions.toc',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reading_time(self):
|
||||||
|
"""Estimate reading time in minutes assuming 200 words per minute."""
|
||||||
|
word_count = len(self.content.split())
|
||||||
|
return max(1, word_count // 200)
|
||||||
|
|
||||||
|
class Comment(BaseModel):
|
||||||
|
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
|
||||||
|
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments')
|
||||||
|
content = models.TextField()
|
||||||
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies')
|
||||||
|
is_approved = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['post', 'is_approved']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Comment by {self.author.username} on {self.post.title}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_reply(self):
|
||||||
|
return self.parent is not None
|
||||||
|
|
||||||
|
class Like(models.Model):
|
||||||
|
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='likes')
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='likes')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['post', 'user']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['post']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user.username} likes {self.post.title}'
|
||||||
32
apps/blog/resources.py
Normal file
32
apps/blog/resources.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from import_export import resources, fields
|
||||||
|
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||||
|
|
||||||
|
from apps.users.models import User
|
||||||
|
from apps.blog.models import Post, Category, Tag
|
||||||
|
|
||||||
|
class CategoryResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = Category
|
||||||
|
fields = ('id', 'name', 'slug', 'description', 'created_at')
|
||||||
|
|
||||||
|
class PostResource(resources.ModelResource):
|
||||||
|
author = fields.Field(
|
||||||
|
column_name='author',
|
||||||
|
attribute='author',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
category = fields.Field(
|
||||||
|
column_name='category',
|
||||||
|
attribute='category',
|
||||||
|
widget=ForeignKeyWidget(Category, 'name')
|
||||||
|
)
|
||||||
|
tags = fields.Field(
|
||||||
|
column_name='tags',
|
||||||
|
attribute='tags',
|
||||||
|
widget=ManyToManyWidget(Tag, field='name', separator='|')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Post
|
||||||
|
fields = ('id', 'title', 'slug', 'content', 'excerpt', 'author',
|
||||||
|
'category', 'tags', 'status', 'is_featured', 'published_at', 'created_at')
|
||||||
1
apps/certificates/__init__.py
Normal file
1
apps/certificates/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
""""""
|
||||||
24
apps/certificates/admin.py
Normal file
24
apps/certificates/admin.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import CertificateTemplate, Skill, UserCertificate
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Skill)
|
||||||
|
class SkillAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'created_at')
|
||||||
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CertificateTemplate)
|
||||||
|
class CertificateTemplateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('event', 'created_at')
|
||||||
|
search_fields = ('event__title',)
|
||||||
|
filter_horizontal = ('skills',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserCertificate)
|
||||||
|
class UserCertificateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'event', 'title', 'score', 'issued_at')
|
||||||
|
list_filter = ('score', 'issued_at')
|
||||||
|
search_fields = ('user__username', 'title', 'event__title')
|
||||||
|
filter_horizontal = ('skills',)
|
||||||
0
apps/certificates/api/__init__.py
Normal file
0
apps/certificates/api/__init__.py
Normal file
70
apps/certificates/api/schemas.py
Normal file
70
apps/certificates/api/schemas.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""API payloads for certificate operations."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class SkillSchema(Schema):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateTemplateOut(Schema):
|
||||||
|
id: int
|
||||||
|
event_id: int
|
||||||
|
event_title: str
|
||||||
|
image_url: Optional[str]
|
||||||
|
skill_ids: List[int]
|
||||||
|
skills: List[SkillSchema]
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateGenerationItem(Schema):
|
||||||
|
user_id: int
|
||||||
|
score: int
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
skill_ids: Optional[List[int]] = None
|
||||||
|
issued_at: Optional[datetime] = None
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateGenerationPayload(Schema):
|
||||||
|
entries: List[CertificateGenerationItem]
|
||||||
|
default_title: Optional[str] = None
|
||||||
|
default_description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserCertificateOut(Schema):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
user_name: str
|
||||||
|
event_id: int
|
||||||
|
title: str
|
||||||
|
certificate_id: str
|
||||||
|
certificate_code: str
|
||||||
|
score: int
|
||||||
|
score_label: str
|
||||||
|
image_url: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateGenerationResponse(Schema):
|
||||||
|
certificates: List[UserCertificateOut]
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateVerificationOut(Schema):
|
||||||
|
certificate_id: str
|
||||||
|
certificate_code: str
|
||||||
|
user_id: int
|
||||||
|
user_name: str
|
||||||
|
event_id: int
|
||||||
|
event_title: str
|
||||||
|
title: str
|
||||||
|
score: int
|
||||||
|
score_label: str
|
||||||
|
issued_at: datetime
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
skills: List[str]
|
||||||
138
apps/certificates/api/views.py
Normal file
138
apps/certificates/api/views.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from core.authentication import jwt_auth
|
||||||
|
from apps.certificates.api.schemas import (
|
||||||
|
CertificateTemplateOut,
|
||||||
|
CertificateGenerationPayload,
|
||||||
|
CertificateGenerationResponse,
|
||||||
|
CertificateVerificationOut,
|
||||||
|
SkillSchema,
|
||||||
|
UserCertificateOut,
|
||||||
|
)
|
||||||
|
from apps.certificates.models import CertificateTemplate, UserCertificate
|
||||||
|
|
||||||
|
|
||||||
|
certificates_router = Router(tags=["Certificates"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_staff(user):
|
||||||
|
if not user or not user.is_staff:
|
||||||
|
raise HttpError(403, "Only staff users can access certificate management.")
|
||||||
|
|
||||||
|
|
||||||
|
@certificates_router.get(
|
||||||
|
"templates/{int:event_id}",
|
||||||
|
response=CertificateTemplateOut,
|
||||||
|
auth=jwt_auth,
|
||||||
|
)
|
||||||
|
def get_template(request, event_id: int):
|
||||||
|
_ensure_staff(request.auth)
|
||||||
|
template = get_object_or_404(
|
||||||
|
CertificateTemplate.objects.select_related('event').prefetch_related('skills'),
|
||||||
|
event_id=event_id,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
skills = [
|
||||||
|
SkillSchema(
|
||||||
|
id=skill.id,
|
||||||
|
name=skill.name,
|
||||||
|
description=skill.description,
|
||||||
|
)
|
||||||
|
for skill in template.skills.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
image_url = None
|
||||||
|
if template.image and hasattr(template.image, 'url'):
|
||||||
|
image_url = request.build_absolute_uri(template.image.url)
|
||||||
|
|
||||||
|
return CertificateTemplateOut(
|
||||||
|
id=template.id,
|
||||||
|
event_id=template.event_id,
|
||||||
|
event_title=template.event.title,
|
||||||
|
image_url=image_url,
|
||||||
|
skill_ids=list(template.skills.values_list('id', flat=True)),
|
||||||
|
skills=skills,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@certificates_router.post(
|
||||||
|
"templates/{int:event_id}/generate",
|
||||||
|
response=CertificateGenerationResponse,
|
||||||
|
auth=jwt_auth,
|
||||||
|
)
|
||||||
|
def generate_certificates(request, event_id: int, payload: CertificateGenerationPayload):
|
||||||
|
_ensure_staff(request.auth)
|
||||||
|
template = get_object_or_404(
|
||||||
|
CertificateTemplate.objects.select_related('event').prefetch_related('skills'),
|
||||||
|
event_id=event_id,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = [entry.model_dump() for entry in payload.entries]
|
||||||
|
certificates = template.generate_certificates(
|
||||||
|
entries,
|
||||||
|
default_title=payload.default_title,
|
||||||
|
default_description=payload.default_description,
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HttpError(400, str(exc))
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for certificate in certificates:
|
||||||
|
image_url = None
|
||||||
|
if certificate.image and hasattr(certificate.image, 'url'):
|
||||||
|
image_url = request.build_absolute_uri(certificate.image.url)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
UserCertificateOut(
|
||||||
|
id=certificate.id,
|
||||||
|
user_id=certificate.user_id,
|
||||||
|
user_name=certificate.user.get_full_name() or certificate.user.email,
|
||||||
|
event_id=certificate.event_id,
|
||||||
|
title=certificate.title,
|
||||||
|
certificate_id=str(certificate.certificate_id),
|
||||||
|
certificate_code=certificate.code,
|
||||||
|
score=certificate.score,
|
||||||
|
score_label=certificate.score_label,
|
||||||
|
image_url=image_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return CertificateGenerationResponse(certificates=result)
|
||||||
|
|
||||||
|
|
||||||
|
@certificates_router.get(
|
||||||
|
"verify/{str:certificate_code}",
|
||||||
|
response=CertificateVerificationOut,
|
||||||
|
)
|
||||||
|
def verify_certificate(request, certificate_code):
|
||||||
|
certificate = get_object_or_404(
|
||||||
|
UserCertificate.objects.select_related('event', 'user').prefetch_related('skills'),
|
||||||
|
code=certificate_code,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
image_url = None
|
||||||
|
if certificate.image and hasattr(certificate.image, 'url'):
|
||||||
|
image_url = request.build_absolute_uri(certificate.image.url)
|
||||||
|
|
||||||
|
return CertificateVerificationOut(
|
||||||
|
certificate_id=str(certificate.certificate_id),
|
||||||
|
certificate_code=certificate.code,
|
||||||
|
user_id=certificate.user_id,
|
||||||
|
user_name=certificate.user.get_full_name() or certificate.user.email,
|
||||||
|
event_id=certificate.event_id,
|
||||||
|
event_title=certificate.event.title,
|
||||||
|
title=certificate.title,
|
||||||
|
score=certificate.score,
|
||||||
|
score_label=certificate.score_label,
|
||||||
|
issued_at=certificate.issued_at,
|
||||||
|
expires_at=certificate.expires_at,
|
||||||
|
image_url=image_url,
|
||||||
|
skills=[skill.name for skill in certificate.skills.all()],
|
||||||
|
)
|
||||||
6
apps/certificates/apps.py
Normal file
6
apps/certificates/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CertificatesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.certificates"
|
||||||
86
apps/certificates/migrations/0001_initial.py
Normal file
86
apps/certificates/migrations/0001_initial.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2025-11-18 09:47
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
SHORT_CERTIFICATE_CODE_LENGTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
def generate_certificate_code():
|
||||||
|
return uuid.uuid4().hex[:SHORT_CERTIFICATE_CODE_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0012_alter_eventemaillog_kind'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Skill',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('name', models.CharField(max_length=120, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CertificateTemplate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('image', models.ImageField(upload_to='certificates/templates/')),
|
||||||
|
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='certificate_template', to='events.event')),
|
||||||
|
('skills', models.ManyToManyField(blank=True, help_text='Skills covered by this event.', related_name='certificate_templates', to='certificates.skill')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Certificate template',
|
||||||
|
'verbose_name_plural': 'Certificate templates',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserCertificate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('certificate_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
('code', models.CharField(default=generate_certificate_code, editable=False, max_length=10, unique=True)),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('score', models.PositiveSmallIntegerField(default=0)),
|
||||||
|
('issued_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('image', models.ImageField(blank=True, null=True, upload_to='certificates/generated/')),
|
||||||
|
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_certificates', to='events.event')),
|
||||||
|
('skills', models.ManyToManyField(blank=True, help_text='Skills demonstrated on this certificate.', related_name='user_certificates', to='certificates.skill')),
|
||||||
|
('template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='awarded_certificates', to='certificates.certificatetemplate')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-issued_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'event'], name='certificate_user_id_61901c_idx'), models.Index(fields=['event', 'score'], name='certificate_event_i_25b8ab_idx')],
|
||||||
|
'unique_together': {('user', 'event')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2026-05-19 14:07
|
||||||
|
|
||||||
|
import apps.certificates.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('certificates', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='usercertificate',
|
||||||
|
name='code',
|
||||||
|
field=models.CharField(default=apps.certificates.models._generate_certificate_code, editable=False, max_length=10, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/certificates/migrations/__init__.py
Normal file
1
apps/certificates/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
""""""
|
||||||
316
apps/certificates/models.py
Normal file
316
apps/certificates/models.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from apps.events.models import Registration
|
||||||
|
from apps.users.models import User
|
||||||
|
from core.models import BaseModel
|
||||||
|
|
||||||
|
SHORT_CERTIFICATE_CODE_LENGTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_certificate_code() -> str:
|
||||||
|
return uuid4().hex[:SHORT_CERTIFICATE_CODE_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
class Skill(BaseModel):
|
||||||
|
name = models.CharField(max_length=120, unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateTemplate(BaseModel):
|
||||||
|
event = models.OneToOneField(
|
||||||
|
'events.Event',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='certificate_template',
|
||||||
|
)
|
||||||
|
image = models.ImageField(upload_to='certificates/templates/')
|
||||||
|
skills = models.ManyToManyField(
|
||||||
|
Skill,
|
||||||
|
blank=True,
|
||||||
|
related_name='certificate_templates',
|
||||||
|
help_text='Skills covered by this event.',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Certificate template'
|
||||||
|
verbose_name_plural = 'Certificate templates'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.event.title} template'
|
||||||
|
|
||||||
|
def _validate_score(self, score: Optional[int]) -> int:
|
||||||
|
"""Normalize score values and ensure they stay within 0-100."""
|
||||||
|
if score is None:
|
||||||
|
raise ValidationError("Score is required")
|
||||||
|
try:
|
||||||
|
normalized = int(score)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValidationError("Score must be an integer between 0 and 100")
|
||||||
|
if normalized < 0 or normalized > 100:
|
||||||
|
raise ValidationError("Score must be between 0 and 100")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _resolve_skill_ids(self, skill_ids: Optional[Sequence[int]]) -> list[int]:
|
||||||
|
"""Return a cleaned list of skill IDs, defaulting to the template skills."""
|
||||||
|
if skill_ids is None:
|
||||||
|
return list(self.skills.values_list('id', flat=True))
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
seen = set()
|
||||||
|
for skill_id in skill_ids:
|
||||||
|
if skill_id is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
skill_int = int(skill_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if skill_int not in seen:
|
||||||
|
seen.add(skill_int)
|
||||||
|
normalized.append(skill_int)
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
|
||||||
|
existing = set(Skill.objects.filter(id__in=normalized).values_list('id', flat=True))
|
||||||
|
missing = set(normalized) - existing
|
||||||
|
if missing:
|
||||||
|
raise ValidationError(f"Skills not found: {', '.join(str(mid) for mid in sorted(missing))}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _ensure_user_registration(self, user: User) -> Registration:
|
||||||
|
"""Require that the user has a confirmed or attended registration for the event."""
|
||||||
|
registration = Registration.objects.filter(
|
||||||
|
event=self.event,
|
||||||
|
user=user,
|
||||||
|
status__in=[
|
||||||
|
Registration.StatusChoices.CONFIRMED,
|
||||||
|
Registration.StatusChoices.ATTENDED,
|
||||||
|
],
|
||||||
|
is_deleted=False,
|
||||||
|
).order_by('-registered_at').first()
|
||||||
|
if not registration:
|
||||||
|
raise ValidationError("User must have a confirmed or attended registration for this event.")
|
||||||
|
return registration
|
||||||
|
|
||||||
|
def _load_font(self, size: int = 48):
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype("arial.ttf", size)
|
||||||
|
except Exception:
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
def _render_certificate_image(self, certificate: 'UserCertificate') -> None:
|
||||||
|
"""Overlay user-specific text on the template image and attach it to the certificate."""
|
||||||
|
if not self.image:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
template_path = self.image.path
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
base_image = Image.open(template_path).convert("RGB")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(base_image)
|
||||||
|
font = self._load_font(size=48)
|
||||||
|
width, height = base_image.size
|
||||||
|
lines = [
|
||||||
|
certificate.user.get_full_name() or certificate.user.email,
|
||||||
|
self.event.title,
|
||||||
|
f"Score: {certificate.score} ({certificate.score_label})",
|
||||||
|
timezone.localtime(certificate.issued_at).strftime('%Y-%m-%d'),
|
||||||
|
]
|
||||||
|
margin = 40
|
||||||
|
total_height = 0
|
||||||
|
measurements = []
|
||||||
|
for line in lines:
|
||||||
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
|
line_height = bbox[3] - bbox[1]
|
||||||
|
line_width = bbox[2] - bbox[0]
|
||||||
|
measurements.append((line, line_width, line_height))
|
||||||
|
total_height += line_height + 10
|
||||||
|
y = height - margin - total_height
|
||||||
|
for line, line_width, line_height in measurements:
|
||||||
|
x = (width - line_width) / 2
|
||||||
|
draw.text((x, y), line, fill='black', font=font)
|
||||||
|
y += line_height + 10
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
base_image.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
filename = f"{self.event.slug}_{certificate.user_id}_{uuid4().hex}.png"
|
||||||
|
certificate.image.save(filename, ContentFile(buffer.read()), save=False)
|
||||||
|
certificate.save(update_fields=['image'])
|
||||||
|
|
||||||
|
def award_certificate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
title: str,
|
||||||
|
description: str = '',
|
||||||
|
score: Optional[int] = None,
|
||||||
|
skill_ids: Optional[Sequence[int]] = None,
|
||||||
|
issued_at=None,
|
||||||
|
expires_at=None,
|
||||||
|
) -> 'UserCertificate':
|
||||||
|
"""
|
||||||
|
Create or update the certificate for a single user.
|
||||||
|
"""
|
||||||
|
self._ensure_user_registration(user)
|
||||||
|
resolved_score = self._validate_score(score)
|
||||||
|
resolved_skills = self._resolve_skill_ids(skill_ids)
|
||||||
|
issued_at = issued_at or timezone.now()
|
||||||
|
title = title or f"{self.event.title} Certificate"
|
||||||
|
description = description or ''
|
||||||
|
|
||||||
|
certificate, _ = UserCertificate.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
event=self.event,
|
||||||
|
defaults={
|
||||||
|
'template': self,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'score': resolved_score,
|
||||||
|
'issued_at': issued_at,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
certificate.skills.set(resolved_skills)
|
||||||
|
self._render_certificate_image(certificate)
|
||||||
|
return certificate
|
||||||
|
|
||||||
|
def generate_certificates(
|
||||||
|
self,
|
||||||
|
entries: Sequence[dict],
|
||||||
|
*,
|
||||||
|
default_title: Optional[str] = None,
|
||||||
|
default_description: Optional[str] = None,
|
||||||
|
) -> list['UserCertificate']:
|
||||||
|
"""
|
||||||
|
Create certificates for a batch of users.
|
||||||
|
Entries expect dicts with at least `user_id` and `score`.
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
raise ValidationError("Entries payload must contain at least one item.")
|
||||||
|
|
||||||
|
user_ids = {entry.get('user_id') for entry in entries if entry.get('user_id') is not None}
|
||||||
|
if not user_ids:
|
||||||
|
raise ValidationError("No valid user IDs were provided.")
|
||||||
|
|
||||||
|
users = {user.id: user for user in User.objects.filter(id__in=user_ids)}
|
||||||
|
missing = user_ids - users.keys()
|
||||||
|
if missing:
|
||||||
|
raise ValidationError(f"Users not found: {', '.join(str(uid) for uid in sorted(missing))}")
|
||||||
|
|
||||||
|
certificates = []
|
||||||
|
for entry in entries:
|
||||||
|
user = users.get(entry.get('user_id'))
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
certificate = self.award_certificate(
|
||||||
|
user=user,
|
||||||
|
title=entry.get('title') or default_title or f"{self.event.title} Certificate",
|
||||||
|
description=entry.get('description') or default_description or '',
|
||||||
|
score=entry.get('score'),
|
||||||
|
skill_ids=entry.get('skill_ids'),
|
||||||
|
issued_at=entry.get('issued_at'),
|
||||||
|
expires_at=entry.get('expires_at'),
|
||||||
|
)
|
||||||
|
certificates.append(certificate)
|
||||||
|
return certificates
|
||||||
|
|
||||||
|
|
||||||
|
class UserCertificate(BaseModel):
|
||||||
|
SCORE_RANGES = [
|
||||||
|
(0, 24, 'Fair'),
|
||||||
|
(25, 49, 'Good'),
|
||||||
|
(50, 74, 'Very Good'),
|
||||||
|
(75, 100, 'Perfect'),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='certificates',
|
||||||
|
)
|
||||||
|
event = models.ForeignKey(
|
||||||
|
'events.Event',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='user_certificates',
|
||||||
|
)
|
||||||
|
template = models.ForeignKey(
|
||||||
|
CertificateTemplate,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='awarded_certificates',
|
||||||
|
)
|
||||||
|
certificate_id = models.UUIDField(default=uuid4, unique=True, editable=False)
|
||||||
|
code = models.CharField(
|
||||||
|
max_length=SHORT_CERTIFICATE_CODE_LENGTH,
|
||||||
|
unique=True,
|
||||||
|
editable=False,
|
||||||
|
default=_generate_certificate_code,
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
score = models.PositiveSmallIntegerField(default=0)
|
||||||
|
issued_at = models.DateTimeField(default=timezone.now)
|
||||||
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
image = models.ImageField(
|
||||||
|
upload_to='certificates/generated/',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
skills = models.ManyToManyField(
|
||||||
|
Skill,
|
||||||
|
blank=True,
|
||||||
|
related_name='user_certificates',
|
||||||
|
help_text='Skills demonstrated on this certificate.',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'event')
|
||||||
|
ordering = ['-issued_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'event']),
|
||||||
|
models.Index(fields=['event', 'score']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user} - {self.title} ({self.certificate_id})'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def score_label(self) -> str:
|
||||||
|
for lower, upper, label in self.SCORE_RANGES:
|
||||||
|
if lower <= self.score <= upper:
|
||||||
|
return label
|
||||||
|
return 'Unknown'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_unique_code() -> str:
|
||||||
|
"""Generate a short certificate code without collisions."""
|
||||||
|
for _ in range(5):
|
||||||
|
candidate = _generate_certificate_code()
|
||||||
|
if not UserCertificate.objects.filter(code=candidate).exists():
|
||||||
|
return candidate
|
||||||
|
raise RuntimeError("Unable to generate a unique certificate code.")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.code or UserCertificate.objects.filter(code=self.code).exclude(pk=self.pk).exists():
|
||||||
|
self.code = self._make_unique_code()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
122
apps/communications/admin.py
Normal file
122
apps/communications/admin.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from simplemde.widgets import SimpleMDEEditor
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|
||||||
|
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
from apps.communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementAdminForm(forms.ModelForm):
|
||||||
|
content = forms.CharField(
|
||||||
|
widget=SimpleMDEEditor(),
|
||||||
|
help_text="Announcement content in Markdown format with live preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Announcement
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Announcement)
|
||||||
|
class AnnouncementAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
form = AnnouncementAdminForm
|
||||||
|
list_display = [
|
||||||
|
'title', 'announcement_type', 'priority', 'author',
|
||||||
|
'is_published', 'publish_date', 'email_sent', 'push_sent', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'announcement_type', 'priority', 'is_published',
|
||||||
|
'send_email', 'send_push', 'target_audience',
|
||||||
|
SoftDeleteListFilter, 'created_at'
|
||||||
|
]
|
||||||
|
search_fields = ['title', 'content', 'author__username']
|
||||||
|
readonly_fields = ['email_sent', 'push_sent', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('title', 'content', 'author')
|
||||||
|
}),
|
||||||
|
('Settings', {
|
||||||
|
'fields': ('announcement_type', 'priority', 'target_audience', 'is_published', 'publish_date')
|
||||||
|
}),
|
||||||
|
('Notifications', {
|
||||||
|
'fields': ('send_email', 'send_push', 'email_sent', 'push_sent')
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + ['publish_announcements', 'send_notifications']
|
||||||
|
|
||||||
|
def publish_announcements(self, request, queryset):
|
||||||
|
queryset.update(is_published=True, publish_date=timezone.now())
|
||||||
|
self.message_user(request, f"{queryset.count()} announcements published.")
|
||||||
|
publish_announcements.short_description = "Publish selected announcements"
|
||||||
|
|
||||||
|
def send_notifications(self, request, queryset):
|
||||||
|
# This will be implemented with Celery tasks
|
||||||
|
for announcement in queryset:
|
||||||
|
if announcement.send_email and not announcement.email_sent:
|
||||||
|
# Trigger email task
|
||||||
|
pass
|
||||||
|
if announcement.send_push and not announcement.push_sent:
|
||||||
|
# Trigger push notification task
|
||||||
|
pass
|
||||||
|
self.message_user(request, f"Notifications queued for {queryset.count()} announcements.")
|
||||||
|
send_notifications.short_description = "Send notifications for selected announcements"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(NewsletterSubscription)
|
||||||
|
class NewsletterSubscriptionAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
list_display = ['email', 'user', 'is_active', 'confirmed_at', 'created_at']
|
||||||
|
list_filter = ['is_active', SoftDeleteListFilter, 'created_at', 'confirmed_at']
|
||||||
|
search_fields = ['email', 'user__username', 'user__email']
|
||||||
|
readonly_fields = ['confirmation_token', 'unsubscribe_token', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Subscription', {
|
||||||
|
'fields': ('email', 'user', 'is_active', 'subscribed_categories')
|
||||||
|
}),
|
||||||
|
('Confirmation', {
|
||||||
|
'fields': ('confirmed_at', 'confirmation_token', 'unsubscribe_token')
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + ['activate_subscriptions', 'deactivate_subscriptions']
|
||||||
|
|
||||||
|
def activate_subscriptions(self, request, queryset):
|
||||||
|
queryset.update(is_active=True)
|
||||||
|
self.message_user(request, f"{queryset.count()} subscriptions activated.")
|
||||||
|
activate_subscriptions.short_description = "Activate selected subscriptions"
|
||||||
|
|
||||||
|
def deactivate_subscriptions(self, request, queryset):
|
||||||
|
queryset.update(is_active=False)
|
||||||
|
self.message_user(request, f"{queryset.count()} subscriptions deactivated.")
|
||||||
|
deactivate_subscriptions.short_description = "Deactivate selected subscriptions"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PushNotificationDevice)
|
||||||
|
class PushNotificationDeviceAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
list_display = ['user', 'device_type', 'is_active', 'created_at']
|
||||||
|
list_filter = ['device_type', 'is_active', SoftDeleteListFilter, 'created_at']
|
||||||
|
search_fields = ['user__username', 'user__email', 'device_token']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Device', {
|
||||||
|
'fields': ('user', 'device_token', 'device_type', 'is_active')
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
0
apps/communications/api/__init__.py
Normal file
0
apps/communications/api/__init__.py
Normal file
124
apps/communications/api/schemas.py
Normal file
124
apps/communications/api/schemas.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Schemas for communications-related endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from ninja import Schema, ModelSchema
|
||||||
|
|
||||||
|
from apps.blog.api.schemas import AuthorSchema
|
||||||
|
from apps.communications.models import (
|
||||||
|
Announcement,
|
||||||
|
NewsletterSubscription,
|
||||||
|
PushNotificationDevice
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementSchema(ModelSchema):
|
||||||
|
author: AuthorSchema
|
||||||
|
content_html: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Announcement
|
||||||
|
model_fields = [
|
||||||
|
'id', 'title', 'content', 'announcement_type', 'priority',
|
||||||
|
'is_published', 'publish_date', 'send_email', 'send_push',
|
||||||
|
'target_audience', 'email_sent', 'push_sent', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_content_html(obj):
|
||||||
|
return obj.content_html
|
||||||
|
|
||||||
|
class AnnouncementListSchema(Schema):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
announcement_type: str
|
||||||
|
priority: str
|
||||||
|
author: AuthorSchema
|
||||||
|
is_published: bool
|
||||||
|
publish_date: Optional[datetime] = None
|
||||||
|
target_audience: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class AnnouncementCreateSchema(Schema):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
announcement_type: str = "general"
|
||||||
|
priority: str = "normal"
|
||||||
|
target_audience: str = "all"
|
||||||
|
is_published: bool = False
|
||||||
|
publish_date: Optional[datetime] = None
|
||||||
|
send_email: bool = False
|
||||||
|
send_push: bool = False
|
||||||
|
|
||||||
|
class AnnouncementUpdateSchema(Schema):
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
announcement_type: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
target_audience: Optional[str] = None
|
||||||
|
is_published: Optional[bool] = None
|
||||||
|
publish_date: Optional[datetime] = None
|
||||||
|
send_email: Optional[bool] = None
|
||||||
|
send_push: Optional[bool] = None
|
||||||
|
|
||||||
|
class NewsletterSubscriptionSchema(ModelSchema):
|
||||||
|
user: Optional[AuthorSchema] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = NewsletterSubscription
|
||||||
|
model_fields = [
|
||||||
|
'id', 'email', 'is_active', 'subscribed_categories',
|
||||||
|
'confirmed_at', 'created_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
class NewsletterSubscribeSchema(Schema):
|
||||||
|
email: str
|
||||||
|
subscribed_categories: Optional[List[str]] = []
|
||||||
|
|
||||||
|
class NewsletterUnsubscribeSchema(Schema):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
class PushDeviceSchema(ModelSchema):
|
||||||
|
user: AuthorSchema
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = PushNotificationDevice
|
||||||
|
model_fields = [
|
||||||
|
'id', 'device_token', 'device_type', 'is_active', 'created_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
class PushDeviceCreateSchema(Schema):
|
||||||
|
device_token: str
|
||||||
|
device_type: str = "web"
|
||||||
|
|
||||||
|
class PushDeviceUpdateSchema(Schema):
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
class PushNotificationSchema(Schema):
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
data: Optional[dict] = None
|
||||||
|
target_audience: str = "all"
|
||||||
|
|
||||||
|
class MessageResponseSchema(Schema):
|
||||||
|
"""Simple message payload for API responses."""
|
||||||
|
message: str
|
||||||
|
success: bool = True
|
||||||
|
|
||||||
|
class AnnouncementStatsSchema(Schema):
|
||||||
|
"""Summary statistics for announcements."""
|
||||||
|
total_announcements: int
|
||||||
|
published_announcements: int
|
||||||
|
draft_announcements: int
|
||||||
|
urgent_announcements: int
|
||||||
|
email_sent_count: int
|
||||||
|
push_sent_count: int
|
||||||
|
|
||||||
|
class NewsletterStatsSchema(Schema):
|
||||||
|
"""Summary statistics for newsletter subscriptions."""
|
||||||
|
total_subscriptions: int
|
||||||
|
active_subscriptions: int
|
||||||
|
confirmed_subscriptions: int
|
||||||
|
recent_subscriptions: int
|
||||||
329
apps/communications/api/views.py
Normal file
329
apps/communications/api/views.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.pagination import paginate
|
||||||
|
from typing import List
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apps.communications.models import (
|
||||||
|
Announcement, NewsletterSubscription, PushNotificationDevice,
|
||||||
|
AnnouncementType, AnnouncementPriority
|
||||||
|
)
|
||||||
|
from apps.communications.utils import (
|
||||||
|
send_announcement_email, send_newsletter_confirmation,
|
||||||
|
get_announcement_recipients
|
||||||
|
)
|
||||||
|
from apps.communications.push_notifications import push_service
|
||||||
|
from apps.communications.api.schemas import (
|
||||||
|
AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema,
|
||||||
|
NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema,
|
||||||
|
PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema,
|
||||||
|
PushNotificationSchema, MessageResponseSchema,
|
||||||
|
AnnouncementStatsSchema, NewsletterStatsSchema
|
||||||
|
)
|
||||||
|
from core.authentication import jwt_auth
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
communications_router = Router()
|
||||||
|
|
||||||
|
# Announcement endpoints
|
||||||
|
@communications_router.get("/announcements/", response=List[AnnouncementListSchema])
|
||||||
|
@paginate
|
||||||
|
def list_announcements(request, published_only: bool = True):
|
||||||
|
"""List announcements"""
|
||||||
|
queryset = Announcement.objects.select_related('author').filter(is_deleted=False)
|
||||||
|
|
||||||
|
if published_only:
|
||||||
|
queryset = queryset.filter(is_published=True, publish_date__lte=timezone.now())
|
||||||
|
|
||||||
|
return queryset.order_by('-created_at')
|
||||||
|
|
||||||
|
@communications_router.get("/announcements/{announcement_id}/", response=AnnouncementSchema)
|
||||||
|
def get_announcement(request, announcement_id: int):
|
||||||
|
"""Get single announcement"""
|
||||||
|
announcement = get_object_or_404(
|
||||||
|
Announcement.objects.select_related('author').filter(is_deleted=False),
|
||||||
|
id=announcement_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if published or user has permission
|
||||||
|
if not announcement.is_published:
|
||||||
|
# Only allow access to unpublished announcements for staff/committee
|
||||||
|
if not hasattr(request, 'auth') or not request.auth:
|
||||||
|
return {"error": "Announcement not found"}, 404
|
||||||
|
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Announcement not found"}, 404
|
||||||
|
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
@communications_router.post("/announcements/", response=AnnouncementSchema, auth=jwt_auth)
|
||||||
|
def create_announcement(request, payload: AnnouncementCreateSchema):
|
||||||
|
"""Create new announcement (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
announcement = Announcement.objects.create(
|
||||||
|
author=user,
|
||||||
|
**payload.dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send notifications if requested and published
|
||||||
|
if announcement.is_published and announcement.publish_date <= timezone.now():
|
||||||
|
if announcement.send_email:
|
||||||
|
recipients = get_announcement_recipients(announcement)
|
||||||
|
if recipients:
|
||||||
|
send_announcement_email(announcement, recipients)
|
||||||
|
announcement.email_sent = True
|
||||||
|
|
||||||
|
if announcement.send_push:
|
||||||
|
push_service.send_announcement_notification(announcement)
|
||||||
|
announcement.push_sent = True
|
||||||
|
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
@communications_router.put("/announcements/{announcement_id}/", response=AnnouncementSchema, auth=jwt_auth)
|
||||||
|
def update_announcement(request, announcement_id: int, payload: AnnouncementUpdateSchema):
|
||||||
|
"""Update announcement (author/committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (user.is_staff or user.is_committee or announcement.author == user):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
for field, value in payload.dict(exclude_unset=True).items():
|
||||||
|
setattr(announcement, field, value)
|
||||||
|
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
# Send notifications if newly published
|
||||||
|
if (announcement.is_published and announcement.publish_date <= timezone.now() and
|
||||||
|
not announcement.email_sent and announcement.send_email):
|
||||||
|
recipients = get_announcement_recipients(announcement)
|
||||||
|
if recipients:
|
||||||
|
send_announcement_email(announcement, recipients)
|
||||||
|
announcement.email_sent = True
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
if (announcement.is_published and announcement.publish_date <= timezone.now() and
|
||||||
|
not announcement.push_sent and announcement.send_push):
|
||||||
|
push_service.send_announcement_notification(announcement)
|
||||||
|
announcement.push_sent = True
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
@communications_router.delete("/announcements/{announcement_id}/", response=MessageResponseSchema, auth=jwt_auth)
|
||||||
|
def delete_announcement(request, announcement_id: int):
|
||||||
|
"""Delete announcement (author/committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (user.is_staff or user.is_committee or announcement.author == user):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
announcement.soft_delete()
|
||||||
|
return {"message": "Announcement deleted successfully"}
|
||||||
|
|
||||||
|
@communications_router.get("/announcements/stats/", response=AnnouncementStatsSchema, auth=jwt_auth)
|
||||||
|
def get_announcement_stats(request):
|
||||||
|
"""Get announcement statistics (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
stats = Announcement.objects.filter(is_deleted=False).aggregate(
|
||||||
|
total_announcements=Count('id'),
|
||||||
|
published_announcements=Count('id', filter=Q(is_published=True)),
|
||||||
|
draft_announcements=Count('id', filter=Q(is_published=False)),
|
||||||
|
urgent_announcements=Count('id', filter=Q(priority='urgent')),
|
||||||
|
email_sent_count=Count('id', filter=Q(email_sent=True)),
|
||||||
|
push_sent_count=Count('id', filter=Q(push_sent=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Newsletter endpoints
|
||||||
|
@communications_router.post("/newsletter/subscribe/", response=MessageResponseSchema)
|
||||||
|
def subscribe_newsletter(request, payload: NewsletterSubscribeSchema):
|
||||||
|
"""Subscribe to newsletter"""
|
||||||
|
try:
|
||||||
|
subscription, created = NewsletterSubscription.objects.get_or_create(
|
||||||
|
email=payload.email,
|
||||||
|
defaults={
|
||||||
|
'subscribed_categories': payload.subscribed_categories,
|
||||||
|
'is_active': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created and not subscription.is_active:
|
||||||
|
subscription.is_active = True
|
||||||
|
subscription.subscribed_categories = payload.subscribed_categories
|
||||||
|
subscription.save()
|
||||||
|
|
||||||
|
# Send confirmation email
|
||||||
|
send_newsletter_confirmation(subscription)
|
||||||
|
|
||||||
|
message = (
|
||||||
|
"عضویت در خبرنامه با موفقیت انجام شد! لطفاً برای تأیید، ایمیل خود را بررسی کنید."
|
||||||
|
if created
|
||||||
|
else "اشتراک خبرنامه بهروزرسانی شد!"
|
||||||
|
)
|
||||||
|
return {"message": message}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Newsletter subscription failed: {str(e)}")
|
||||||
|
return {"message": "Subscription failed", "success": False}, 400
|
||||||
|
|
||||||
|
@communications_router.post("/newsletter/unsubscribe/", response=MessageResponseSchema)
|
||||||
|
def unsubscribe_newsletter(request, payload: NewsletterUnsubscribeSchema):
|
||||||
|
"""Unsubscribe from newsletter"""
|
||||||
|
try:
|
||||||
|
subscription = NewsletterSubscription.objects.get(email=payload.email)
|
||||||
|
subscription.is_active = False
|
||||||
|
subscription.save()
|
||||||
|
return {"message": "Successfully unsubscribed from newsletter"}
|
||||||
|
except NewsletterSubscription.DoesNotExist:
|
||||||
|
return {"message": "Email not found in subscription list"}, 404
|
||||||
|
|
||||||
|
@communications_router.get("/newsletter/confirm/{token}/", response=MessageResponseSchema)
|
||||||
|
def confirm_newsletter_subscription(request, token: str):
|
||||||
|
"""Confirm newsletter subscription"""
|
||||||
|
try:
|
||||||
|
subscription = NewsletterSubscription.objects.get(confirmation_token=token)
|
||||||
|
subscription.confirmed_at = timezone.now()
|
||||||
|
subscription.is_active = True
|
||||||
|
subscription.save()
|
||||||
|
return {"message": "Newsletter subscription confirmed successfully!"}
|
||||||
|
except NewsletterSubscription.DoesNotExist:
|
||||||
|
return {"message": "Invalid confirmation token"}, 400
|
||||||
|
|
||||||
|
@communications_router.get("/newsletter/unsubscribe/{token}/", response=MessageResponseSchema)
|
||||||
|
def unsubscribe_newsletter_token(request, token: str):
|
||||||
|
"""Unsubscribe using token from email"""
|
||||||
|
try:
|
||||||
|
subscription = NewsletterSubscription.objects.get(unsubscribe_token=token)
|
||||||
|
subscription.is_active = False
|
||||||
|
subscription.save()
|
||||||
|
return {"message": "Successfully unsubscribed from newsletter"}
|
||||||
|
except NewsletterSubscription.DoesNotExist:
|
||||||
|
return {"message": "Invalid unsubscribe token"}, 400
|
||||||
|
|
||||||
|
@communications_router.get("/newsletter/subscriptions/", response=List[NewsletterSubscriptionSchema], auth=jwt_auth)
|
||||||
|
@paginate
|
||||||
|
def list_newsletter_subscriptions(request):
|
||||||
|
"""List newsletter subscriptions (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
return NewsletterSubscription.objects.select_related('user').filter(is_deleted=False).order_by('-created_at')
|
||||||
|
|
||||||
|
@communications_router.get("/newsletter/stats/", response=NewsletterStatsSchema, auth=jwt_auth)
|
||||||
|
def get_newsletter_stats(request):
|
||||||
|
"""Get newsletter statistics (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
stats = NewsletterSubscription.objects.filter(is_deleted=False).aggregate(
|
||||||
|
total_subscriptions=Count('id'),
|
||||||
|
active_subscriptions=Count('id', filter=Q(is_active=True)),
|
||||||
|
confirmed_subscriptions=Count('id', filter=Q(confirmed_at__isnull=False)),
|
||||||
|
recent_subscriptions=Count('id', filter=Q(created_at__gte=timezone.now() - timezone.timedelta(days=30)))
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Push notification endpoints
|
||||||
|
@communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth)
|
||||||
|
def register_push_device(request, payload: PushDeviceCreateSchema):
|
||||||
|
"""Register push notification device"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
device, created = PushNotificationDevice.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
device_token=payload.device_token,
|
||||||
|
defaults={'device_type': payload.device_type, 'is_active': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
device.is_active = True
|
||||||
|
device.device_type = payload.device_type
|
||||||
|
device.save()
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
@communications_router.delete("/push-devices/", response=MessageResponseSchema, auth=jwt_auth)
|
||||||
|
def unregister_push_device(request, device_token: str):
|
||||||
|
"""Unregister push notification device"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = PushNotificationDevice.objects.get(user=user, device_token=device_token)
|
||||||
|
device.delete()
|
||||||
|
return {"message": "Device unregistered successfully"}
|
||||||
|
except PushNotificationDevice.DoesNotExist:
|
||||||
|
return {"message": "Device not found"}, 404
|
||||||
|
|
||||||
|
@communications_router.get("/push-devices/", response=List[PushDeviceSchema], auth=jwt_auth)
|
||||||
|
def list_user_push_devices(request):
|
||||||
|
"""List user's push notification devices"""
|
||||||
|
user = request.auth
|
||||||
|
return PushNotificationDevice.objects.filter(user=user, is_deleted=False).order_by('-created_at')
|
||||||
|
|
||||||
|
@communications_router.put("/push-devices/{device_id}/", response=PushDeviceSchema, auth=jwt_auth)
|
||||||
|
def update_push_device(request, device_id: int, payload: PushDeviceUpdateSchema):
|
||||||
|
"""Update push notification device"""
|
||||||
|
user = request.auth
|
||||||
|
device = get_object_or_404(PushNotificationDevice, id=device_id, user=user, is_deleted=False)
|
||||||
|
|
||||||
|
device.is_active = payload.is_active
|
||||||
|
device.save()
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
@communications_router.post("/push-notifications/send/", response=MessageResponseSchema, auth=jwt_auth)
|
||||||
|
def send_push_notification(request, payload: PushNotificationSchema):
|
||||||
|
"""Send push notification (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
# Get target users
|
||||||
|
users = []
|
||||||
|
if payload.target_audience == 'all':
|
||||||
|
users = User.objects.filter(is_active=True)
|
||||||
|
elif payload.target_audience == 'members':
|
||||||
|
users = User.objects.filter(is_member=True, is_active=True)
|
||||||
|
elif payload.target_audience == 'committee':
|
||||||
|
users = User.objects.filter(is_committee=True, is_active=True)
|
||||||
|
|
||||||
|
# Send notifications
|
||||||
|
total_sent = push_service.send_to_multiple_users(
|
||||||
|
users, payload.title, payload.body, payload.data
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": f"Push notification sent to {total_sent} devices"}
|
||||||
|
|
||||||
|
# Utility endpoints
|
||||||
|
@communications_router.get("/announcement-types/", response=List[dict])
|
||||||
|
def get_announcement_types(request):
|
||||||
|
"""Get available announcement types"""
|
||||||
|
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementType.choices]
|
||||||
|
|
||||||
|
@communications_router.get("/announcement-priorities/", response=List[dict])
|
||||||
|
def get_announcement_priorities(request):
|
||||||
|
"""Get available announcement priorities"""
|
||||||
|
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementPriority.choices]
|
||||||
7
apps/communications/apps.py
Normal file
7
apps/communications/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CommunicationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.communications"
|
||||||
|
verbose_name = "Communications"
|
||||||
536
apps/communications/fixtures/communications.json
Normal file
536
apps/communications/fixtures/communications.json
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-01T10:00:00Z",
|
||||||
|
"updated_at": "2024-03-01T10:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "شروع ثبتنام کارگاه یادگیری ماشین",
|
||||||
|
"content": "# شروع ثبتنام کارگاه یادگیری ماشین\n\nبا سلام و احترام\n\nثبتنام کارگاه یادگیری ماشین پیشرفته از امروز آغاز شد.\n\n## جزئیات:\n- تاریخ: ۱۵ اسفند ۱۴۰۲\n- مدت: ۴ ساعت\n- هزینه: ۱۵۰ هزار تومان\n- ظرفیت: ۵۰ نفر\n\nبرای ثبتنام به وبسایت انجمن مراجعه کنید.",
|
||||||
|
"announcement_type": "event",
|
||||||
|
"priority": "high",
|
||||||
|
"author": 1,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-03-01T10:00:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": true,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": true,
|
||||||
|
"target_audience": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-10T14:30:00Z",
|
||||||
|
"updated_at": "2024-03-10T14:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "تغییر زمان مسابقه برنامهنویسی",
|
||||||
|
"content": "# تغییر زمان مسابقه برنامهنویسی\n\nبه اطلاع شرکتکنندگان محترم میرساند که زمان مسابقه برنامهنویسی بهاری به دلیل تعطیلات از ۲۲ اسفند به ۲۹ اسفند تغییر یافت.\n\nعذرخواهی بابت این تغییر و لطفاً برنامهریزی خود را بر این اساس انجام دهید.",
|
||||||
|
"announcement_type": "urgent",
|
||||||
|
"priority": "urgent",
|
||||||
|
"author": 2,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-03-10T14:30:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": true,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": true,
|
||||||
|
"target_audience": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-15T09:00:00Z",
|
||||||
|
"updated_at": "2024-03-15T09:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "وبینار امنیت سایبری - رایگان",
|
||||||
|
"content": "# وبینار امنیت سایبری\n\nانجمن علمی مهندسی کامپیوتر برگزار میکند:\n\n**وبینار امنیت سایبری**\n\n- تاریخ: ۷ فروردین ۱۴۰۳\n- ساعت: ۱۹:۰۰ الی ۲۱:۰۰\n- مدرس: دکتر محمد رضایی\n- شرکت: رایگان\n\nلینک ورود یک ساعت قبل از شروع ارسال خواهد شد.",
|
||||||
|
"announcement_type": "event",
|
||||||
|
"priority": "normal",
|
||||||
|
"author": 5,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-03-15T09:00:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": false,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": false,
|
||||||
|
"target_audience": "members"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-20T11:15:00Z",
|
||||||
|
"updated_at": "2024-03-20T11:15:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "فراخوان مقاله برای نشریه انجمن",
|
||||||
|
"content": "# فراخوان مقاله برای نشریه انجمن\n\nدانشجویان و اساتید محترم میتوانند مقالات خود را در زمینههای زیر برای چاپ در نشریه انجمن ارسال کنند:\n\n## موضوعات:\n- هوش مصنوعی\n- امنیت سایبری\n- مهندسی نرمافزار\n- شبکههای کامپیوتری\n- علم داده\n\n## مهلت ارسال:\n۳۰ فروردین ۱۴۰۳\n\nایمیل ارسال: journal@cs-association.ac.ir",
|
||||||
|
"announcement_type": "academic",
|
||||||
|
"priority": "normal",
|
||||||
|
"author": 1,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-03-20T11:15:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": false,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": false,
|
||||||
|
"target_audience": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-01T08:00:00Z",
|
||||||
|
"updated_at": "2024-04-01T08:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "هکاتون هوش مصنوعی - ثبتنام آغاز شد",
|
||||||
|
"content": "# هکاتون هوش مصنوعی\n\nبزرگترین رویداد سال انجمن!\n\n## جزئیات:\n- تاریخ: ۳۰ فروردین تا ۲ اردیبهشت\n- مدت: ۴۸ ساعت\n- جایزه کل: ۲۰ میلیون تومان\n- ظرفیت: ۶۰ نفر (۲۰ تیم)\n\n## امکانات:\n- غذا و نوشیدنی رایگان\n- منتورینگ اساتید\n- فضای کار ۲۴ ساعته\n\nثبتنام تیمی (۳ نفره) الزامی است.",
|
||||||
|
"announcement_type": "event",
|
||||||
|
"priority": "high",
|
||||||
|
"author": 9,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-04-01T08:00:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": true,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": true,
|
||||||
|
"target_audience": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-05T16:00:00Z",
|
||||||
|
"updated_at": "2024-04-05T16:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "جلسه کمیته اجرایی انجمن",
|
||||||
|
"content": "# جلسه کمیته اجرایی انجمن\n\nاعضای محترم کمیته اجرایی\n\nجلسه ماهانه کمیته اجرایی:\n\n- تاریخ: ۱۰ اردیبهشت ۱۴۰۳\n- ساعت: ۱۴:۰۰\n- مکان: دفتر انجمن\n\n## دستور جلسه:\n1. بررسی گزارش مالی\n2. برنامهریزی رویدادهای آتی\n3. بررسی درخواستهای عضویت\n4. سایر موارد\n\nحضور همه اعضا الزامی است.",
|
||||||
|
"announcement_type": "general",
|
||||||
|
"priority": "normal",
|
||||||
|
"author": 1,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-04-05T16:00:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": false,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": false,
|
||||||
|
"target_audience": "committee"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-15T12:30:00Z",
|
||||||
|
"updated_at": "2024-04-15T12:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "سمینار کارآفرینی فناوری",
|
||||||
|
"content": "# سمینار کارآفرینی فناوری\n\nبا حضور کارآفرینان موفق صنعت فناوری\n\n## سخنرانان:\n- دکتر علی احمدی (موسس تپسی)\n- خانم سارا محمدی (مدیرعامل کافهبازار)\n- مهندس رضا کریمی (سرمایهگذار)\n\n## موضوعات:\n- از ایده تا محصول\n- جذب سرمایه\n- چالشهای استارتاپی\n- آینده فناوری در ایران\n\nشرکت رایگان - ظرفیت محدود",
|
||||||
|
"announcement_type": "event",
|
||||||
|
"priority": "high",
|
||||||
|
"author": 2,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-04-15T12:30:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": true,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": true,
|
||||||
|
"target_audience": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-20T10:45:00Z",
|
||||||
|
"updated_at": "2024-04-20T10:45:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "کارگاه DevOps - ثبتنام محدود",
|
||||||
|
"content": "# کارگاه DevOps و Docker\n\nآموزش عملی ابزارهای DevOps\n\n## محتوا:\n- Docker و Containerization\n- CI/CD Pipeline\n- Kubernetes مقدماتی\n- پروژه عملی\n\n## جزئیات:\n- تاریخ: ۱۴ اردیبهشت\n- مدت: ۸ ساعت\n- هزینه: ۳۰۰ هزار تومان\n- ظرفیت: ۲۵ نفر\n\n⚠️ ظرفیت بسیار محدود - عجله کنید!",
|
||||||
|
"announcement_type": "event",
|
||||||
|
"priority": "high",
|
||||||
|
"author": 8,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-04-20T10:45:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": true,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": true,
|
||||||
|
"target_audience": "members"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-25T13:20:00Z",
|
||||||
|
"updated_at": "2024-04-25T13:20:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "مسابقه طراحی UI/UX - جوایز جذاب",
|
||||||
|
"content": "# مسابقه طراحی UI/UX\n\nفرصتی برای نمایش خلاقیت شما!\n\n## موضوع:\nطراحی اپلیکیشن مدیریت تسک دانشجویی\n\n## جوایز:\n- نفر اول: iPad Air\n- نفر دوم: AirPods Pro\n- نفر سوم: پاوربانک ۲۰۰۰۰ میلیآمپر\n\n## مهلت ارسال:\n۲۰ اردیبهشت ۱۴۰۳\n\nفایلهای Figma یا Adobe XD قابل قبول هستند.",
|
||||||
|
"announcement_type": "event",
|
||||||
|
"priority": "normal",
|
||||||
|
"author": 12,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-04-25T13:20:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": false,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": false,
|
||||||
|
"target_audience": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.announcement",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-05-01T15:00:00Z",
|
||||||
|
"updated_at": "2024-05-01T15:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "نشست فارغالتحصیلان - دعوت ویژه",
|
||||||
|
"content": "# نشست فارغالتحصیلان\n\nدیدار با فارغالتحصیلان موفق\n\n## مهمانان ویژه:\n- دکتر حسن زارع (مدیر فنی گوگل)\n- مهندس مریم حسینی (بنیانگذار استارتاپ)\n- دکتر امیر قربانی (استاد MIT)\n\n## برنامه:\n- ۱۷:۰۰ - پذیرایی\n- ۱۸:۰۰ - سخنرانیها\n- ۱۹:۳۰ - پرسش و پاسخ\n- ۲۰:۳۰ - ضیافت شام\n\nشرکت رایگان - ثبتنام الزامی",
|
||||||
|
"announcement_type": "event",
|
||||||
|
"priority": "normal",
|
||||||
|
"author": 5,
|
||||||
|
"is_published": true,
|
||||||
|
"publish_date": "2024-05-01T15:00:00Z",
|
||||||
|
"send_email": true,
|
||||||
|
"send_push": false,
|
||||||
|
"email_sent": true,
|
||||||
|
"push_sent": false,
|
||||||
|
"target_audience": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-15T10:30:00Z",
|
||||||
|
"updated_at": "2024-01-15T10:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "sara.mohammadi@student.ac.ir",
|
||||||
|
"user": 2,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["event", "academic", "general"],
|
||||||
|
"confirmed_at": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-20T14:15:00Z",
|
||||||
|
"updated_at": "2024-01-20T14:15:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "reza.karimi@student.ac.ir",
|
||||||
|
"user": 3,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["event", "urgent"],
|
||||||
|
"confirmed_at": "2024-01-20T14:15:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-01T09:45:00Z",
|
||||||
|
"updated_at": "2024-02-01T09:45:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "maryam.hosseini@student.ac.ir",
|
||||||
|
"user": 4,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["event", "academic"],
|
||||||
|
"confirmed_at": "2024-02-01T09:45:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-05T16:20:00Z",
|
||||||
|
"updated_at": "2024-02-05T16:20:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "hassan.zare@student.ac.ir",
|
||||||
|
"user": 5,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["general", "event", "academic", "urgent"],
|
||||||
|
"confirmed_at": "2024-02-05T16:20:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-10T11:30:00Z",
|
||||||
|
"updated_at": "2024-02-10T11:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "zahra.safari@student.ac.ir",
|
||||||
|
"user": 6,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["event", "academic"],
|
||||||
|
"confirmed_at": "2024-02-10T11:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-15T13:45:00Z",
|
||||||
|
"updated_at": "2024-02-15T13:45:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "fateme.moradi@student.ac.ir",
|
||||||
|
"user": 8,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["event"],
|
||||||
|
"confirmed_at": "2024-02-15T13:45:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-20T08:15:00Z",
|
||||||
|
"updated_at": "2024-02-20T08:15:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "amir.ghorbani@student.ac.ir",
|
||||||
|
"user": 9,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["general", "event", "academic"],
|
||||||
|
"confirmed_at": "2024-02-20T08:15:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-25T15:30:00Z",
|
||||||
|
"updated_at": "2024-02-25T15:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "nasrin.jafari@student.ac.ir",
|
||||||
|
"user": 10,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["academic", "event"],
|
||||||
|
"confirmed_at": "2024-02-25T15:30:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-01T12:00:00Z",
|
||||||
|
"updated_at": "2024-03-01T12:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "mehdi.bagheri@student.ac.ir",
|
||||||
|
"user": 11,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["event"],
|
||||||
|
"confirmed_at": "2024-03-01T12:00:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-05T14:45:00Z",
|
||||||
|
"updated_at": "2024-03-05T14:45:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "leila.mousavi@student.ac.ir",
|
||||||
|
"user": 12,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["event", "academic"],
|
||||||
|
"confirmed_at": "2024-03-05T14:45:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 11,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-10T10:20:00Z",
|
||||||
|
"updated_at": "2024-03-10T10:20:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "external.user1@gmail.com",
|
||||||
|
"user": null,
|
||||||
|
"is_active": true,
|
||||||
|
"subscribed_categories": ["event"],
|
||||||
|
"confirmed_at": "2024-03-10T10:20:00Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.newslettersubscription",
|
||||||
|
"pk": 12,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-15T16:30:00Z",
|
||||||
|
"updated_at": "2024-03-15T16:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"email": "external.user2@yahoo.com",
|
||||||
|
"user": null,
|
||||||
|
"is_active": false,
|
||||||
|
"subscribed_categories": ["general"],
|
||||||
|
"confirmed_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-10T08:00:00Z",
|
||||||
|
"updated_at": "2024-01-10T08:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 1,
|
||||||
|
"device_token": "web_push_token_admin_chrome",
|
||||||
|
"device_type": "web",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-15T12:30:00Z",
|
||||||
|
"updated_at": "2024-01-15T12:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 2,
|
||||||
|
"device_token": "web_push_token_sara_firefox",
|
||||||
|
"device_type": "web",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-20T16:45:00Z",
|
||||||
|
"updated_at": "2024-01-20T16:45:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 3,
|
||||||
|
"device_token": "web_push_token_reza_chrome",
|
||||||
|
"device_type": "web",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-01T11:20:00Z",
|
||||||
|
"updated_at": "2024-02-01T11:20:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 4,
|
||||||
|
"device_token": "android_token_maryam_phone",
|
||||||
|
"device_type": "android",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-05T18:10:00Z",
|
||||||
|
"updated_at": "2024-02-05T18:10:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 5,
|
||||||
|
"device_token": "web_push_token_hassan_edge",
|
||||||
|
"device_type": "web",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-10T13:25:00Z",
|
||||||
|
"updated_at": "2024-02-10T13:25:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 6,
|
||||||
|
"device_token": "ios_token_zahra_iphone",
|
||||||
|
"device_type": "ios",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-15T15:40:00Z",
|
||||||
|
"updated_at": "2024-02-15T15:40:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 8,
|
||||||
|
"device_token": "web_push_token_fateme_chrome",
|
||||||
|
"device_type": "web",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-20T10:15:00Z",
|
||||||
|
"updated_at": "2024-02-20T10:15:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 9,
|
||||||
|
"device_token": "web_push_token_amir_firefox",
|
||||||
|
"device_type": "web",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-25T17:30:00Z",
|
||||||
|
"updated_at": "2024-02-25T17:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 10,
|
||||||
|
"device_token": "android_token_nasrin_phone",
|
||||||
|
"device_type": "android",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-01T14:00:00Z",
|
||||||
|
"updated_at": "2024-03-01T14:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 11,
|
||||||
|
"device_token": "web_push_token_mehdi_chrome",
|
||||||
|
"device_type": "web",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 11,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-05T16:50:00Z",
|
||||||
|
"updated_at": "2024-03-05T16:50:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 12,
|
||||||
|
"device_token": "ios_token_leila_iphone",
|
||||||
|
"device_type": "ios",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "communications.pushnotificationdevice",
|
||||||
|
"pk": 12,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-10T08:00:00Z",
|
||||||
|
"updated_at": "2024-03-10T12:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"user": 1,
|
||||||
|
"device_token": "android_token_admin_phone",
|
||||||
|
"device_type": "android",
|
||||||
|
"is_active": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
78
apps/communications/migrations/0001_initial.py
Normal file
78
apps/communications/migrations/0001_initial.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Announcement',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('title', models.CharField(max_length=200, verbose_name='Title')),
|
||||||
|
('content', models.TextField(verbose_name='Content')),
|
||||||
|
('announcement_type', models.CharField(choices=[('general', 'General'), ('event', 'Event'), ('academic', 'Academic'), ('urgent', 'Urgent'), ('newsletter', 'Newsletter')], default='general', max_length=20, verbose_name='Type')),
|
||||||
|
('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('urgent', 'Urgent')], default='normal', max_length=10, verbose_name='Priority')),
|
||||||
|
('is_published', models.BooleanField(default=False, verbose_name='Published')),
|
||||||
|
('publish_date', models.DateTimeField(blank=True, null=True, verbose_name='Publish Date')),
|
||||||
|
('send_email', models.BooleanField(default=False, verbose_name='Send Email Notification')),
|
||||||
|
('send_push', models.BooleanField(default=False, verbose_name='Send Push Notification')),
|
||||||
|
('email_sent', models.BooleanField(default=False, verbose_name='Email Sent')),
|
||||||
|
('push_sent', models.BooleanField(default=False, verbose_name='Push Sent')),
|
||||||
|
('target_audience', models.CharField(choices=[('all', 'All Users'), ('members', 'Members Only'), ('committee', 'Committee Only'), ('subscribers', 'Newsletter Subscribers Only')], default='all', max_length=20, verbose_name='Target Audience')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Announcement',
|
||||||
|
'verbose_name_plural': 'Announcements',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NewsletterSubscription',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Active')),
|
||||||
|
('subscribed_categories', models.JSONField(blank=True, default=list, help_text='List of announcement types to receive', verbose_name='Subscribed Categories')),
|
||||||
|
('confirmation_token', models.CharField(blank=True, max_length=100, verbose_name='Confirmation Token')),
|
||||||
|
('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='Confirmed At')),
|
||||||
|
('unsubscribe_token', models.CharField(blank=True, max_length=100, verbose_name='Unsubscribe Token')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Newsletter Subscription',
|
||||||
|
'verbose_name_plural': 'Newsletter Subscriptions',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PushNotificationDevice',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('device_token', models.TextField(verbose_name='Device Token')),
|
||||||
|
('device_type', models.CharField(choices=[('web', 'Web'), ('android', 'Android'), ('ios', 'iOS')], max_length=10, verbose_name='Device Type')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Active')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Push Notification Device',
|
||||||
|
'verbose_name_plural': 'Push Notification Devices',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
37
apps/communications/migrations/0002_initial.py
Normal file
37
apps/communications/migrations/0002_initial.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('communications', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='announcement',
|
||||||
|
name='author',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='announcements', to=settings.AUTH_USER_MODEL, verbose_name='Author'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='newslettersubscription',
|
||||||
|
name='user',
|
||||||
|
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='newsletter_subscription', to=settings.AUTH_USER_MODEL, verbose_name='User'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='pushnotificationdevice',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='push_devices', to=settings.AUTH_USER_MODEL, verbose_name='User'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='pushnotificationdevice',
|
||||||
|
unique_together={('user', 'device_token')},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/communications/migrations/__init__.py
Normal file
0
apps/communications/migrations/__init__.py
Normal file
142
apps/communications/models.py
Normal file
142
apps/communications/models.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from core.models import BaseModel
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementType(models.TextChoices):
|
||||||
|
GENERAL = 'general', 'General'
|
||||||
|
EVENT = 'event', 'Event'
|
||||||
|
ACADEMIC = 'academic', 'Academic'
|
||||||
|
URGENT = 'urgent', 'Urgent'
|
||||||
|
NEWSLETTER = 'newsletter', 'Newsletter'
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementPriority(models.TextChoices):
|
||||||
|
LOW = 'low', 'Low'
|
||||||
|
NORMAL = 'normal', 'Normal'
|
||||||
|
HIGH = 'high', 'High'
|
||||||
|
URGENT = 'urgent', 'Urgent'
|
||||||
|
|
||||||
|
|
||||||
|
class Announcement(BaseModel):
|
||||||
|
title = models.CharField(max_length=200, verbose_name='Title')
|
||||||
|
content = models.TextField(verbose_name='Content')
|
||||||
|
announcement_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=AnnouncementType.choices,
|
||||||
|
default=AnnouncementType.GENERAL,
|
||||||
|
verbose_name='Type'
|
||||||
|
)
|
||||||
|
priority = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=AnnouncementPriority.choices,
|
||||||
|
default=AnnouncementPriority.NORMAL,
|
||||||
|
verbose_name='Priority'
|
||||||
|
)
|
||||||
|
author = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='announcements',
|
||||||
|
verbose_name='Author'
|
||||||
|
)
|
||||||
|
is_published = models.BooleanField(default=False, verbose_name='Published')
|
||||||
|
publish_date = models.DateTimeField(null=True, blank=True, verbose_name='Publish Date')
|
||||||
|
send_email = models.BooleanField(default=False, verbose_name='Send Email Notification')
|
||||||
|
send_push = models.BooleanField(default=False, verbose_name='Send Push Notification')
|
||||||
|
email_sent = models.BooleanField(default=False, verbose_name='Email Sent')
|
||||||
|
push_sent = models.BooleanField(default=False, verbose_name='Push Sent')
|
||||||
|
target_audience = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('all', 'All Users'),
|
||||||
|
('members', 'Members Only'),
|
||||||
|
('committee', 'Committee Only'),
|
||||||
|
('subscribers', 'Newsletter Subscribers Only'),
|
||||||
|
],
|
||||||
|
default='all',
|
||||||
|
verbose_name='Target Audience'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Announcement'
|
||||||
|
verbose_name_plural = 'Announcements'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_html(self):
|
||||||
|
"""Convert markdown content to HTML"""
|
||||||
|
import markdown
|
||||||
|
return markdown.markdown(self.content)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterSubscription(BaseModel):
|
||||||
|
email = models.EmailField(unique=True, verbose_name='Email')
|
||||||
|
user = models.OneToOneField(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='newsletter_subscription',
|
||||||
|
verbose_name='User'
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name='Active')
|
||||||
|
subscribed_categories = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Subscribed Categories',
|
||||||
|
help_text='List of announcement types to receive'
|
||||||
|
)
|
||||||
|
confirmation_token = models.CharField(max_length=100, blank=True, verbose_name='Confirmation Token')
|
||||||
|
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='Confirmed At')
|
||||||
|
unsubscribe_token = models.CharField(max_length=100, blank=True, verbose_name='Unsubscribe Token')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Newsletter Subscription'
|
||||||
|
verbose_name_plural = 'Newsletter Subscriptions'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.confirmation_token:
|
||||||
|
import uuid
|
||||||
|
self.confirmation_token = str(uuid.uuid4())
|
||||||
|
if not self.unsubscribe_token:
|
||||||
|
import uuid
|
||||||
|
self.unsubscribe_token = str(uuid.uuid4())
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotificationDevice(BaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='push_devices',
|
||||||
|
verbose_name='User'
|
||||||
|
)
|
||||||
|
device_token = models.TextField(verbose_name='Device Token')
|
||||||
|
device_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=[
|
||||||
|
('web', 'Web'),
|
||||||
|
('android', 'Android'),
|
||||||
|
('ios', 'iOS'),
|
||||||
|
],
|
||||||
|
verbose_name='Device Type'
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name='Active')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Push Notification Device'
|
||||||
|
verbose_name_plural = 'Push Notification Devices'
|
||||||
|
unique_together = ['user', 'device_token']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.device_type}"
|
||||||
194
apps/communications/push_notifications.py
Normal file
194
apps/communications/push_notifications.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
|
||||||
|
from apps.communications.models import PushNotificationDevice
|
||||||
|
from apps.events.models import Registration
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotificationService:
|
||||||
|
"""Service for handling web push notifications"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
|
||||||
|
self.vapid_public_key = getattr(settings, 'VAPID_PUBLIC_KEY', None)
|
||||||
|
self.vapid_claims = getattr(settings, 'VAPID_CLAIMS', {})
|
||||||
|
|
||||||
|
def send_notification(
|
||||||
|
self,
|
||||||
|
subscription_info: Dict[str, Any],
|
||||||
|
data: Dict[str, Any],
|
||||||
|
ttl: int = 86400
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send a push notification to a single device
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subscription_info: Device subscription information
|
||||||
|
data: Notification payload
|
||||||
|
ttl: Time to live in seconds (default 24 hours)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=json.dumps(data),
|
||||||
|
vapid_private_key=self.vapid_private_key,
|
||||||
|
vapid_claims=self.vapid_claims,
|
||||||
|
ttl=ttl
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except WebPushException as e:
|
||||||
|
logger.error(f"Push notification failed: {e}")
|
||||||
|
if e.response and e.response.status_code in [410, 413]:
|
||||||
|
# Subscription is no longer valid, should be removed
|
||||||
|
self._remove_invalid_subscription(subscription_info)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error sending push notification: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_to_multiple(
|
||||||
|
self,
|
||||||
|
devices: List[PushNotificationDevice],
|
||||||
|
data: Dict[str, Any],
|
||||||
|
ttl: int = 86400
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Send push notification to multiple devices
|
||||||
|
|
||||||
|
Args:
|
||||||
|
devices: List of PushNotificationDevice objects
|
||||||
|
data: Notification payload
|
||||||
|
ttl: Time to live in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Statistics of sent/failed notifications
|
||||||
|
"""
|
||||||
|
stats = {'sent': 0, 'failed': 0}
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
subscription_info = {
|
||||||
|
'endpoint': device.endpoint,
|
||||||
|
'keys': {
|
||||||
|
'p256dh': device.p256dh_key,
|
||||||
|
'auth': device.auth_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.send_notification(subscription_info, data, ttl):
|
||||||
|
stats['sent'] += 1
|
||||||
|
else:
|
||||||
|
stats['failed'] += 1
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def send_announcement_notification(
|
||||||
|
self,
|
||||||
|
announcement,
|
||||||
|
devices: Optional[List[PushNotificationDevice]] = None
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Send push notification for an announcement
|
||||||
|
|
||||||
|
Args:
|
||||||
|
announcement: Announcement model instance
|
||||||
|
devices: Optional list of specific devices to send to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Statistics of sent/failed notifications
|
||||||
|
"""
|
||||||
|
if devices is None:
|
||||||
|
# Get devices based on announcement audience
|
||||||
|
if announcement.audience == 'all':
|
||||||
|
devices = PushNotificationDevice.objects.filter(is_active=True)
|
||||||
|
elif announcement.audience == 'members':
|
||||||
|
devices = PushNotificationDevice.objects.filter(
|
||||||
|
user__is_member=True,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
elif announcement.audience == 'committee':
|
||||||
|
devices = PushNotificationDevice.objects.filter(
|
||||||
|
user__is_committee_member=True,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
devices = PushNotificationDevice.objects.none()
|
||||||
|
|
||||||
|
# Prepare notification data
|
||||||
|
data = {
|
||||||
|
'title': announcement.title,
|
||||||
|
'body': announcement.content[:100] + '...' if len(announcement.content) > 100 else announcement.content,
|
||||||
|
'icon': '/static/images/logo.png',
|
||||||
|
'badge': '/static/images/badge.png',
|
||||||
|
'data': {
|
||||||
|
'type': 'announcement',
|
||||||
|
'id': announcement.id,
|
||||||
|
'url': f'/announcements/{announcement.id}/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.send_to_multiple(devices, data)
|
||||||
|
|
||||||
|
def send_event_reminder_notification(
|
||||||
|
self,
|
||||||
|
event,
|
||||||
|
devices: Optional[List[PushNotificationDevice]] = None
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Send push notification for event reminder
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Event model instance
|
||||||
|
devices: Optional list of specific devices to send to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Statistics of sent/failed notifications
|
||||||
|
"""
|
||||||
|
if devices is None:
|
||||||
|
# Get devices of registered users
|
||||||
|
registered_users = Registration.objects.filter(
|
||||||
|
event=event,
|
||||||
|
status='confirmed'
|
||||||
|
).values_list('user_id', flat=True)
|
||||||
|
|
||||||
|
devices = PushNotificationDevice.objects.filter(
|
||||||
|
user_id__in=registered_users,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare notification data
|
||||||
|
data = {
|
||||||
|
'title': f'Event Reminder: {event.title}',
|
||||||
|
'body': f'Your event "{event.title}" starts in 24 hours!',
|
||||||
|
'icon': '/static/images/logo.png',
|
||||||
|
'badge': '/static/images/badge.png',
|
||||||
|
'data': {
|
||||||
|
'type': 'event_reminder',
|
||||||
|
'id': event.id,
|
||||||
|
'url': f'/events/{event.id}/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.send_to_multiple(devices, data)
|
||||||
|
|
||||||
|
def _remove_invalid_subscription(self, subscription_info: Dict[str, Any]):
|
||||||
|
"""Remove invalid subscription from database"""
|
||||||
|
try:
|
||||||
|
PushNotificationDevice.objects.filter(
|
||||||
|
endpoint=subscription_info['endpoint']
|
||||||
|
).delete()
|
||||||
|
logger.info(f"Removed invalid subscription: {subscription_info['endpoint']}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing invalid subscription: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Create a singleton instance
|
||||||
|
push_service = PushNotificationService()
|
||||||
56
apps/communications/resources.py
Normal file
56
apps/communications/resources.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from import_export import resources, fields
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from apps.communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementResource(resources.ModelResource):
|
||||||
|
author = fields.Field(
|
||||||
|
column_name='author',
|
||||||
|
attribute='author',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Announcement
|
||||||
|
fields = (
|
||||||
|
'id', 'title', 'content', 'announcement_type', 'priority',
|
||||||
|
'author', 'is_published', 'publish_date', 'send_email', 'send_push',
|
||||||
|
'target_audience', 'created_at', 'updated_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterSubscriptionResource(resources.ModelResource):
|
||||||
|
user = fields.Field(
|
||||||
|
column_name='user',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = NewsletterSubscription
|
||||||
|
fields = (
|
||||||
|
'id', 'email', 'user', 'is_active', 'subscribed_categories',
|
||||||
|
'confirmed_at', 'created_at', 'updated_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotificationDeviceResource(resources.ModelResource):
|
||||||
|
user = fields.Field(
|
||||||
|
column_name='user',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PushNotificationDevice
|
||||||
|
fields = (
|
||||||
|
'id', 'user', 'device_type', 'is_active', 'created_at', 'updated_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
278
apps/communications/tasks.py
Normal file
278
apps/communications/tasks.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from celery import shared_task
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.communications.models import Announcement, NewsletterSubscription
|
||||||
|
from apps.communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients
|
||||||
|
from apps.communications.push_notifications import push_service
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
SYSTEM_USER_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def send_announcement_notifications(self, announcement_id):
|
||||||
|
"""Send email and push notifications for an announcement"""
|
||||||
|
try:
|
||||||
|
announcement = Announcement.objects.get(id=announcement_id)
|
||||||
|
|
||||||
|
# Send email notifications
|
||||||
|
if announcement.send_email and not announcement.email_sent:
|
||||||
|
recipients = get_announcement_recipients(announcement)
|
||||||
|
if recipients:
|
||||||
|
success = send_announcement_email(announcement, recipients)
|
||||||
|
if success:
|
||||||
|
announcement.email_sent = True
|
||||||
|
announcement.save()
|
||||||
|
logger.info(f"Email notifications sent for announcement {announcement.id}")
|
||||||
|
|
||||||
|
# Send push notifications
|
||||||
|
if announcement.send_push and not announcement.push_sent:
|
||||||
|
sent_count = push_service.send_announcement_notification(announcement)
|
||||||
|
if sent_count > 0:
|
||||||
|
announcement.push_sent = True
|
||||||
|
announcement.save()
|
||||||
|
logger.info(f"Push notifications sent to {sent_count} devices for announcement {announcement.id}")
|
||||||
|
|
||||||
|
return f"Notifications sent for announcement: {announcement.title}"
|
||||||
|
|
||||||
|
except Announcement.DoesNotExist:
|
||||||
|
logger.error(f"Announcement {announcement_id} not found")
|
||||||
|
return f"Announcement {announcement_id} not found"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send announcement notifications: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def send_newsletter_confirmation_task(self, subscription_id):
|
||||||
|
"""Send newsletter confirmation email"""
|
||||||
|
try:
|
||||||
|
from .utils import send_newsletter_confirmation
|
||||||
|
|
||||||
|
subscription = NewsletterSubscription.objects.get(id=subscription_id)
|
||||||
|
success = send_newsletter_confirmation(subscription)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Newsletter confirmation sent to {subscription.email}")
|
||||||
|
return f"Newsletter confirmation sent to {subscription.email}"
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to send newsletter confirmation")
|
||||||
|
|
||||||
|
except NewsletterSubscription.DoesNotExist:
|
||||||
|
logger.error(f"Newsletter subscription {subscription_id} not found")
|
||||||
|
return f"Newsletter subscription {subscription_id} not found"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send newsletter confirmation: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_event_reminders():
|
||||||
|
"""Send reminders for events starting about 24 hours from now within a 30-minute window."""
|
||||||
|
try:
|
||||||
|
reminder_target = timezone.now() + timedelta(hours=24)
|
||||||
|
window = timedelta(minutes=30)
|
||||||
|
start_range = reminder_target - window
|
||||||
|
end_range = reminder_target + window
|
||||||
|
|
||||||
|
events = Event.objects.filter(
|
||||||
|
start_time__range=(start_range, end_range),
|
||||||
|
status='published',
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
total_sent = 0
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
# Get confirmed registrations
|
||||||
|
registrations = Registration.objects.filter(
|
||||||
|
event=event,
|
||||||
|
status='confirmed',
|
||||||
|
is_deleted=False
|
||||||
|
).select_related('user')
|
||||||
|
|
||||||
|
for registration in registrations:
|
||||||
|
try:
|
||||||
|
# Send email reminder
|
||||||
|
send_event_reminder(event, registration.user)
|
||||||
|
|
||||||
|
# Send push notification reminder
|
||||||
|
push_service.send_event_reminder_notification(event, registration.user)
|
||||||
|
|
||||||
|
total_sent += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send reminder to {registration.user.email}: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"Event reminders sent to {total_sent} users")
|
||||||
|
return f"Event reminders sent to {total_sent} users"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send event reminders: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_weekly_newsletter():
|
||||||
|
"""Send the weekly newsletter as the system user with recent announcements and upcoming events."""
|
||||||
|
try:
|
||||||
|
# Get active newsletter subscribers
|
||||||
|
subscribers = NewsletterSubscription.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
confirmed_at__isnull=False,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not subscribers.exists():
|
||||||
|
logger.info("No active newsletter subscribers found")
|
||||||
|
return "No active newsletter subscribers found"
|
||||||
|
|
||||||
|
# Get recent announcements (last 7 days)
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
recent_announcements = Announcement.objects.filter(
|
||||||
|
is_published=True,
|
||||||
|
publish_date__gte=week_ago,
|
||||||
|
announcement_type__in=['general', 'academic', 'newsletter'],
|
||||||
|
is_deleted=False
|
||||||
|
).order_by('-publish_date')[:5]
|
||||||
|
|
||||||
|
# Get upcoming events (next 14 days)
|
||||||
|
two_weeks_ahead = timezone.now() + timedelta(days=14)
|
||||||
|
upcoming_events = Event.objects.filter(
|
||||||
|
start_time__range=(timezone.now(), two_weeks_ahead),
|
||||||
|
status='published',
|
||||||
|
is_deleted=False
|
||||||
|
).order_by('start_time')[:5]
|
||||||
|
|
||||||
|
newsletter_content = f"""
|
||||||
|
# Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}
|
||||||
|
|
||||||
|
## Recent Announcements
|
||||||
|
"""
|
||||||
|
|
||||||
|
for announcement in recent_announcements:
|
||||||
|
newsletter_content += f"- **{announcement.title}** ({announcement.publish_date.strftime('%B %d')})\n"
|
||||||
|
|
||||||
|
newsletter_content += "\n## Upcoming Events\n"
|
||||||
|
|
||||||
|
for event in upcoming_events:
|
||||||
|
newsletter_content += f"- **{event.title}** - {event.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
|
||||||
|
|
||||||
|
if not recent_announcements.exists() and not upcoming_events.exists():
|
||||||
|
newsletter_content += "\nNo recent announcements or upcoming events this week."
|
||||||
|
|
||||||
|
newsletter = Announcement.objects.create(
|
||||||
|
title=f"Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}",
|
||||||
|
content=newsletter_content,
|
||||||
|
announcement_type='newsletter',
|
||||||
|
priority='normal',
|
||||||
|
author_id=SYSTEM_USER_ID,
|
||||||
|
is_published=True,
|
||||||
|
publish_date=timezone.now(),
|
||||||
|
send_email=True,
|
||||||
|
target_audience='subscribers'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send to subscribers
|
||||||
|
subscriber_emails = list(subscribers.values_list('email', flat=True))
|
||||||
|
success = send_announcement_email(newsletter, subscriber_emails)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
newsletter.email_sent = True
|
||||||
|
newsletter.save()
|
||||||
|
logger.info(f"Weekly newsletter sent to {len(subscriber_emails)} subscribers")
|
||||||
|
return f"Weekly newsletter sent to {len(subscriber_emails)} subscribers"
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to send weekly newsletter")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send weekly newsletter: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_expired_tokens():
|
||||||
|
"""Clean up expired newsletter confirmation tokens"""
|
||||||
|
try:
|
||||||
|
# Remove unconfirmed subscriptions older than 7 days
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
expired_subscriptions = NewsletterSubscription.objects.filter(
|
||||||
|
confirmed_at__isnull=True,
|
||||||
|
created_at__lt=week_ago
|
||||||
|
)
|
||||||
|
|
||||||
|
count = expired_subscriptions.count()
|
||||||
|
expired_subscriptions.delete()
|
||||||
|
|
||||||
|
logger.info(f"Cleaned up {count} expired newsletter subscriptions")
|
||||||
|
return f"Cleaned up {count} expired newsletter subscriptions"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to cleanup expired tokens: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_bulk_announcement(announcement_id, recipient_emails):
|
||||||
|
"""Send announcement to a specific list of recipients"""
|
||||||
|
try:
|
||||||
|
announcement = Announcement.objects.get(id=announcement_id)
|
||||||
|
|
||||||
|
# Split recipients into batches to avoid overwhelming the email server
|
||||||
|
batch_size = 50
|
||||||
|
total_sent = 0
|
||||||
|
|
||||||
|
for i in range(0, len(recipient_emails), batch_size):
|
||||||
|
batch = recipient_emails[i:i + batch_size]
|
||||||
|
success = send_announcement_email(announcement, batch)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
total_sent += len(batch)
|
||||||
|
logger.info(f"Sent announcement to batch of {len(batch)} recipients")
|
||||||
|
|
||||||
|
# Small delay between batches
|
||||||
|
import time
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
logger.info(f"Bulk announcement sent to {total_sent} recipients")
|
||||||
|
return f"Bulk announcement sent to {total_sent} recipients"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send bulk announcement: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_scheduled_announcements():
|
||||||
|
"""Process announcements scheduled for publication"""
|
||||||
|
try:
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
# Get announcements scheduled for publication
|
||||||
|
scheduled_announcements = Announcement.objects.filter(
|
||||||
|
is_published=True,
|
||||||
|
publish_date__lte=now,
|
||||||
|
email_sent=False,
|
||||||
|
send_email=True,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
|
for announcement in scheduled_announcements:
|
||||||
|
# Send notifications
|
||||||
|
send_announcement_notifications.delay(announcement.id)
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
|
logger.info(f"Processed {processed_count} scheduled announcements")
|
||||||
|
return f"Processed {processed_count} scheduled announcements"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to process scheduled announcements: {exc}")
|
||||||
|
raise exc
|
||||||
140
apps/communications/utils.py
Normal file
140
apps/communications/utils.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apps.communications.models import NewsletterSubscription
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def send_announcement_email(announcement, recipients):
|
||||||
|
"""Send announcement email to recipients"""
|
||||||
|
try:
|
||||||
|
template_name = f'emails/announcement_email.html'
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'announcement': announcement,
|
||||||
|
'unsubscribe_url': f"{settings.FRONTEND_ROOT}newsletter/unsubscribe/",
|
||||||
|
'manage_subscription_url': f"{settings.FRONTEND_ROOT}newsletter/manage-subscription",
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string(template_name, context)
|
||||||
|
plain_message = strip_tags(html_message)
|
||||||
|
|
||||||
|
subject = f"انجمن علمی کامپیوتر گیلان | {announcement.title}"
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=recipients,
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Announcement email sent to {len(recipients)} recipients")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send announcement email: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_newsletter_confirmation(subscription):
|
||||||
|
"""Send newsletter confirmation email"""
|
||||||
|
try:
|
||||||
|
template_name = f'emails/newsletter_confirmation.html'
|
||||||
|
|
||||||
|
confirmation_url = f"{settings.FRONTEND_ROOT}confirm-subscription/{subscription.confirmation_token}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'subscription': subscription,
|
||||||
|
'confirmation_url': confirmation_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string(template_name, context)
|
||||||
|
plain_message = strip_tags(html_message)
|
||||||
|
|
||||||
|
subject = "تأیید اشتراک خبرنامه"
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[subscription.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Newsletter confirmation sent to {subscription.email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send newsletter confirmation: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_event_reminder(event, user):
|
||||||
|
"""Send event reminder email"""
|
||||||
|
try:
|
||||||
|
template_name = f'emails/event_reminder.html'
|
||||||
|
|
||||||
|
event_url = f"{settings.FRONTEND_ROOT}events/{event.slug}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'event': event,
|
||||||
|
'user': user,
|
||||||
|
'event_url': event_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string(template_name, context)
|
||||||
|
plain_message = strip_tags(html_message)
|
||||||
|
|
||||||
|
subject = f"یادآوری رویداد: {event.title}"
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Event reminder sent to {user.email} for event {event.title}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send event reminder: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_announcement_recipients(announcement):
|
||||||
|
"""Get list of email addresses based on announcement target audience"""
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
recipients = []
|
||||||
|
|
||||||
|
if announcement.target_audience == 'all':
|
||||||
|
# All users with email
|
||||||
|
recipients = list(User.objects.filter(email__isnull=False).values_list('email', flat=True))
|
||||||
|
|
||||||
|
elif announcement.target_audience == 'members':
|
||||||
|
# Only members (users with is_member=True)
|
||||||
|
recipients = list(User.objects.filter(is_member=True, email__isnull=False).values_list('email', flat=True))
|
||||||
|
|
||||||
|
elif announcement.target_audience == 'committee':
|
||||||
|
# Only committee members
|
||||||
|
recipients = list(User.objects.filter(is_committee=True, email__isnull=False).values_list('email', flat=True))
|
||||||
|
|
||||||
|
elif announcement.target_audience == 'subscribers':
|
||||||
|
# Only newsletter subscribers
|
||||||
|
recipients = list(NewsletterSubscription.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
confirmed_at__isnull=False
|
||||||
|
).values_list('email', flat=True))
|
||||||
|
|
||||||
|
return recipients
|
||||||
418
apps/events/admin.py
Normal file
418
apps/events/admin.py
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
from core.templatetags.jalali import jdate
|
||||||
|
from unfold.decorators import action as unfold_action
|
||||||
|
|
||||||
|
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
from apps.events.models import Event, Registration, EventEmailLog
|
||||||
|
from apps.events.resources import EventResource, RegistrationResource
|
||||||
|
from apps.events.tasks import (
|
||||||
|
queue_skyroom_credentials,
|
||||||
|
send_skyroom_credentials_individual_task,
|
||||||
|
send_event_reminder_task,
|
||||||
|
queue_event_announcement,
|
||||||
|
queue_invites_to_non_registered_users,
|
||||||
|
)
|
||||||
|
from apps.events.admin_forms import AnnouncementForm
|
||||||
|
from apps.events.tasks import _send_html_email
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Event)
|
||||||
|
class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = EventResource
|
||||||
|
list_display = (
|
||||||
|
'title', 'event_type', 'start_time_display', 'end_time_display', 'status',
|
||||||
|
'price_display', 'capacity_display', 'attendees_display', 'is_registration_open_display'
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
'event_type', 'status', 'is_deleted',
|
||||||
|
'start_time', 'end_time', 'registration_start_date', 'registration_end_date',
|
||||||
|
SoftDeleteListFilter
|
||||||
|
)
|
||||||
|
search_fields = ('title', 'description', 'address')
|
||||||
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
|
date_hierarchy = 'start_time'
|
||||||
|
filter_horizontal = ('gallery_images',)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Event Details', {
|
||||||
|
'fields': ('title', 'slug', 'description', 'featured_image')
|
||||||
|
}),
|
||||||
|
('Timing & Type', {
|
||||||
|
'fields': ('start_time', 'end_time', 'event_type', 'status')
|
||||||
|
}),
|
||||||
|
('Location & Online', {
|
||||||
|
'fields': ('address', 'location', 'online_link'),
|
||||||
|
'description': 'For On-Site or Hybrid events, provide address and select on map. For Online events, provide a link.'
|
||||||
|
}),
|
||||||
|
('Registration & Pricing', {
|
||||||
|
'fields': ('capacity', 'price', 'registration_start_date', 'registration_end_date', 'registration_success_markdown'),
|
||||||
|
'description': 'Leave capacity blank for unlimited. Leave price blank for free events.'
|
||||||
|
}),
|
||||||
|
('Gallery', {
|
||||||
|
'fields': ('gallery_images',),
|
||||||
|
'description': 'Add images related to this event from the Gallery app.'
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ('deleted_at',)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + [
|
||||||
|
'make_published',
|
||||||
|
'make_draft',
|
||||||
|
'make_cancelled',
|
||||||
|
'make_completed',
|
||||||
|
'restore_events',
|
||||||
|
]
|
||||||
|
|
||||||
|
actions_row = [
|
||||||
|
'action_send_announcement',
|
||||||
|
'action_send_reminder_now',
|
||||||
|
'action_send_skyroom_credentials',
|
||||||
|
'action_invite_other_users',
|
||||||
|
]
|
||||||
|
|
||||||
|
@admin.display(description="Price")
|
||||||
|
def price_display(self, obj):
|
||||||
|
return obj.price if obj.price is not None else "رایگان"
|
||||||
|
|
||||||
|
@admin.display(description="Start")
|
||||||
|
def start_time_display(self, obj):
|
||||||
|
return jdate(obj.start_time)
|
||||||
|
|
||||||
|
@admin.display(description="End")
|
||||||
|
def end_time_display(self, obj):
|
||||||
|
return jdate(obj.end_time)
|
||||||
|
|
||||||
|
@admin.display(description="Capacity")
|
||||||
|
def capacity_display(self, obj):
|
||||||
|
return obj.capacity if obj.capacity is not None else "نامحدود"
|
||||||
|
|
||||||
|
@admin.display(description="Attendees")
|
||||||
|
def attendees_display(self, obj):
|
||||||
|
return obj.current_attendees_count
|
||||||
|
|
||||||
|
@admin.display(description="Open", boolean=True)
|
||||||
|
def is_registration_open_display(self, obj):
|
||||||
|
return obj.is_registration_open
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected events as published")
|
||||||
|
def make_published(self, request, queryset):
|
||||||
|
queryset.update(status=Event.StatusChoices.PUBLISHED)
|
||||||
|
self.message_user(request, f"Published {queryset.count()} events.")
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected events as draft")
|
||||||
|
def make_draft(self, request, queryset):
|
||||||
|
queryset.update(status=Event.StatusChoices.DRAFT)
|
||||||
|
self.message_user(request, f"Marked {queryset.count()} events as draft.")
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected events as cancelled")
|
||||||
|
def make_cancelled(self, request, queryset):
|
||||||
|
queryset.update(status=Event.StatusChoices.CANCELLED)
|
||||||
|
self.message_user(request, f"Cancelled {queryset.count()} events.")
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected events as completed")
|
||||||
|
def make_completed(self, request, queryset):
|
||||||
|
queryset.update(status=Event.StatusChoices.COMPLETED)
|
||||||
|
self.message_user(request, f"Marked {queryset.count()} events as completed.")
|
||||||
|
|
||||||
|
@admin.action(description="Restore selected events")
|
||||||
|
def restore_events(self, request, queryset):
|
||||||
|
for event in queryset:
|
||||||
|
event.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} events.")
|
||||||
|
|
||||||
|
@unfold_action(description="Send Skyroom Credentials")
|
||||||
|
def action_send_skyroom_credentials(self, request, object_id: int):
|
||||||
|
event = Event.objects.get(pk=object_id)
|
||||||
|
queue_skyroom_credentials.delay(event.pk)
|
||||||
|
self.message_user(request, f"ارسال مشخصات اسکایروم برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||||
|
|
||||||
|
@unfold_action(description="Send new Reminder")
|
||||||
|
def action_send_reminder_now(self, request, object_id: int):
|
||||||
|
event = Event.objects.get(pk=object_id)
|
||||||
|
send_event_reminder_task.delay(event.pk)
|
||||||
|
self.message_user(request, f"یادآوری برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||||
|
|
||||||
|
@unfold_action(description="send new Announcement")
|
||||||
|
def action_send_announcement(self, request, object_id: int):
|
||||||
|
"""
|
||||||
|
این اکشن یک فرم میگیرد (عنوان/متن/وضعیتها) و با تمپلیت Unfold نشان داده میشود.
|
||||||
|
"""
|
||||||
|
form = AnnouncementForm(request.POST or None)
|
||||||
|
event = Event.objects.get(pk=object_id)
|
||||||
|
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
subject = form.cleaned_data["subject"]
|
||||||
|
body_html = form.cleaned_data["body_html"]
|
||||||
|
statuses = form.cleaned_data["statuses"] or None
|
||||||
|
queue_event_announcement.delay(event.pk, subject, body_html, statuses=statuses)
|
||||||
|
self.message_user(request, f"اطلاعیه برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
"title": "ارسال اطلاعیه گروهی",
|
||||||
|
"opts": self.model._meta,
|
||||||
|
"form": form,
|
||||||
|
"action_name": "action_send_announcement",
|
||||||
|
"action_checkbox_name": ACTION_CHECKBOX_NAME,
|
||||||
|
}
|
||||||
|
return TemplateResponse(request, "forms/admin_announcement.html", context)
|
||||||
|
|
||||||
|
@unfold_action(description="Invite other users")
|
||||||
|
def action_invite_other_users(self, request, object_id: int):
|
||||||
|
event = Event.objects.get(pk=object_id)
|
||||||
|
queue_invites_to_non_registered_users.delay(event.pk)
|
||||||
|
self.message_user(request, f"دعوت برای شرکت در رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Registration)
|
||||||
|
class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = RegistrationResource
|
||||||
|
list_display = (
|
||||||
|
'user',
|
||||||
|
'event',
|
||||||
|
'status',
|
||||||
|
'registered_at',
|
||||||
|
'ticket_id',
|
||||||
|
'discount_code',
|
||||||
|
'discount_amount',
|
||||||
|
'final_price',
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
'status',
|
||||||
|
'event',
|
||||||
|
'is_deleted',
|
||||||
|
'registered_at',
|
||||||
|
SoftDeleteListFilter
|
||||||
|
)
|
||||||
|
search_fields = ('user__username', 'user__email', 'user__first_name', 'user__last_name', 'event__title', 'ticket_id')
|
||||||
|
readonly_fields = (
|
||||||
|
'ticket_id',
|
||||||
|
'registered_at',
|
||||||
|
'confirmation_email_sent_at',
|
||||||
|
'cancellation_email_sent_at',
|
||||||
|
'discount_code',
|
||||||
|
'discount_amount',
|
||||||
|
'final_price',
|
||||||
|
'deleted_at',
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
'Registration Details',
|
||||||
|
{
|
||||||
|
'fields': (
|
||||||
|
'user',
|
||||||
|
'event',
|
||||||
|
'status',
|
||||||
|
'registered_at',
|
||||||
|
'ticket_id',
|
||||||
|
'confirmation_email_sent_at',
|
||||||
|
'cancellation_email_sent_at',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Pricing & Discount',
|
||||||
|
{
|
||||||
|
'fields': ('discount_code', 'discount_amount', 'final_price'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + [
|
||||||
|
'confirm_registrations',
|
||||||
|
'cancel_registrations',
|
||||||
|
'mark_attended',
|
||||||
|
'restore_registrations',
|
||||||
|
]
|
||||||
|
actions_row = [
|
||||||
|
'action_email_selected',
|
||||||
|
'action_send_skyroom_credentials',
|
||||||
|
]
|
||||||
|
|
||||||
|
@admin.action(description="Confirm selected registrations")
|
||||||
|
def confirm_registrations(self, request, queryset):
|
||||||
|
queryset.update(status=Registration.StatusChoices.CONFIRMED)
|
||||||
|
self.message_user(request, f"Confirmed {queryset.count()} registrations.")
|
||||||
|
|
||||||
|
@admin.action(description="Cancel selected registrations")
|
||||||
|
def cancel_registrations(self, request, queryset):
|
||||||
|
queryset.update(status=Registration.StatusChoices.CANCELLED)
|
||||||
|
self.message_user(request, f"Cancelled {queryset.count()} registrations.")
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected registrations as attended")
|
||||||
|
def mark_attended(self, request, queryset):
|
||||||
|
queryset.update(status=Registration.StatusChoices.ATTENDED)
|
||||||
|
self.message_user(request, f"Marked {queryset.count()} registrations as attended.")
|
||||||
|
|
||||||
|
@admin.action(description="Restore selected registrations")
|
||||||
|
def restore_registrations(self, request, queryset):
|
||||||
|
for registration in queryset:
|
||||||
|
registration.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} registrations.")
|
||||||
|
|
||||||
|
@unfold_action(description="send email to registrated user")
|
||||||
|
def action_email_selected(self, request, object_id: int):
|
||||||
|
"""
|
||||||
|
همان فرم اطلاعیه را میگیرد و به افراد انتخابشده ایمیل میزند.
|
||||||
|
برای نمایش فرم، از تمپلیت Unfold استفاده میکنیم.
|
||||||
|
"""
|
||||||
|
form = AnnouncementForm(request.POST or None)
|
||||||
|
registration = Registration.objects.get(id=object_id)
|
||||||
|
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
subject = form.cleaned_data["subject"]
|
||||||
|
body_html = form.cleaned_data["body_html"]
|
||||||
|
|
||||||
|
user = registration.user
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": registration.event,
|
||||||
|
"body_html": body_html,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{registration.event.slug}",
|
||||||
|
}
|
||||||
|
html = render_to_string("emails/event_announcement.html", ctx)
|
||||||
|
_send_html_email(subject, html, user.email)
|
||||||
|
|
||||||
|
self.message_user(request, f"ارسال ایمیل انجام شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_registration_changelist"))
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
"title": "ارسال ایمیل به ثبتنامهای انتخابشده",
|
||||||
|
"form": AnnouncementForm(),
|
||||||
|
"opts": self.model._meta,
|
||||||
|
"action_name": "action_email_selected",
|
||||||
|
"action_checkbox_name": ACTION_CHECKBOX_NAME,
|
||||||
|
}
|
||||||
|
return TemplateResponse(request, "forms/admin_announcement.html", context)
|
||||||
|
|
||||||
|
@unfold_action(description="Send Skyroom Credentials")
|
||||||
|
def action_send_skyroom_credentials(self, request, object_id: int):
|
||||||
|
send_skyroom_credentials_individual_task.delay(object_id)
|
||||||
|
self.message_user(request, f"ارسال مشخصات اسکایروم به کاربر مربوطه صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_registration_changelist"))
|
||||||
|
|
||||||
|
|
||||||
|
from apps.events.tasks import send_invite_to_user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EventEmailLog)
|
||||||
|
class EventEmailLogAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"event",
|
||||||
|
"user",
|
||||||
|
"user_email",
|
||||||
|
"kind",
|
||||||
|
"status",
|
||||||
|
"sent_at",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"kind",
|
||||||
|
"status",
|
||||||
|
"event",
|
||||||
|
("sent_at", admin.EmptyFieldListFilter),
|
||||||
|
("error", admin.EmptyFieldListFilter),
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"user__email",
|
||||||
|
"user__username",
|
||||||
|
"user__first_name",
|
||||||
|
"user__last_name",
|
||||||
|
"event__title",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("event", "user")
|
||||||
|
date_hierarchy = "created_at"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
list_per_page = 50
|
||||||
|
list_select_related = ("event", "user")
|
||||||
|
|
||||||
|
# چون این مدل برای ایدمپوتنسی حیاتی است، ویرایش دستی را محدود میکنیم
|
||||||
|
readonly_fields = (
|
||||||
|
"event",
|
||||||
|
"user",
|
||||||
|
"kind",
|
||||||
|
"status",
|
||||||
|
"error",
|
||||||
|
"sent_at",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
fields = readonly_fields
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + [
|
||||||
|
'resend_selected_emails'
|
||||||
|
]
|
||||||
|
|
||||||
|
@admin.display(description="Email", ordering="user__email")
|
||||||
|
def user_email(self, obj):
|
||||||
|
return obj.user.email or "—"
|
||||||
|
|
||||||
|
@admin.action(description="ارسال مجدد ایمیل برای رکوردهای انتخابشده")
|
||||||
|
def resend_selected_emails(self, request, queryset):
|
||||||
|
"""
|
||||||
|
رکوردهای SENT را اسکیپ میکند، بقیه را به وضعیت pending برمیگرداند
|
||||||
|
و تسک ارسال تکی را در صف میگذارد (ایدِمپوتنت).
|
||||||
|
"""
|
||||||
|
queued = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for log in queryset.select_related("event", "user"):
|
||||||
|
if log.status == EventEmailLog.STATUS_SENT:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# برگرداندن به pending و پاک کردن خطا
|
||||||
|
if log.status != EventEmailLog.STATUS_PENDING or log.error:
|
||||||
|
log.status = EventEmailLog.STATUS_PENDING
|
||||||
|
log.error = ""
|
||||||
|
log.save(update_fields=["status", "error", "updated_at"])
|
||||||
|
|
||||||
|
# صف کردن تسک اتمی
|
||||||
|
send_invite_to_user.delay(log.event_id, log.user_id)
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
if queued:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"%(n)d مورد در صف ارسال قرار گرفت." % {"n": queued},
|
||||||
|
level=messages.SUCCESS,
|
||||||
|
)
|
||||||
|
if skipped:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"%(n)d مورد قبلاً ارسال شده بود و نادیده گرفته شد." % {"n": skipped},
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
25
apps/events/admin_forms.py
Normal file
25
apps/events/admin_forms.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
from unfold.widgets import UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget
|
||||||
|
|
||||||
|
from apps.events.models import Registration
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementForm(forms.Form):
|
||||||
|
subject = forms.CharField(
|
||||||
|
label="Subject",
|
||||||
|
max_length=200,
|
||||||
|
widget=UnfoldAdminTextInputWidget,
|
||||||
|
)
|
||||||
|
body_html = forms.CharField(
|
||||||
|
label="Text (HTML or plain-text)",
|
||||||
|
widget=UnfoldAdminTextareaWidget,
|
||||||
|
help_text="you can enter either HTML or plain-text."
|
||||||
|
)
|
||||||
|
statuses = forms.MultipleChoiceField(
|
||||||
|
label="Statuses to sent",
|
||||||
|
required=False,
|
||||||
|
choices=Registration.StatusChoices.choices,
|
||||||
|
initial=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED],
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
)
|
||||||
0
apps/events/api/__init__.py
Normal file
0
apps/events/api/__init__.py
Normal file
247
apps/events/api/schemas.py
Normal file
247
apps/events/api/schemas.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""Event and gallery API schemas."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
from ninja import ModelSchema, Schema
|
||||||
|
from pydantic import field_validator
|
||||||
|
from typing import Literal, Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from apps.blog.api.schemas import AuthorSchema
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.gallery.models import Gallery
|
||||||
|
from apps.payments.models import Payment
|
||||||
|
|
||||||
|
|
||||||
|
class EventGallerySchema(ModelSchema):
|
||||||
|
"""Schema representing gallery items associated with an event."""
|
||||||
|
uploaded_by: AuthorSchema
|
||||||
|
file_size_mb: float
|
||||||
|
markdown_url: str
|
||||||
|
absolute_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Gallery
|
||||||
|
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
|
||||||
|
'width', 'height', 'is_public', 'created_at']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_absolute_image_url(obj, context):
|
||||||
|
request = context['request']
|
||||||
|
if obj.image and hasattr(obj.image, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.image.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
class EventSchema(ModelSchema):
|
||||||
|
"""Schema providing full event details for API responses."""
|
||||||
|
gallery_images: List[EventGallerySchema]
|
||||||
|
description_html: str
|
||||||
|
registration_count: int
|
||||||
|
absolute_featured_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Event
|
||||||
|
model_fields = [
|
||||||
|
'id', 'title', 'slug', 'description', 'featured_image', 'event_type',
|
||||||
|
'address', 'location', 'online_link', 'start_time', 'end_time',
|
||||||
|
'registration_start_date', 'registration_end_date', 'registration_success_markdown',
|
||||||
|
'capacity', 'price', 'status', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_absolute_featured_image_url(obj, context):
|
||||||
|
request = context['request']
|
||||||
|
if obj.featured_image and hasattr(obj.featured_image, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.featured_image.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_registration_count(obj):
|
||||||
|
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_description_html(obj):
|
||||||
|
return obj.description_html
|
||||||
|
|
||||||
|
|
||||||
|
class EventListSchema(Schema):
|
||||||
|
"""Condensed event representation for list endpoints."""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
slug: str
|
||||||
|
featured_image: Optional[str] = None
|
||||||
|
absolute_featured_image_url: Optional[str] = None
|
||||||
|
event_type: str
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
registration_start_date: Optional[datetime] = None
|
||||||
|
registration_end_date: Optional[datetime] = None
|
||||||
|
capacity: Optional[int] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
status: str
|
||||||
|
registration_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_absolute_featured_image_url(obj, context):
|
||||||
|
request = context['request']
|
||||||
|
if obj.featured_image and hasattr(obj.featured_image, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.featured_image.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_registration_count(obj):
|
||||||
|
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||||
|
|
||||||
|
class EventCreateSchema(Schema):
|
||||||
|
"""Payload for creating events via the API."""
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
event_type: str
|
||||||
|
address: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
online_link: Optional[str] = None
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
registration_start_date: Optional[datetime] = None
|
||||||
|
registration_end_date: Optional[datetime] = None
|
||||||
|
capacity: Optional[int] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
status: str = "draft"
|
||||||
|
gallery_image_ids: Optional[List[int]] = []
|
||||||
|
|
||||||
|
class EventUpdateSchema(Schema):
|
||||||
|
"""Payload for updating events via the API."""
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_type: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
online_link: Optional[str] = None
|
||||||
|
start_time: Optional[datetime] = None
|
||||||
|
end_time: Optional[datetime] = None
|
||||||
|
registration_start_date: Optional[datetime] = None
|
||||||
|
registration_end_date: Optional[datetime] = None
|
||||||
|
capacity: Optional[int] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
gallery_image_ids: Optional[List[int]] = None
|
||||||
|
|
||||||
|
class RegistrationSchema(ModelSchema):
|
||||||
|
"""Schema describing a registration entry with event context."""
|
||||||
|
user: AuthorSchema
|
||||||
|
event: EventListSchema
|
||||||
|
discount_code: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Registration
|
||||||
|
model_fields = [
|
||||||
|
'id',
|
||||||
|
'status',
|
||||||
|
'registered_at',
|
||||||
|
'ticket_id',
|
||||||
|
'discount_amount',
|
||||||
|
'final_price',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_discount_code(obj):
|
||||||
|
return obj.discount_code.code if obj.discount_code else None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserSchema(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentAdminSchema(Schema):
|
||||||
|
id: int
|
||||||
|
authority: Optional[str]
|
||||||
|
ref_id: Optional[str]
|
||||||
|
status: int
|
||||||
|
status_label: str
|
||||||
|
base_amount: int
|
||||||
|
discount_amount: int
|
||||||
|
amount: int
|
||||||
|
verified_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
discount_code: Optional[str]
|
||||||
|
user: AdminUserSchema
|
||||||
|
|
||||||
|
@field_validator("discount_code", mode="before")
|
||||||
|
def normalize_discount_code(cls, value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if hasattr(value, "code"):
|
||||||
|
return value.code
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationAdminSchema(Schema):
|
||||||
|
id: int
|
||||||
|
ticket_id: UUID
|
||||||
|
status: str
|
||||||
|
status_label: str
|
||||||
|
registered_at: datetime
|
||||||
|
final_price: Optional[int]
|
||||||
|
discount_amount: Optional[int]
|
||||||
|
user: AdminUserSchema
|
||||||
|
payments: List[PaymentAdminSchema]
|
||||||
|
|
||||||
|
|
||||||
|
class EventAdminDetailSchema(EventSchema):
|
||||||
|
registrations: List[RegistrationAdminSchema] = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_registrations(obj):
|
||||||
|
return obj.registrations.select_related("user").prefetch_related(
|
||||||
|
"payments__discount_code"
|
||||||
|
).order_by("-registered_at")
|
||||||
|
|
||||||
|
class PaginatedRegistrationSchema(Schema):
|
||||||
|
count: int
|
||||||
|
next: Optional[str] = None
|
||||||
|
previous: Optional[str] = None
|
||||||
|
results: List[RegistrationAdminSchema]
|
||||||
|
|
||||||
|
class RegistrationStatusUpdateSchema(Schema):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
class RegisterationDetailSchema(Schema):
|
||||||
|
"""Detailed registration information with associated event metadata."""
|
||||||
|
event_image: Optional[str]
|
||||||
|
event_title: str
|
||||||
|
event_type: str
|
||||||
|
ticket_id: UUID
|
||||||
|
status: str
|
||||||
|
registered_at: datetime
|
||||||
|
success_markdown: Optional[str]
|
||||||
|
|
||||||
|
class EventBriefSchema(Schema):
|
||||||
|
"""Minimal event representation used for nested responses."""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
slug: str
|
||||||
|
start_date: datetime
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
price: int
|
||||||
|
absolute_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
class MyEventRegistrationOut(Schema):
|
||||||
|
"""Registration information as returned to authenticated users."""
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
status: Literal["pending", "confirmed", "cancelled", "attended"]
|
||||||
|
event: EventBriefSchema
|
||||||
|
|
||||||
|
class RegistrationStatusOut(Schema):
|
||||||
|
is_registered: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationCreateSchema(Schema):
|
||||||
|
discount_code: Optional[str] = None
|
||||||
370
apps/events/api/views.py
Normal file
370
apps/events/api/views.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Q, Case, When, IntegerField
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ninja import Router, Query
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from apps.events.api.schemas import (
|
||||||
|
EventAdminDetailSchema,
|
||||||
|
EventBriefSchema,
|
||||||
|
EventCreateSchema,
|
||||||
|
EventListSchema,
|
||||||
|
EventSchema,
|
||||||
|
EventUpdateSchema,
|
||||||
|
MyEventRegistrationOut,
|
||||||
|
PaginatedRegistrationSchema,
|
||||||
|
RegisterationDetailSchema,
|
||||||
|
RegistrationCreateSchema,
|
||||||
|
RegistrationSchema,
|
||||||
|
RegistrationStatusOut,
|
||||||
|
RegistrationStatusUpdateSchema,
|
||||||
|
)
|
||||||
|
from core.authentication import jwt_auth
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.payments.models import DiscountCode
|
||||||
|
from core.api.schemas import ErrorSchema, MessageSchema
|
||||||
|
|
||||||
|
events_router = Router()
|
||||||
|
|
||||||
|
# Event endpoints
|
||||||
|
@events_router.get("/", response=List[EventListSchema])
|
||||||
|
def list_events(
|
||||||
|
request,
|
||||||
|
# status: Optional[str] = None,
|
||||||
|
status: Optional[List[str]] = Query(None),
|
||||||
|
event_type: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""List events with filtering and pagination"""
|
||||||
|
queryset = Event.objects.filter(is_deleted=False).prefetch_related('gallery_images')
|
||||||
|
|
||||||
|
if status:
|
||||||
|
if "," in status:
|
||||||
|
parts = [s.strip() for s in status.split(",") if s.strip()]
|
||||||
|
queryset = queryset.filter(status__in=parts)
|
||||||
|
else:
|
||||||
|
queryset = queryset.filter(status__in=status)
|
||||||
|
if event_type:
|
||||||
|
queryset = queryset.filter(event_type=event_type)
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(title__icontains=search) | Q(description__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
published_first=Case(
|
||||||
|
When(status='published', then=0),
|
||||||
|
default=1,
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
).order_by('published_first', '-start_time', '-id')
|
||||||
|
|
||||||
|
events = queryset[offset:offset + limit]
|
||||||
|
return events
|
||||||
|
|
||||||
|
@events_router.get("/{int:event_id}", response=EventSchema)
|
||||||
|
def get_event(request, event_id: int):
|
||||||
|
"""Get event details by ID"""
|
||||||
|
event = get_object_or_404(
|
||||||
|
Event.objects.prefetch_related('gallery_images'),
|
||||||
|
id=event_id,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
@events_router.get("/slug/{str:slug}", response=EventSchema)
|
||||||
|
def get_event_by_slug(request, slug: str):
|
||||||
|
"""Get event details by slug"""
|
||||||
|
event = get_object_or_404(
|
||||||
|
Event.objects.prefetch_related('gallery_images'),
|
||||||
|
slug=slug,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
@events_router.post("/", response=EventSchema)
|
||||||
|
def create_event(request, payload: EventCreateSchema):
|
||||||
|
"""Create a new event"""
|
||||||
|
gallery_image_ids = payload.dict().pop('gallery_image_ids', [])
|
||||||
|
event = Event.objects.create(**payload.dict(exclude={'gallery_image_ids'}))
|
||||||
|
|
||||||
|
if gallery_image_ids:
|
||||||
|
event.gallery_images.set(gallery_image_ids)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
@events_router.put("/{int:event_id}", response=EventSchema)
|
||||||
|
def update_event(request, event_id: int, payload: EventUpdateSchema):
|
||||||
|
"""Update an existing event"""
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
|
||||||
|
update_data = payload.dict(exclude_unset=True)
|
||||||
|
gallery_image_ids = update_data.pop('gallery_image_ids', None)
|
||||||
|
|
||||||
|
for attr, value in update_data.items():
|
||||||
|
setattr(event, attr, value)
|
||||||
|
|
||||||
|
if 'title' in update_data:
|
||||||
|
event.slug = slugify(event.title)
|
||||||
|
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
if gallery_image_ids is not None:
|
||||||
|
event.gallery_images.set(gallery_image_ids)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
@events_router.delete("/{int:event_id}", response=MessageSchema)
|
||||||
|
def delete_event(request, event_id: int):
|
||||||
|
"""Soft delete an event"""
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
event.delete()
|
||||||
|
return {"message": "Event deleted successfully"}
|
||||||
|
|
||||||
|
# Registration endpoints
|
||||||
|
@events_router.get("/{int:event_id}/registrations", response=List[RegistrationSchema])
|
||||||
|
def list_event_registrations(request, event_id: int, limit: int = 20, offset: int = 0):
|
||||||
|
"""List registrations for a specific event"""
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
queryset = event.registrations.filter(is_deleted=False).select_related('user')
|
||||||
|
|
||||||
|
registrations = queryset[offset:offset + limit]
|
||||||
|
return registrations
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.get("/{int:event_id}/admin-registrations", response={200: PaginatedRegistrationSchema, 403: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def list_event_registrations_admin(
|
||||||
|
request,
|
||||||
|
event_id: int,
|
||||||
|
status: Optional[List[str]] = Query(None),
|
||||||
|
university: Optional[str] = Query(None),
|
||||||
|
major: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(20, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
"""List registrations with filters for admin dashboard"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_superuser):
|
||||||
|
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||||
|
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
qs = (
|
||||||
|
event.registrations.filter(is_deleted=False)
|
||||||
|
.select_related("user")
|
||||||
|
.prefetch_related("payments__discount_code")
|
||||||
|
.order_by("-registered_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
status_values = status or request.GET.getlist('status')
|
||||||
|
if status_values:
|
||||||
|
qs = qs.filter(status__in=status_values)
|
||||||
|
|
||||||
|
if university:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(user__university__code__icontains=university)
|
||||||
|
| Q(user__university__name__icontains=university)
|
||||||
|
)
|
||||||
|
|
||||||
|
if major:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(user__major__code__icontains=major)
|
||||||
|
| Q(user__major__name__icontains=major)
|
||||||
|
)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(user__username__icontains=search)
|
||||||
|
| Q(user__email__icontains=search)
|
||||||
|
| Q(user__first_name__icontains=search)
|
||||||
|
| Q(user__last_name__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
results = qs[offset : offset + limit]
|
||||||
|
|
||||||
|
return PaginatedRegistrationSchema(count=total, next=None, previous=None, results=list(results))
|
||||||
|
|
||||||
|
@events_router.post(
|
||||||
|
"/{int:event_id}/register",
|
||||||
|
response=RegistrationSchema,
|
||||||
|
auth=jwt_auth,
|
||||||
|
)
|
||||||
|
def register_for_event(
|
||||||
|
request,
|
||||||
|
event_id: int,
|
||||||
|
payload: RegistrationCreateSchema | None = None,
|
||||||
|
):
|
||||||
|
"""Register current user for an event"""
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists():
|
||||||
|
raise HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید.")
|
||||||
|
|
||||||
|
if event.registration_end_date and event.registration_end_date < timezone.now():
|
||||||
|
raise HttpError(400, "مهلت ثبتنام به پایان رسیدهاست")
|
||||||
|
|
||||||
|
if event.registration_start_date and event.registration_start_date > timezone.now():
|
||||||
|
raise HttpError(400, "زمان ثبتنام هنوز آغاز نشده است")
|
||||||
|
|
||||||
|
if not event.has_available_slots:
|
||||||
|
raise HttpError(400, "ظرفیت شرکتکنندگان تکمیل است")
|
||||||
|
|
||||||
|
# Create or get existing registration
|
||||||
|
discount_code = None
|
||||||
|
if payload and payload.discount_code:
|
||||||
|
discount_code = payload.discount_code
|
||||||
|
elif request.GET.get("discount_code"):
|
||||||
|
discount_code = request.GET.get("discount_code")
|
||||||
|
|
||||||
|
registration, created = Registration.objects.get_or_create(
|
||||||
|
event=event,
|
||||||
|
user=user,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
defaults={"final_price": event.price},
|
||||||
|
)
|
||||||
|
|
||||||
|
if registration.status == Registration.StatusChoices.CONFIRMED:
|
||||||
|
return HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید")
|
||||||
|
|
||||||
|
if registration.status == Registration.StatusChoices.CANCELLED:
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=user,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
final_price=event.price,
|
||||||
|
)
|
||||||
|
elif not created and registration.final_price is None:
|
||||||
|
registration.final_price = event.price
|
||||||
|
registration.save(update_fields=["final_price"])
|
||||||
|
|
||||||
|
applied_code = None
|
||||||
|
discount_amount = 0
|
||||||
|
final_price = event.price
|
||||||
|
fields_to_update = []
|
||||||
|
|
||||||
|
if discount_code:
|
||||||
|
applied_code = DiscountCode.objects.filter(
|
||||||
|
code=discount_code,
|
||||||
|
applicable_events=event,
|
||||||
|
is_active=True,
|
||||||
|
).first()
|
||||||
|
if not applied_code:
|
||||||
|
raise HttpError(400, "UcO_ O<>OrU?UOU? U.O1O<31>O\"O<EFBFBD> U+UOO3O<33>")
|
||||||
|
final_price, discount_amount = applied_code.calculate_discount(event, user)
|
||||||
|
registration.discount_code = applied_code
|
||||||
|
registration.discount_amount = discount_amount
|
||||||
|
fields_to_update.extend(["discount_code", "discount_amount"])
|
||||||
|
|
||||||
|
if registration.final_price != final_price:
|
||||||
|
registration.final_price = final_price
|
||||||
|
fields_to_update.append("final_price")
|
||||||
|
|
||||||
|
if not event.price or final_price == 0:
|
||||||
|
registration.status = Registration.StatusChoices.CONFIRMED
|
||||||
|
fields_to_update.append("status")
|
||||||
|
|
||||||
|
if fields_to_update:
|
||||||
|
registration.save(update_fields=list(set(fields_to_update)))
|
||||||
|
|
||||||
|
return registration
|
||||||
|
|
||||||
|
@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth)
|
||||||
|
def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema):
|
||||||
|
"""Update registration status"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
||||||
|
registration.status = payload.dict(exclude_unset=True).get('status')
|
||||||
|
registration.full_clean()
|
||||||
|
registration.save()
|
||||||
|
|
||||||
|
return registration
|
||||||
|
|
||||||
|
@events_router.delete("/registrations/{int:registration_id}", response=MessageSchema, auth=jwt_auth)
|
||||||
|
def cancel_registration(request, registration_id: int):
|
||||||
|
"""Cancel a registration"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
||||||
|
registration.delete()
|
||||||
|
return {"message": "ثبتنام شما لغو شد :("}
|
||||||
|
|
||||||
|
@events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth)
|
||||||
|
def verify_my_registration(request, ticket_id: UUID):
|
||||||
|
try:
|
||||||
|
reg = Registration.objects.select_related("event").get(ticket_id=ticket_id, user=request.auth)
|
||||||
|
return {
|
||||||
|
"event_image": request.build_absolute_uri(reg.event.featured_image.url) if reg.event.featured_image else None,
|
||||||
|
"event_title": reg.event.title,
|
||||||
|
"event_type": reg.event.get_event_type_display(),
|
||||||
|
"ticket_id": reg.ticket_id,
|
||||||
|
"status": reg.status,
|
||||||
|
"registered_at": reg.registered_at,
|
||||||
|
"success_markdown": reg.event.registration_success_markdown,
|
||||||
|
}
|
||||||
|
except Registration.DoesNotExist:
|
||||||
|
raise HttpError(404, "registration not found")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.get("/my-registrations", response=List[MyEventRegistrationOut], auth=jwt_auth)
|
||||||
|
def my_registrations(request):
|
||||||
|
qs = (
|
||||||
|
Registration.objects
|
||||||
|
.filter(user=request.auth)
|
||||||
|
.select_related("event")
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
out: List[MyEventRegistrationOut] = []
|
||||||
|
for r in qs:
|
||||||
|
out.append(
|
||||||
|
MyEventRegistrationOut(
|
||||||
|
id=r.id,
|
||||||
|
created_at=r.created_at,
|
||||||
|
status=r.status,
|
||||||
|
event=EventBriefSchema(
|
||||||
|
id=r.event.id,
|
||||||
|
title=r.event.title,
|
||||||
|
slug=r.event.slug,
|
||||||
|
start_date=r.event.start_time,
|
||||||
|
end_date=r.event.end_time,
|
||||||
|
location=r.event.location,
|
||||||
|
price=r.event.price,
|
||||||
|
absolute_image_url=request.build_absolute_uri(r.event.featured_image.url) if r.event.featured_image else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth)
|
||||||
|
def is_registered(request, event_id: int):
|
||||||
|
exists = Registration.objects.filter(
|
||||||
|
user=request.auth,
|
||||||
|
event_id=event_id,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED
|
||||||
|
).exists()
|
||||||
|
return {"is_registered": exists}
|
||||||
|
@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth)
|
||||||
|
def event_admin_detail(request, event_id: int):
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_superuser):
|
||||||
|
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||||
|
|
||||||
|
event = get_object_or_404(
|
||||||
|
Event.objects.prefetch_related(
|
||||||
|
'gallery_images',
|
||||||
|
'registrations__user',
|
||||||
|
'registrations__payments__discount_code'
|
||||||
|
),
|
||||||
|
id=event_id,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
return event
|
||||||
6
apps/events/apps.py
Normal file
6
apps/events/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class EventsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.events"
|
||||||
379
apps/events/fixtures/events.json
Normal file
379
apps/events/fixtures/events.json
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-28T10:00:00Z",
|
||||||
|
"updated_at": "2024-02-28T10:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "کارگاه یادگیری ماشین پیشرفته",
|
||||||
|
"slug": "advanced-machine-learning-workshop",
|
||||||
|
"description": "# کارگاه یادگیری ماشین پیشرفته\n\nدر این کارگاه با تکنیکهای پیشرفته یادگیری ماشین آشنا خواهید شد.\n\n## سرفصلها:\n- Deep Learning\n- Neural Networks\n- TensorFlow و Keras\n- پروژه عملی\n\n## پیشنیازها:\n- آشنایی با پایتون\n- دانش پایه ریاضی\n- تجربه کار با NumPy",
|
||||||
|
"start_time": "2024-03-15T14:00:00Z",
|
||||||
|
"end_time": "2024-03-15T18:00:00Z",
|
||||||
|
"event_type": "on_site",
|
||||||
|
"address": "سالن کنفرانس دانشکده مهندسی کامپیوتر",
|
||||||
|
"location": "35.7219,51.3890",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 50,
|
||||||
|
"price": "150000.00",
|
||||||
|
"registration_start_date": "2024-03-01T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-03-14T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-02T09:00:00Z",
|
||||||
|
"updated_at": "2024-03-02T09:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "مسابقه برنامهنویسی بهاری",
|
||||||
|
"slug": "spring-programming-contest",
|
||||||
|
"description": "# مسابقه برنامهنویسی بهاری\n\nمسابقهای هیجانانگیز برای تمامی علاقهمندان به برنامهنویسی\n\n## جوایز:\n- نفر اول: ۵ میلیون تومان\n- نفر دوم: ۳ میلیون تومان \n- نفر سوم: ۲ میلیون تومان\n\n## قوانین:\n- مسابقه انفرادی\n- مدت زمان: ۳ ساعت\n- ۸ مسئله الگوریتمی\n- زبانهای مجاز: C++, Java, Python",
|
||||||
|
"start_time": "2024-03-22T09:00:00Z",
|
||||||
|
"end_time": "2024-03-22T12:00:00Z",
|
||||||
|
"event_type": "on_site",
|
||||||
|
"address": "آزمایشگاه کامپیوتر شماره ۱",
|
||||||
|
"location": "35.7225,51.3885",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 80,
|
||||||
|
"price": null,
|
||||||
|
"registration_start_date": "2024-03-05T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-03-20T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-08T11:00:00Z",
|
||||||
|
"updated_at": "2024-03-08T11:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "وبینار امنیت سایبری",
|
||||||
|
"slug": "cybersecurity-webinar",
|
||||||
|
"description": "# وبینار امنیت سایبری\n\nآشنایی با آخرین تهدیدات سایبری و روشهای مقابله\n\n## موضوعات:\n- تهدیدات جدید سایبری\n- روشهای حفاظت\n- ابزارهای امنیتی\n- مطالعه موردی حملات\n\n## مدرس:\nدکتر محمد رضایی - متخصص امنیت سایبری",
|
||||||
|
"start_time": "2024-03-28T19:00:00Z",
|
||||||
|
"end_time": "2024-03-28T21:00:00Z",
|
||||||
|
"event_type": "online",
|
||||||
|
"online_link": "https://meet.google.com/abc-defg-hij",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 200,
|
||||||
|
"price": null,
|
||||||
|
"registration_start_date": "2024-03-10T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-03-27T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-18T14:00:00Z",
|
||||||
|
"updated_at": "2024-03-18T14:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "کارگاه React.js و Next.js",
|
||||||
|
"slug": "reactjs-nextjs-workshop",
|
||||||
|
"description": "# کارگاه React.js و Next.js\n\nآموزش کامل توسعه وب مدرن با React و Next.js\n\n## محتوای کارگاه:\n- مبانی React.js\n- Hooks و State Management\n- Next.js و SSR\n- پروژه عملی\n\n## مدرس:\nمهندس امیر قربانی - توسعهدهنده فولاستک",
|
||||||
|
"start_time": "2024-04-05T13:00:00Z",
|
||||||
|
"end_time": "2024-04-05T17:00:00Z",
|
||||||
|
"event_type": "hybrid",
|
||||||
|
"address": "کلاس ۲۰۵ ساختمان مهندسی کامپیوتر",
|
||||||
|
"location": "35.7230,51.3880",
|
||||||
|
"online_link": "https://zoom.us/j/123456789",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 40,
|
||||||
|
"price": "200000.00",
|
||||||
|
"registration_start_date": "2024-03-20T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-04-04T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-22T16:00:00Z",
|
||||||
|
"updated_at": "2024-03-22T16:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "بازدید از شرکت دیجیکالا",
|
||||||
|
"slug": "digikala-company-visit",
|
||||||
|
"description": "# بازدید از شرکت دیجیکالا\n\nبازدید علمی از یکی از بزرگترین شرکتهای فناوری کشور\n\n## برنامه بازدید:\n- آشنایی با ساختار شرکت\n- بازدید از بخشهای مختلف\n- گفتگو با مهندسان\n- معرفی فرصتهای شغلی\n\n## نکات مهم:\n- حمل و نقل رایگان\n- ناهار در محل\n- اهدای هدایای تبلیغاتی",
|
||||||
|
"start_time": "2024-04-12T08:00:00Z",
|
||||||
|
"end_time": "2024-04-12T16:00:00Z",
|
||||||
|
"event_type": "on_site",
|
||||||
|
"address": "شرکت دیجیکالا، تهران",
|
||||||
|
"location": "35.7580,51.4100",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 30,
|
||||||
|
"price": null,
|
||||||
|
"registration_start_date": "2024-03-25T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-04-10T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-30T12:00:00Z",
|
||||||
|
"updated_at": "2024-03-30T12:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "هکاتون هوش مصنوعی",
|
||||||
|
"slug": "ai-hackathon",
|
||||||
|
"description": "# هکاتون هوش مصنوعی\n\nرقابت ۴۸ ساعته برای ساخت پروژههای هوش مصنوعی\n\n## موضوعات:\n- پردازش زبان طبیعی\n- بینایی کامپیوتر\n- یادگیری تقویتی\n- هوش مصنوعی در پزشکی\n\n## جوایز:\n- تیم اول: ۱۰ میلیون تومان\n- تیم دوم: ۶ میلیون تومان\n- تیم سوم: ۴ میلیون تومان\n\n## امکانات:\n- غذا و نوشیدنی رایگان\n- فضای کار ۲۴ ساعته\n- منتورینگ توسط اساتید",
|
||||||
|
"start_time": "2024-04-19T18:00:00Z",
|
||||||
|
"end_time": "2024-04-21T18:00:00Z",
|
||||||
|
"event_type": "on_site",
|
||||||
|
"address": "مرکز نوآوری دانشگاه",
|
||||||
|
"location": "35.7200,51.3900",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 60,
|
||||||
|
"price": "100000.00",
|
||||||
|
"registration_start_date": "2024-04-01T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-04-17T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-08T15:00:00Z",
|
||||||
|
"updated_at": "2024-04-08T15:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "سمینار کارآفرینی فناوری",
|
||||||
|
"slug": "tech-entrepreneurship-seminar",
|
||||||
|
"description": "# سمینار کارآفرینی فناوری\n\nآشنایی با دنیای کارآفرینی و استارتاپهای فناوری\n\n## سخنرانان:\n- دکتر علی احمدی - موسس استارتاپ تپسی\n- خانم سارا محمدی - مدیرعامل کافهبازار\n- مهندس رضا کریمی - سرمایهگذار فرشته\n\n## موضوعات:\n- ایدهیابی و اعتبارسنجی\n- تیمسازی\n- جذب سرمایه\n- بازاریابی دیجیتال",
|
||||||
|
"start_time": "2024-04-26T14:00:00Z",
|
||||||
|
"end_time": "2024-04-26T18:00:00Z",
|
||||||
|
"event_type": "hybrid",
|
||||||
|
"address": "آمفیتئاتر مرکزی دانشگاه",
|
||||||
|
"location": "35.7210,51.3895",
|
||||||
|
"online_link": "https://meet.google.com/xyz-uvw-rst",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 150,
|
||||||
|
"price": null,
|
||||||
|
"registration_start_date": "2024-04-10T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-04-25T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-12T13:00:00Z",
|
||||||
|
"updated_at": "2024-04-12T13:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "کارگاه DevOps و Docker",
|
||||||
|
"slug": "devops-docker-workshop",
|
||||||
|
"description": "# کارگاه DevOps و Docker\n\nآموزش عملی ابزارهای DevOps و کانتینریزیشن\n\n## سرفصلها:\n- مقدمهای بر DevOps\n- Docker و Containerization\n- Docker Compose\n- CI/CD Pipeline\n- Kubernetes مقدماتی\n\n## پیشنیازها:\n- آشنایی با Linux\n- تجربه کار با Terminal\n- دانش پایه شبکه",
|
||||||
|
"start_time": "2024-05-03T09:00:00Z",
|
||||||
|
"end_time": "2024-05-03T17:00:00Z",
|
||||||
|
"event_type": "on_site",
|
||||||
|
"address": "آزمایشگاه شبکه دانشکده",
|
||||||
|
"location": "35.7215,51.3888",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 25,
|
||||||
|
"price": "300000.00",
|
||||||
|
"registration_start_date": "2024-04-15T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-05-01T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-18T10:00:00Z",
|
||||||
|
"updated_at": "2024-04-18T10:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "مسابقه طراحی UI/UX",
|
||||||
|
"slug": "ui-ux-design-contest",
|
||||||
|
"description": "# مسابقه طراحی UI/UX\n\nرقابت خلاقانه برای طراحی بهترین رابط کاربری\n\n## موضوع مسابقه:\nطراحی اپلیکیشن موبایل برای مدیریت تسکهای دانشجویی\n\n## معیارهای داوری:\n- خلاقیت و نوآوری\n- قابلیت استفاده\n- زیبایی بصری\n- تجربه کاربری\n\n## جوایز:\n- نفر اول: تبلت iPad\n- نفر دوم: هدفون بیسیم\n- نفر سوم: پاوربانک",
|
||||||
|
"start_time": "2024-05-10T10:00:00Z",
|
||||||
|
"end_time": "2024-05-10T18:00:00Z",
|
||||||
|
"event_type": "on_site",
|
||||||
|
"address": "استودیو طراحی دانشکده هنر",
|
||||||
|
"location": "35.7240,51.3870",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 40,
|
||||||
|
"price": "50000.00",
|
||||||
|
"registration_start_date": "2024-04-20T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-05-08T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.event",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-28T17:00:00Z",
|
||||||
|
"updated_at": "2024-04-28T17:00:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "نشست فارغالتحصیلان",
|
||||||
|
"slug": "alumni-meetup",
|
||||||
|
"description": "# نشست فارغالتحصیلان\n\nدیدار با فارغالتحصیلان موفق رشته مهندسی کامپیوتر\n\n## برنامه:\n- معرفی فارغالتحصیلان\n- تجربیات شغلی\n- مشاوره تحصیلی\n- شبکهسازی\n- ضیافت شام\n\n## مهمانان ویژه:\n- دکتر حسن زارع - مدیر فنی گوگل\n- مهندس مریم حسینی - بنیانگذار استارتاپ\n- دکتر امیر قربانی - استاد MIT",
|
||||||
|
"start_time": "2024-05-17T17:00:00Z",
|
||||||
|
"end_time": "2024-05-17T22:00:00Z",
|
||||||
|
"event_type": "on_site",
|
||||||
|
"address": "سالن همایشهای دانشگاه",
|
||||||
|
"location": "35.7205,51.3892",
|
||||||
|
"status": "published",
|
||||||
|
"capacity": 100,
|
||||||
|
"price": null,
|
||||||
|
"registration_start_date": "2024-05-01T00:00:00Z",
|
||||||
|
"registration_end_date": "2024-05-15T23:59:59Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-02T10:30:00Z",
|
||||||
|
"updated_at": "2024-03-02T10:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-02T10:30:00Z",
|
||||||
|
"event": 1,
|
||||||
|
"user": 3,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-03T14:15:00Z",
|
||||||
|
"updated_at": "2024-03-03T14:15:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-03T14:15:00Z",
|
||||||
|
"event": 1,
|
||||||
|
"user": 4,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-06T09:20:00Z",
|
||||||
|
"updated_at": "2024-03-06T09:20:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-06T09:20:00Z",
|
||||||
|
"event": 2,
|
||||||
|
"user": 5,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-07T16:45:00Z",
|
||||||
|
"updated_at": "2024-03-07T16:45:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-07T16:45:00Z",
|
||||||
|
"event": 2,
|
||||||
|
"user": 6,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-12T11:30:00Z",
|
||||||
|
"updated_at": "2024-03-12T11:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-12T11:30:00Z",
|
||||||
|
"event": 3,
|
||||||
|
"user": 7,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-13T13:25:00Z",
|
||||||
|
"updated_at": "2024-03-13T13:25:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-13T13:25:00Z",
|
||||||
|
"event": 3,
|
||||||
|
"user": 8,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-22T15:10:00Z",
|
||||||
|
"updated_at": "2024-03-22T15:10:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-22T15:10:00Z",
|
||||||
|
"event": 4,
|
||||||
|
"user": 9,
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-23T12:40:00Z",
|
||||||
|
"updated_at": "2024-03-23T12:40:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-23T12:40:00Z",
|
||||||
|
"event": 4,
|
||||||
|
"user": 10,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-27T08:55:00Z",
|
||||||
|
"updated_at": "2024-03-27T08:55:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-03-27T08:55:00Z",
|
||||||
|
"event": 5,
|
||||||
|
"user": 11,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-02T14:20:00Z",
|
||||||
|
"updated_at": "2024-04-02T14:20:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-04-02T14:20:00Z",
|
||||||
|
"event": 6,
|
||||||
|
"user": 12,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 11,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-12T10:15:00Z",
|
||||||
|
"updated_at": "2024-04-12T10:15:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-04-12T10:15:00Z",
|
||||||
|
"event": 7,
|
||||||
|
"user": 2,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "events.registration",
|
||||||
|
"pk": 12,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-04-16T16:30:00Z",
|
||||||
|
"updated_at": "2024-04-16T16:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"registered_at": "2024-04-16T16:30:00Z",
|
||||||
|
"event": 8,
|
||||||
|
"user": 1,
|
||||||
|
"status": "confirmed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
60
apps/events/migrations/0001_initial.py
Normal file
60
apps/events/migrations/0001_initial.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
import location_field.models.plain
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Event',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=255, unique=True)),
|
||||||
|
('description', models.TextField(help_text='Event description in Markdown format')),
|
||||||
|
('start_time', models.DateTimeField()),
|
||||||
|
('end_time', models.DateTimeField()),
|
||||||
|
('address', models.CharField(blank=True, help_text='Physical address or venue name', max_length=255, null=True)),
|
||||||
|
('location', location_field.models.plain.PlainLocationField(blank=True, help_text='Select location on map', max_length=63, null=True)),
|
||||||
|
('event_type', models.CharField(choices=[('online', 'آنلاین'), ('on_site', 'حضوری'), ('hybrid', 'آنلاین/حضوری')], default='on_site', max_length=10)),
|
||||||
|
('online_link', models.URLField(blank=True, help_text='Link for online events (e.g., Zoom, Google Meet)', max_length=500, null=True)),
|
||||||
|
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], default='draft', max_length=10)),
|
||||||
|
('capacity', models.PositiveIntegerField(blank=True, help_text='Maximum number of attendees (leave blank for unlimited)', null=True)),
|
||||||
|
('price', models.IntegerField(default=0, help_text='Price of the event. Leave blank for free events.')),
|
||||||
|
('registration_start_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('registration_end_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('featured_image', models.ImageField(blank=True, null=True, upload_to='events/featured/')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['start_time'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Registration',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('registered_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('attended', 'Attended')], default='pending', max_length=10)),
|
||||||
|
('ticket_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['registered_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
27
apps/events/migrations/0002_initial.py
Normal file
27
apps/events/migrations/0002_initial.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0001_initial'),
|
||||||
|
('gallery', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='gallery_images',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Images taken during or related to the event.', related_name='event_galleries', to='gallery.gallery'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='registration',
|
||||||
|
name='event',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='events.event'),
|
||||||
|
),
|
||||||
|
]
|
||||||
39
apps/events/migrations/0003_initial.py
Normal file
39
apps/events/migrations/0003_initial.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0002_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='registration',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_registrations', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='event',
|
||||||
|
index=models.Index(fields=['status', 'start_time'], name='events_even_status_189ced_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='event',
|
||||||
|
index=models.Index(fields=['event_type'], name='events_even_event_t_a87b5c_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='registration',
|
||||||
|
index=models.Index(fields=['event', 'status'], name='events_regi_event_i_c98244_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='registration',
|
||||||
|
index=models.Index(fields=['user'], name='events_regi_user_id_a0262e_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0003_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='registration_success_markdown',
|
||||||
|
field=models.TextField(blank=True, help_text='Optional markdown shown to users after a successful registration.', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 13:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0004_event_registration_success_markdown'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='registration',
|
||||||
|
name='cancellation_email_sent_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='registration',
|
||||||
|
name='confirmation_email_sent_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-25 20:47
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0005_registration_cancellation_email_sent_at_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='event',
|
||||||
|
options={'ordering': ['-start_time']},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='registration',
|
||||||
|
options={'ordering': ['-registered_at']},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EventEmailLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('kind', models.CharField(choices=[('invite_non_registered', 'Invite non-registered users')], max_length=64)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed')], default='pending', max_length=16)),
|
||||||
|
('error', models.TextField(blank=True, null=True)),
|
||||||
|
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_logs', to='events.event')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_logs', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['event', 'kind', 'status'], name='events_even_event_i_d6c2f2_idx'), models.Index(fields=['user', 'kind', 'status'], name='events_even_user_id_67be40_idx')],
|
||||||
|
'unique_together': {('event', 'user', 'kind')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-25 21:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0006_alter_event_options_alter_registration_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='eventemaillog',
|
||||||
|
name='deleted_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='eventemaillog',
|
||||||
|
name='is_deleted',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='eventemaillog',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
apps/events/migrations/0008_alter_eventemaillog_kind.py
Normal file
18
apps/events/migrations/0008_alter_eventemaillog_kind.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-11-05 11:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='eventemaillog',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'send skyroom credentials'), ('send_event_announcement', 'send_event_announcement'), ('send_event_announcement2', 'send_event_announcement2'), ('send_event_announcement3', 'send_event_announcement3')], max_length=64),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2025-11-17 13:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payments', '0002_initial'),
|
||||||
|
('events', '0008_alter_eventemaillog_kind'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='registration',
|
||||||
|
name='discount_amount',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='registration',
|
||||||
|
name='discount_code',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registrations', to='payments.discountcode'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='registration',
|
||||||
|
name='final_price',
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def copy_payment_discounts(apps, schema_editor):
|
||||||
|
Registration = apps.get_model("events", "Registration")
|
||||||
|
Payment = apps.get_model("payments", "Payment")
|
||||||
|
|
||||||
|
payments = (
|
||||||
|
Payment.objects.exclude(discount_code__isnull=True)
|
||||||
|
.select_related("discount_code")
|
||||||
|
.order_by("id")
|
||||||
|
)
|
||||||
|
for payment in payments:
|
||||||
|
registration = (
|
||||||
|
Registration.objects.filter(event_id=payment.event_id, user_id=payment.user_id)
|
||||||
|
.order_by("-registered_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not registration:
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated_fields = []
|
||||||
|
if payment.discount_code_id and not registration.discount_code_id:
|
||||||
|
registration.discount_code_id = payment.discount_code_id
|
||||||
|
updated_fields.append("discount_code")
|
||||||
|
if payment.discount_amount and not registration.discount_amount:
|
||||||
|
registration.discount_amount = payment.discount_amount
|
||||||
|
updated_fields.append("discount_amount")
|
||||||
|
if payment.amount is not None and registration.final_price is None:
|
||||||
|
registration.final_price = payment.amount
|
||||||
|
updated_fields.append("final_price")
|
||||||
|
|
||||||
|
if updated_fields:
|
||||||
|
registration.save(update_fields=updated_fields)
|
||||||
|
|
||||||
|
if payment.registration_id is None:
|
||||||
|
payment.registration_id = registration.id
|
||||||
|
payment.save(update_fields=["registration"])
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_copy_payment_discounts(apps, schema_editor):
|
||||||
|
# No-op for reverse; data retention preferred.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("payments", "0003_payment_registration"),
|
||||||
|
("events", "0009_registration_discount_amount_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(copy_payment_discounts, reverse_copy_payment_discounts),
|
||||||
|
]
|
||||||
22
apps/events/migrations/0011_eventemaillog_context_hash.py
Normal file
22
apps/events/migrations/0011_eventemaillog_context_hash.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-11-17 19:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0010_backfill_registration_discounts'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='eventemaillog',
|
||||||
|
name='context_hash',
|
||||||
|
field=models.CharField(blank=True, max_length=64, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='eventemaillog',
|
||||||
|
unique_together={('event', 'user', 'kind', 'context_hash')},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
apps/events/migrations/0012_alter_eventemaillog_kind.py
Normal file
18
apps/events/migrations/0012_alter_eventemaillog_kind.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2025-11-18 08:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0011_eventemaillog_context_hash'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='eventemaillog',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'Skyroom credentials'), ('send_event_announcement', 'Event announcement'), ('send_event_announcement2', 'Event announcement 2'), ('send_event_announcement3', 'Event announcement 3')], max_length=64),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
apps/events/migrations/0013_alter_eventemaillog_kind.py
Normal file
18
apps/events/migrations/0013_alter_eventemaillog_kind.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2026-05-19 14:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0012_alter_eventemaillog_kind'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='eventemaillog',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'Skyroom credentials'), ('send_event_announcement', 'Event announcement'), ('send_event_announcement2', 'Event announcement 2'), ('send_event_announcement3', 'Event announcement 3'), ('send_event_reminder', 'Event reminder')], max_length=64),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/events/migrations/__init__.py
Normal file
0
apps/events/migrations/__init__.py
Normal file
269
apps/events/models.py
Normal file
269
apps/events/models.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
from location_field.models.plain import PlainLocationField as LocationField
|
||||||
|
|
||||||
|
from core.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Event(BaseModel):
|
||||||
|
class TypeChoices(models.TextChoices):
|
||||||
|
ONLINE = 'online', 'آنلاین'
|
||||||
|
ON_SITE = 'on_site', 'حضوری'
|
||||||
|
HYBRID = 'hybrid', 'آنلاین/حضوری'
|
||||||
|
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
DRAFT = 'draft', 'Draft'
|
||||||
|
PUBLISHED = 'published', 'Published'
|
||||||
|
CANCELLED = 'cancelled', 'Cancelled'
|
||||||
|
COMPLETED = 'completed', 'Completed'
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(max_length=255, unique=True, blank=True)
|
||||||
|
description = models.TextField(help_text="Event description in Markdown format")
|
||||||
|
|
||||||
|
start_time = models.DateTimeField()
|
||||||
|
end_time = models.DateTimeField()
|
||||||
|
|
||||||
|
address = models.CharField(max_length=255, blank=True, null=True, help_text="Physical address or venue name")
|
||||||
|
location = LocationField(based_fields=['address'], zoom=15, blank=True, null=True,
|
||||||
|
help_text="Select location on map")
|
||||||
|
|
||||||
|
event_type = models.CharField(max_length=10, choices=TypeChoices.choices, default=TypeChoices.ON_SITE)
|
||||||
|
online_link = models.URLField(max_length=500, blank=True, null=True,
|
||||||
|
help_text="Link for online events (e.g., Zoom, Google Meet)")
|
||||||
|
|
||||||
|
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
||||||
|
capacity = models.PositiveIntegerField(null=True, blank=True,
|
||||||
|
help_text="Maximum number of attendees (leave blank for unlimited)")
|
||||||
|
|
||||||
|
price = models.IntegerField(default=0, help_text="Price of the event. Leave blank for free events.")
|
||||||
|
|
||||||
|
registration_start_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
registration_end_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
featured_image = models.ImageField(upload_to='events/featured/', null=True, blank=True)
|
||||||
|
gallery_images = models.ManyToManyField('gallery.Gallery', blank=True, related_name='event_galleries',
|
||||||
|
help_text="Images taken during or related to the event.")
|
||||||
|
|
||||||
|
registration_success_markdown = models.TextField(
|
||||||
|
blank=True, null=True,
|
||||||
|
help_text="Optional markdown shown to users after a successful registration."
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-start_time']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', 'start_time']),
|
||||||
|
models.Index(fields=['event_type']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.title)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description_html(self):
|
||||||
|
"""Convert markdown description to HTML"""
|
||||||
|
return markdown.markdown(
|
||||||
|
self.description,
|
||||||
|
extensions=[
|
||||||
|
'markdown.extensions.extra',
|
||||||
|
'markdown.extensions.toc',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_registration_open(self):
|
||||||
|
now = timezone.now()
|
||||||
|
return (self.registration_start_date is None or now >= self.registration_start_date) and \
|
||||||
|
(self.registration_end_date is None or now <= self.registration_end_date)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_attendees_count(self):
|
||||||
|
"""Count confirmed attendees"""
|
||||||
|
return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_available_slots(self):
|
||||||
|
"""Check whether registration slots are available, treating None as unlimited capacity."""
|
||||||
|
if self.capacity is None:
|
||||||
|
return True
|
||||||
|
return self.current_attendees_count < self.capacity
|
||||||
|
|
||||||
|
|
||||||
|
class Registration(BaseModel):
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
PENDING = 'pending', 'Pending'
|
||||||
|
CONFIRMED = 'confirmed', 'Confirmed'
|
||||||
|
CANCELLED = 'cancelled', 'Cancelled'
|
||||||
|
ATTENDED = 'attended', 'Attended'
|
||||||
|
|
||||||
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
|
||||||
|
registered_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
status = models.CharField(max_length=10, choices=StatusChoices.choices,
|
||||||
|
default=StatusChoices.PENDING)
|
||||||
|
ticket_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
|
||||||
|
confirmation_email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
cancellation_email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
discount_code = models.ForeignKey(
|
||||||
|
"payments.DiscountCode",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="registrations",
|
||||||
|
)
|
||||||
|
discount_amount = models.PositiveIntegerField(default=0)
|
||||||
|
final_price = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-registered_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['event', 'status']),
|
||||||
|
models.Index(fields=['user']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} registered for {self.event.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_label(self):
|
||||||
|
"""Human-readable label for the current registration status."""
|
||||||
|
return self.get_status_display()
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# detect create vs update
|
||||||
|
is_create = self._state.adding
|
||||||
|
old_status = None
|
||||||
|
|
||||||
|
if not is_create and self.pk:
|
||||||
|
old_status = (
|
||||||
|
self.__class__.objects.only("status").get(pk=self.pk).status
|
||||||
|
)
|
||||||
|
|
||||||
|
# save first (so we have a pk + final values)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# 1) on create -> send confirmation if pending/confirmed (and not sent before)
|
||||||
|
if is_create and self.status == self.StatusChoices.CONFIRMED and not self.confirmation_email_sent_at:
|
||||||
|
# lazy import to avoid circular import
|
||||||
|
from apps.events.tasks import send_registration_confirmation_email
|
||||||
|
send_registration_confirmation_email.delay(str(self.pk))
|
||||||
|
self.confirmation_email_sent_at = timezone.now()
|
||||||
|
super().save(update_fields=["confirmation_email_sent_at"])
|
||||||
|
|
||||||
|
# 2) status changed -> cancelled
|
||||||
|
if (not is_create) and (old_status != self.StatusChoices.CANCELLED) and (self.status == self.StatusChoices.CANCELLED) and (not self.cancellation_email_sent_at):
|
||||||
|
from apps.events.tasks import send_registration_cancellation_email
|
||||||
|
send_registration_cancellation_email.delay(str(self.pk))
|
||||||
|
self.cancellation_email_sent_at = timezone.now()
|
||||||
|
super().save(update_fields=["cancellation_email_sent_at"])
|
||||||
|
|
||||||
|
# 3) status changed -> confirmed (if not sent before)
|
||||||
|
if (not is_create) and (old_status != self.StatusChoices.CONFIRMED) and (self.status == self.StatusChoices.CONFIRMED) and (not self.confirmation_email_sent_at):
|
||||||
|
from apps.events.tasks import send_registration_confirmation_email
|
||||||
|
send_registration_confirmation_email.delay(str(self.pk))
|
||||||
|
self.confirmation_email_sent_at = timezone.now()
|
||||||
|
super().save(update_fields=["confirmation_email_sent_at"])
|
||||||
|
|
||||||
|
|
||||||
|
class EventEmailLog(BaseModel):
|
||||||
|
class KindChoices(models.TextChoices):
|
||||||
|
INVITE_NON_REGISTERED = "invite_non_registered", "Invite non-registered users"
|
||||||
|
SKYROOM_CREDENTIALS = "send_skyroom_credentials", "Skyroom credentials"
|
||||||
|
EVENT_ANNOUNCEMENT = "send_event_announcement", "Event announcement"
|
||||||
|
EVENT_ANNOUNCEMENT2 = "send_event_announcement2", "Event announcement 2"
|
||||||
|
EVENT_ANNOUNCEMENT3 = "send_event_announcement3", "Event announcement 3"
|
||||||
|
EVENT_REMINDER = "send_event_reminder", "Event reminder"
|
||||||
|
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
SENT = "sent", "Sent"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
|
||||||
|
KIND_INVITE_NON_REGISTERED = KindChoices.INVITE_NON_REGISTERED
|
||||||
|
KIND_SKYROOM_CREDENTIALS = KindChoices.SKYROOM_CREDENTIALS
|
||||||
|
KIND_EVENT_ANNOUNCEMENT = KindChoices.EVENT_ANNOUNCEMENT
|
||||||
|
KIND_EVENT_ANNOUNCEMENT2 = KindChoices.EVENT_ANNOUNCEMENT2
|
||||||
|
KIND_EVENT_ANNOUNCEMENT3 = KindChoices.EVENT_ANNOUNCEMENT3
|
||||||
|
KIND_EVENT_REMINDER = KindChoices.EVENT_REMINDER
|
||||||
|
KIND_CHOICES = KindChoices.choices
|
||||||
|
|
||||||
|
STATUS_PENDING = StatusChoices.PENDING
|
||||||
|
STATUS_SENT = StatusChoices.SENT
|
||||||
|
STATUS_FAILED = StatusChoices.FAILED
|
||||||
|
STATUS_CHOICES = StatusChoices.choices
|
||||||
|
|
||||||
|
event = models.ForeignKey('events.Event', on_delete=models.CASCADE, related_name='email_logs')
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='email_logs')
|
||||||
|
kind = models.CharField(max_length=64, choices=KIND_CHOICES)
|
||||||
|
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||||
|
error = models.TextField(blank=True, null=True)
|
||||||
|
sent_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
context_hash = models.CharField(max_length=64, blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("event", "user", "kind", "context_hash")
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["event", "kind", "status"]),
|
||||||
|
models.Index(fields=["user", "kind", "status"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.event.id} - {self.user.id} - {self.kind} - {self.status}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_context(context):
|
||||||
|
if context is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(context, str):
|
||||||
|
context = str(context)
|
||||||
|
return hashlib.sha256(context.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def claim(cls, *, event_id, user_id, kind, context=None):
|
||||||
|
context_hash = cls._hash_context(context)
|
||||||
|
log, created = cls.objects.get_or_create(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user_id,
|
||||||
|
kind=kind,
|
||||||
|
context_hash=context_hash,
|
||||||
|
defaults={"status": cls.STATUS_PENDING},
|
||||||
|
)
|
||||||
|
if not created and log.status in (cls.STATUS_PENDING, cls.STATUS_SENT):
|
||||||
|
return log, True
|
||||||
|
if not created:
|
||||||
|
log._commit_status(cls.STATUS_PENDING, error="")
|
||||||
|
return log, False
|
||||||
|
|
||||||
|
def _commit_status(self, status, *, error="", sent_at=None):
|
||||||
|
self.status = status
|
||||||
|
self.error = error
|
||||||
|
update_fields = ["status", "error"]
|
||||||
|
if status == self.STATUS_SENT:
|
||||||
|
self.sent_at = sent_at or timezone.now()
|
||||||
|
update_fields.append("sent_at")
|
||||||
|
elif self.sent_at is not None:
|
||||||
|
self.sent_at = None
|
||||||
|
update_fields.append("sent_at")
|
||||||
|
if hasattr(self, "updated_at"):
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
self.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
def mark_sent(self):
|
||||||
|
self._commit_status(self.STATUS_SENT)
|
||||||
|
|
||||||
|
def mark_failed(self, error):
|
||||||
|
self._commit_status(self.STATUS_FAILED, error=error)
|
||||||
86
apps/events/resources.py
Normal file
86
apps/events/resources.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from import_export import resources, fields
|
||||||
|
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||||
|
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.users.models import User
|
||||||
|
from apps.gallery.models import Gallery
|
||||||
|
from apps.payments.models import DiscountCode
|
||||||
|
|
||||||
|
class EventResource(resources.ModelResource):
|
||||||
|
gallery_images = fields.Field(
|
||||||
|
column_name='gallery_images',
|
||||||
|
attribute='gallery_images',
|
||||||
|
widget=ManyToManyWidget(Gallery, field='title', separator='|')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = (
|
||||||
|
'id', 'title', 'slug', 'description', 'start_time', 'end_time',
|
||||||
|
'event_type', 'address', 'location', 'online_link', 'status',
|
||||||
|
'capacity', 'price', 'registration_start_date', 'registration_end_date',
|
||||||
|
'featured_image', 'gallery_images', 'created_at', 'updated_at',
|
||||||
|
'is_deleted', 'deleted_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
class RegistrationResource(resources.ModelResource):
|
||||||
|
"""Export registrations with user attributes and shortened ticket identifiers."""
|
||||||
|
|
||||||
|
event = fields.Field(
|
||||||
|
column_name='event',
|
||||||
|
attribute='event',
|
||||||
|
widget=ForeignKeyWidget(Event, 'title')
|
||||||
|
)
|
||||||
|
user_username = fields.Field(
|
||||||
|
column_name='user_username',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
user_email = fields.Field(
|
||||||
|
column_name='user_email',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'email')
|
||||||
|
)
|
||||||
|
user_first_name = fields.Field(
|
||||||
|
column_name='user_first_name',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'first_name')
|
||||||
|
)
|
||||||
|
user_last_name = fields.Field(
|
||||||
|
column_name='user_last_name',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'last_name')
|
||||||
|
)
|
||||||
|
discount_code = fields.Field(
|
||||||
|
column_name='discount_code',
|
||||||
|
attribute='discount_code',
|
||||||
|
widget=ForeignKeyWidget(DiscountCode, 'code')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Registration
|
||||||
|
fields = (
|
||||||
|
'id',
|
||||||
|
'event',
|
||||||
|
'user_username',
|
||||||
|
'user_email',
|
||||||
|
'user_first_name',
|
||||||
|
'user_last_name',
|
||||||
|
'registered_at',
|
||||||
|
'status',
|
||||||
|
'ticket_id',
|
||||||
|
'discount_code',
|
||||||
|
'discount_amount',
|
||||||
|
'final_price',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'is_deleted',
|
||||||
|
'deleted_at',
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
def dehydrate_ticket_id(self, obj):
|
||||||
|
"""Limit ticket identifiers to eight characters in exports."""
|
||||||
|
val = getattr(obj, 'ticket_id', '')
|
||||||
|
return str(val)[:8] if val else ''
|
||||||
584
apps/events/tasks.py
Normal file
584
apps/events/tasks.py
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from celery import shared_task, group
|
||||||
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
|
import markdown
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apps.users.models import User
|
||||||
|
from apps.events.models import Event, Registration, EventEmailLog
|
||||||
|
from core.templatetags.jalali import fa_digits, jdate
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS = 30
|
||||||
|
ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS = 45
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def send_registration_confirmation_email(self, registration_pk: str):
|
||||||
|
"""Send a registration confirmation email, loading the model lazily to avoid circular imports."""
|
||||||
|
try:
|
||||||
|
from .models import Registration
|
||||||
|
reg = (
|
||||||
|
Registration.objects
|
||||||
|
.select_related("event", "user")
|
||||||
|
.get(pk=registration_pk)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_email = getattr(reg.user, "email", None)
|
||||||
|
if not user_email:
|
||||||
|
return
|
||||||
|
|
||||||
|
success_md = reg.event.registration_success_markdown or ""
|
||||||
|
success_html = markdown.markdown(
|
||||||
|
success_md,
|
||||||
|
extensions=["extra", "sane_lists", "toc"]
|
||||||
|
) if success_md else ""
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": reg.user,
|
||||||
|
"event": reg.event,
|
||||||
|
"registration": reg,
|
||||||
|
"success_html": success_html,
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"تأیید ثبتنام شما در {reg.event.title}"
|
||||||
|
html_body = render_to_string("emails/event_registration_confirmation.html", context)
|
||||||
|
plain_body = strip_tags(html_body)
|
||||||
|
|
||||||
|
message = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=plain_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user_email],
|
||||||
|
)
|
||||||
|
message.attach_alternative(html_body, "text/html")
|
||||||
|
message.send(fail_silently=False)
|
||||||
|
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send event registration email: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def send_registration_cancellation_email(self, registration_pk: str):
|
||||||
|
try:
|
||||||
|
from .models import Registration
|
||||||
|
reg = (
|
||||||
|
Registration.objects
|
||||||
|
.select_related("event", "user")
|
||||||
|
.get(pk=registration_pk)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_email = getattr(reg.user, "email", None)
|
||||||
|
if not user_email:
|
||||||
|
return
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": reg.user,
|
||||||
|
"event": reg.event,
|
||||||
|
"registration": reg,
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"لغو ثبتنام شما در {reg.event.title}"
|
||||||
|
html_body = render_to_string("emails/event_registration_cancellation.html", context)
|
||||||
|
plain_body = strip_tags(html_body)
|
||||||
|
|
||||||
|
message = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=plain_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user_email],
|
||||||
|
)
|
||||||
|
message.attach_alternative(html_body, "text/html")
|
||||||
|
message.send(fail_silently=False)
|
||||||
|
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send event registration email: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_recipients(event, statuses=None, only_verified=True):
|
||||||
|
qs = Registration.objects.filter(event=event, is_deleted=False)
|
||||||
|
if statuses:
|
||||||
|
qs = qs.filter(status__in=statuses)
|
||||||
|
if only_verified:
|
||||||
|
qs = qs.filter(user__is_email_verified=True)
|
||||||
|
|
||||||
|
qs = qs.exclude(user__email__isnull=True).exclude(user__email="")
|
||||||
|
return qs.select_related("user")
|
||||||
|
|
||||||
|
|
||||||
|
def _send_html_email(subject, html_body, to_email):
|
||||||
|
text_body = strip_tags(html_body)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[to_email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_body, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_email_context(*parts):
|
||||||
|
values = [str(part) for part in parts if part not in (None, "")]
|
||||||
|
return "|".join(values) if values else None
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3}, soft_time_limit=60)
|
||||||
|
def send_skyroom_credentials_individual_task(self, reg_id: int):
|
||||||
|
"""
|
||||||
|
ارسال نامکاربری/رمز برای اسکایروم
|
||||||
|
- username = user.email
|
||||||
|
- password = registration.ticket_id[:8]
|
||||||
|
- url = event.online_link (اگر لینک در فیلد online_link ذخیره شده باشد)
|
||||||
|
"""
|
||||||
|
r = Registration.objects.get(pk=reg_id)
|
||||||
|
event = r.event
|
||||||
|
user = r.user
|
||||||
|
sky_user = user.email.strip().split('@')[0]
|
||||||
|
sky_pass = str(r.ticket_id)[:8]
|
||||||
|
skyroom_url = event.online_link
|
||||||
|
try:
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"skyroom_url": skyroom_url,
|
||||||
|
"sky_username": sky_user,
|
||||||
|
"sky_password": sky_pass,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||||
|
}
|
||||||
|
subject = f"اطلاعات دسترسی اسکایروم - {event.title}"
|
||||||
|
html = render_to_string("emails/skyroom_credentials.html", ctx)
|
||||||
|
text_body = strip_tags(html)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html, "text/html")
|
||||||
|
msg.send()
|
||||||
|
logger.info(f'Skyroom Credentials for Event "{event.title}" sent to {user.email}')
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send skyroom credentials email: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def send_event_reminder_task(self, event_id: int):
|
||||||
|
"""
|
||||||
|
یادآوری رویداد (ارسال الان؛ برای ارسال خودکار یک روز قبل، یک beat job بسازید)
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
regs = (
|
||||||
|
_event_recipients(event, statuses=["confirmed", "attended"])
|
||||||
|
.select_related("user", "event")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
reg_ids = list(regs.values_list("id", flat=True))
|
||||||
|
|
||||||
|
job = group(send_event_reminder_to_user.s(event_id, rid) for rid in reg_ids)
|
||||||
|
res = job.apply_async()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Queued %s event reminder emails for event "%s" (group_id=%s)',
|
||||||
|
len(reg_ids),
|
||||||
|
event.title,
|
||||||
|
res.id,
|
||||||
|
)
|
||||||
|
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(Exception,),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_jitter=True,
|
||||||
|
retry_kwargs={"max_retries": 3},
|
||||||
|
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||||
|
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||||
|
)
|
||||||
|
def send_event_reminder_to_user(self, event_id: int, registration_id: int):
|
||||||
|
"""
|
||||||
|
Send reminder email to a single registration; safe to retry without duplicating emails.
|
||||||
|
"""
|
||||||
|
user = None
|
||||||
|
log = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||||
|
user = r.user
|
||||||
|
event = r.event
|
||||||
|
|
||||||
|
to_email = (user.email or "").strip()
|
||||||
|
if not to_email:
|
||||||
|
return {"skipped": True, "status": "no_email"}
|
||||||
|
|
||||||
|
context_key = _build_email_context(
|
||||||
|
"event_reminder",
|
||||||
|
event.slug or event.id,
|
||||||
|
event.start_time,
|
||||||
|
)
|
||||||
|
log, skip = EventEmailLog.claim(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user.id,
|
||||||
|
kind=EventEmailLog.KIND_EVENT_REMINDER,
|
||||||
|
context=context_key,
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
return {"skipped": True, "status": log.status}
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"یادآوری رویداد: {event.title}"
|
||||||
|
html = render_to_string("emails/event_reminder.html", ctx)
|
||||||
|
text_body = strip_tags(html)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[to_email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
log.mark_sent()
|
||||||
|
logger.info('Event reminder for "%s" sent to %s', event.title, to_email)
|
||||||
|
return f"Email sent to {to_email}"
|
||||||
|
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
if log:
|
||||||
|
log.mark_failed("Soft time limit exceeded")
|
||||||
|
logger.warning(
|
||||||
|
"Soft time limit exceeded (event_id=%s, registration_id=%s)",
|
||||||
|
event_id,
|
||||||
|
registration_id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if log:
|
||||||
|
log.mark_failed(str(exc))
|
||||||
|
logger.error(
|
||||||
|
"Failed to send event reminder email: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def queue_event_announcement(self, event_id: int, subject: str, body_html: str, statuses=None):
|
||||||
|
"""
|
||||||
|
تسک مادر: ثبتنامهای هدف را پیدا میکند و برای هر Registration یک تسک کوچک میسازد.
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
|
||||||
|
# محدوده مخاطبان: اگر statuses داده نشد، همان پیشفرض قبلی شما
|
||||||
|
statuses = statuses or ["confirmed", "attended", "pending"]
|
||||||
|
|
||||||
|
regs = (
|
||||||
|
_event_recipients(event, statuses=statuses)
|
||||||
|
.select_related("user", "event")
|
||||||
|
.exclude(user__email__isnull=True)
|
||||||
|
.exclude(user__email="")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
reg_ids = list(regs.values_list("id", flat=True))
|
||||||
|
|
||||||
|
# ساخت group از تسکهای کوچک؛ هر کدام فقط یک ایمیل ارسال میکند
|
||||||
|
job = group(
|
||||||
|
send_event_announcement_to_user.s(event_id, rid, subject, body_html)
|
||||||
|
for rid in reg_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
# اگر نتیجهها لازم نیست: CELERY_TASK_IGNORE_RESULT = True
|
||||||
|
res = job.apply_async()
|
||||||
|
logger.info(
|
||||||
|
'Queued %s event-announcement emails for event "%s" (group_id=%s)',
|
||||||
|
len(reg_ids), event.title, res.id
|
||||||
|
)
|
||||||
|
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(Exception,),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_jitter=True,
|
||||||
|
retry_kwargs={"max_retries": 3},
|
||||||
|
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||||
|
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||||
|
)
|
||||||
|
def send_event_announcement_to_user(self, event_id: int, registration_id: int, subject: str, body_html: str):
|
||||||
|
"""
|
||||||
|
تسک کوچک و اتمی: ارسال ایمیل اعلان رویداد برای یک Registration.
|
||||||
|
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
|
||||||
|
"""
|
||||||
|
user = None
|
||||||
|
log = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# از Registration میگیریم تا یک کوئری کمتر به Event بزنیم
|
||||||
|
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||||
|
user = r.user
|
||||||
|
event = r.event
|
||||||
|
|
||||||
|
context_key = _build_email_context(
|
||||||
|
"event_announcement3",
|
||||||
|
event.slug or event.id,
|
||||||
|
subject,
|
||||||
|
body_html,
|
||||||
|
)
|
||||||
|
log, skip = EventEmailLog.claim(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user.id,
|
||||||
|
kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT3,
|
||||||
|
context=context_key,
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
return {"skipped": True, "status": log.status}
|
||||||
|
|
||||||
|
# کانتکست رندر ایمیل: body_html مستقیم داخل تمپلیت شما اینجکت میشود
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"body_html": body_html,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_to_string("emails/event_announcement.html", ctx)
|
||||||
|
text_body = strip_tags(html)
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
log.mark_sent()
|
||||||
|
|
||||||
|
logger.info('Event announcement for "%s" sent to %s', event.title, user.email)
|
||||||
|
return f"Email sent to {user.email}"
|
||||||
|
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
if log:
|
||||||
|
log.mark_failed("Soft time limit exceeded")
|
||||||
|
logger.warning("Soft time limit exceeded (event_id=%s, registration_id=%s)", event_id, registration_id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if log:
|
||||||
|
log.mark_failed(str(exc))
|
||||||
|
logger.error("Failed to send event announcement email: %s", exc, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _event_url(event):
|
||||||
|
root = getattr(settings, "FRONTEND_ROOT", "/")
|
||||||
|
slug_or_id = getattr(event, "slug", None) or event.id
|
||||||
|
return f"{root}events/{slug_or_id}"
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def queue_invites_to_non_registered_users(self, event_id: int, only_verified=True, only_active=True):
|
||||||
|
"""
|
||||||
|
تسک مادر: فقط کاربرها را پیدا میکند و برای هر نفر یک تسک کوچک میسازد.
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
|
||||||
|
qs = User.objects.all()
|
||||||
|
if only_verified:
|
||||||
|
qs = qs.filter(is_email_verified=True)
|
||||||
|
if only_active:
|
||||||
|
qs = qs.filter(is_active=True)
|
||||||
|
|
||||||
|
# کسانی که برای این ایونت ثبتنام نکردهاند
|
||||||
|
qs = qs.exclude(event_registrations__event_id=event_id) \
|
||||||
|
.exclude(email__isnull=True).exclude(email="") \
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
user_ids = list(qs.values_list("id", flat=True))
|
||||||
|
|
||||||
|
# گَروهِ تسکهای کوچک
|
||||||
|
job = group(send_invite_to_user.s(event_id, uid) for uid in user_ids)
|
||||||
|
res = job.apply_async()
|
||||||
|
return {"event_id": event_id, "queued": len(user_ids), "group_id": res.id}
|
||||||
|
|
||||||
|
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, time_limit=60)
|
||||||
|
def send_invite_to_user(self, event_id: int, user_id: int):
|
||||||
|
"""
|
||||||
|
تسک کوچک و اتمی: برای هر کاربر حداکثر یک ایمیل میفرستد (با لاگ ایدمپوتنسی).
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
user = User.objects.get(pk=user_id)
|
||||||
|
|
||||||
|
# ساخت محتوا
|
||||||
|
context = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"event_url": _event_url(event),
|
||||||
|
"start_time": fa_digits(jdate(event.start_time))
|
||||||
|
}
|
||||||
|
# ایدمپوتنسی: اگر قبلاً این ایمیل رزرو/ارسال شده، Skip
|
||||||
|
subject = f"دعوت به شرکت در «{event.title}»"
|
||||||
|
text_body = render_to_string("emails/event_invite_non_registered.txt", context)
|
||||||
|
html_body = render_to_string("emails/event_invite_non_registered.html", context)
|
||||||
|
context_key = _build_email_context(
|
||||||
|
"invite_non_registered",
|
||||||
|
event.slug or event.id,
|
||||||
|
html_body,
|
||||||
|
)
|
||||||
|
log, skip = EventEmailLog.claim(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user_id,
|
||||||
|
kind=EventEmailLog.KIND_INVITE_NON_REGISTERED,
|
||||||
|
context=context_key,
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
return {"skipped": True, "status": log.status}
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[user.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_body, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
log.mark_sent()
|
||||||
|
return f"Email sent to {user.email}"
|
||||||
|
except Exception as exc:
|
||||||
|
log.mark_failed(str(exc))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def queue_skyroom_credentials(self, event_id: int):
|
||||||
|
"""
|
||||||
|
تسک مادر: ثبتنامهای تاییدشده را پیدا میکند و برای هر Registration یک تسک کوچک میسازد.
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
|
||||||
|
# فقط CONFIRMED ها + ایمیل معتبر
|
||||||
|
regs = (
|
||||||
|
_event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED])
|
||||||
|
.select_related("user", "event")
|
||||||
|
.exclude(user__email__isnull=True)
|
||||||
|
.exclude(user__email="")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
reg_ids = list(regs.values_list("id", flat=True))
|
||||||
|
|
||||||
|
# ساخت group از تسکهای کوچک؛ هر کدوم فقط یک ایمیل ارسال میکنند
|
||||||
|
job = group(send_skyroom_credentials_to_user.s(event_id, rid) for rid in reg_ids)
|
||||||
|
|
||||||
|
# توصیه: اگر نتیجهها را لازم ندارید، در تنظیمات CELERY_TASK_IGNORE_RESULT=True بگذارید
|
||||||
|
res = job.apply_async()
|
||||||
|
logger.info(
|
||||||
|
'Queued %s Skyroom-credential emails for event "%s" (group_id=%s)',
|
||||||
|
len(reg_ids), event.title, res.id
|
||||||
|
)
|
||||||
|
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(Exception,),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_jitter=True,
|
||||||
|
retry_kwargs={"max_retries": 3},
|
||||||
|
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||||
|
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||||
|
)
|
||||||
|
def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int):
|
||||||
|
"""
|
||||||
|
تسک کوچک و اتمی: ارسال نامکاربری/رمز اسکایروم برای یک Registration.
|
||||||
|
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
|
||||||
|
"""
|
||||||
|
user = None
|
||||||
|
log = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||||
|
user = r.user
|
||||||
|
event = r.event
|
||||||
|
|
||||||
|
# ساخت یوزرنیم/پسورد
|
||||||
|
sky_username = (user.email or "").strip().split("@")[0]
|
||||||
|
sky_password = str(r.ticket_id or "")[:8]
|
||||||
|
skyroom_url = event.online_link
|
||||||
|
|
||||||
|
context_key = _build_email_context(
|
||||||
|
"skyroom_credentials",
|
||||||
|
event.slug or event.id,
|
||||||
|
sky_username,
|
||||||
|
sky_password,
|
||||||
|
skyroom_url,
|
||||||
|
)
|
||||||
|
log, skip = EventEmailLog.claim(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user.id,
|
||||||
|
kind=EventEmailLog.KIND_SKYROOM_CREDENTIALS,
|
||||||
|
context=context_key,
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
return {"skipped": True, "status": log.status}
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"skyroom_url": skyroom_url,
|
||||||
|
"sky_username": sky_username,
|
||||||
|
"sky_password": sky_password,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"اطلاعات دسترسی اسکایروم - {event.title}"
|
||||||
|
html = render_to_string("emails/skyroom_credentials.html", ctx)
|
||||||
|
text_body = strip_tags(html)
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
log.mark_sent()
|
||||||
|
|
||||||
|
logger.info('Skyroom credentials for "%s" sent to %s', event.title, user.email)
|
||||||
|
return f"Email sent to {user.email}"
|
||||||
|
|
||||||
|
except SoftTimeLimitExceeded as exc:
|
||||||
|
# ثبت خطا و اجازه به Celery برای retry خودکار
|
||||||
|
if log:
|
||||||
|
log.mark_failed("Soft time limit exceeded")
|
||||||
|
logger.warning(
|
||||||
|
"Soft time limit exceeded for event_id=%s, registration_id=%s", event_id, registration_id
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if log:
|
||||||
|
log.mark_failed(str(exc))
|
||||||
|
logger.error("Failed to send skyroom credentials email: %s", exc, exc_info=True)
|
||||||
|
raise
|
||||||
0
apps/events/tests/__init__.py
Normal file
0
apps/events/tests/__init__.py
Normal file
0
apps/events/tests/integration/__init__.py
Normal file
0
apps/events/tests/integration/__init__.py
Normal file
540
apps/events/tests/integration/test_events.py
Normal file
540
apps/events/tests/integration/test_events.py
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.authentication import create_jwt_token
|
||||||
|
from apps.events.api.schemas import (
|
||||||
|
EventSchema,
|
||||||
|
EventGallerySchema,
|
||||||
|
EventListSchema,
|
||||||
|
RegistrationSchema,
|
||||||
|
PaymentAdminSchema,
|
||||||
|
EventAdminDetailSchema,
|
||||||
|
)
|
||||||
|
from apps.events.api.views import list_events
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.gallery.models import Gallery
|
||||||
|
from apps.payments.models import DiscountCode
|
||||||
|
from apps.users.models import Major, University, User
|
||||||
|
|
||||||
|
MEDIA_ROOT = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=MEDIA_ROOT)
|
||||||
|
class EventsAPIIntegrationTests(TestCase):
|
||||||
|
password = "TestPass123!"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = User.objects.create_user(
|
||||||
|
username="event_user",
|
||||||
|
email="event.user@example.com",
|
||||||
|
password=cls.password,
|
||||||
|
)
|
||||||
|
cls.user.is_email_verified = True
|
||||||
|
cls.user.save(update_fields=["is_email_verified"])
|
||||||
|
|
||||||
|
cls.staff = User.objects.create_user(
|
||||||
|
username="event_staff",
|
||||||
|
email="event.staff@example.com",
|
||||||
|
password=cls.password,
|
||||||
|
is_staff=True,
|
||||||
|
)
|
||||||
|
cls.staff.is_email_verified = True
|
||||||
|
cls.staff.save(update_fields=["is_email_verified"])
|
||||||
|
cls.major, _ = Major.objects.get_or_create(code="CS", defaults={"name": "Computer Science"})
|
||||||
|
cls.university, _ = University.objects.get_or_create(code="UT", defaults={"name": "University of Tehran"})
|
||||||
|
cls.user.major = cls.major
|
||||||
|
cls.user.university = cls.university
|
||||||
|
cls.user.save(update_fields=["major", "university"])
|
||||||
|
cls.staff.major = cls.major
|
||||||
|
cls.staff.university = cls.university
|
||||||
|
cls.staff.save(update_fields=["major", "university"])
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.token = create_jwt_token(self.user)
|
||||||
|
self.staff_token = create_jwt_token(self.staff)
|
||||||
|
|
||||||
|
self.event = self._create_event(
|
||||||
|
title="Integration Event",
|
||||||
|
description="Integration description.",
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
price=0,
|
||||||
|
)
|
||||||
|
self.other_event = self._create_event(
|
||||||
|
title="Other Published",
|
||||||
|
description="Searchable",
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
price=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth_headers(self, token):
|
||||||
|
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def _create_event(self, **overrides):
|
||||||
|
now = timezone.now()
|
||||||
|
defaults = {
|
||||||
|
"title": "Event Title",
|
||||||
|
"description": "Description",
|
||||||
|
"start_time": now,
|
||||||
|
"end_time": now + timedelta(hours=2),
|
||||||
|
"registration_start_date": now - timedelta(days=1),
|
||||||
|
"registration_end_date": now + timedelta(days=5),
|
||||||
|
"slug": f"event-{uuid.uuid4().hex[:6]}",
|
||||||
|
"location": "Campus",
|
||||||
|
"online_link": "https://meet.example.com",
|
||||||
|
"price": 0,
|
||||||
|
"capacity": 10,
|
||||||
|
"status": Event.StatusChoices.PUBLISHED,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return Event.objects.create(**defaults)
|
||||||
|
|
||||||
|
def _create_gallery_image(self):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (10, 10), color="blue").save(buffer, format="PNG")
|
||||||
|
buffer.seek(0)
|
||||||
|
file = SimpleUploadedFile("gallery.png", buffer.read(), content_type="image/png")
|
||||||
|
return Gallery.objects.create(
|
||||||
|
title="Gallery image",
|
||||||
|
description="desc",
|
||||||
|
image=file,
|
||||||
|
uploaded_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_paid_event(self):
|
||||||
|
return self._create_event(price=30000, capacity=5)
|
||||||
|
|
||||||
|
def _create_registration(self, event, user, status=Registration.StatusChoices.PENDING):
|
||||||
|
return Registration.objects.create(event=event, user=user, status=status, final_price=event.price)
|
||||||
|
|
||||||
|
# Basic event endpoints ------------------------------------------------
|
||||||
|
|
||||||
|
def test_list_events_filters_and_search(self):
|
||||||
|
# Act
|
||||||
|
response = self.client.get("/api/events/", {"status": "published", "search": "Searchable"})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(any(item["id"] == self.other_event.id for item in data))
|
||||||
|
|
||||||
|
def test_get_event_by_id_and_slug(self):
|
||||||
|
response_id = self.client.get(f"/api/events/{self.event.id}")
|
||||||
|
response_slug = self.client.get(f"/api/events/slug/{self.event.slug}")
|
||||||
|
|
||||||
|
self.assertEqual(response_id.status_code, 200)
|
||||||
|
self.assertEqual(response_slug.status_code, 200)
|
||||||
|
self.assertEqual(response_id.json()["id"], self.event.id)
|
||||||
|
self.assertEqual(response_slug.json()["slug"], self.event.slug)
|
||||||
|
|
||||||
|
def test_create_update_and_delete_event(self):
|
||||||
|
payload = {
|
||||||
|
"title": "New Event",
|
||||||
|
"description": "Desc",
|
||||||
|
"start_time": (timezone.now() + timedelta(days=1)).isoformat(),
|
||||||
|
"end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(),
|
||||||
|
"event_type": Event.TypeChoices.ON_SITE,
|
||||||
|
"status": Event.StatusChoices.DRAFT,
|
||||||
|
"price": 5000,
|
||||||
|
}
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/events/",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 200)
|
||||||
|
event_id = created.json()["id"]
|
||||||
|
|
||||||
|
updated = self.client.put(
|
||||||
|
f"/api/events/{event_id}",
|
||||||
|
data=json.dumps({"title": "Updated Event"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.status_code, 200)
|
||||||
|
self.assertEqual(updated.json()["title"], "Updated Event")
|
||||||
|
|
||||||
|
deleted = self.client.delete(f"/api/events/{event_id}")
|
||||||
|
self.assertEqual(deleted.status_code, 200)
|
||||||
|
|
||||||
|
def test_admin_detail_and_registration_list_requires_staff(self):
|
||||||
|
staff_headers = self._auth_headers(self.staff_token)
|
||||||
|
user_headers = self._auth_headers(self.token)
|
||||||
|
|
||||||
|
_ = self._create_registration(self.event, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||||
|
|
||||||
|
# Non staff forbidden
|
||||||
|
list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **user_headers)
|
||||||
|
self.assertEqual(list_resp.status_code, 403)
|
||||||
|
|
||||||
|
# Staff allowed
|
||||||
|
list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **staff_headers)
|
||||||
|
detail_resp = self.client.get(f"/api/events/{self.event.id}/admin-detail", **staff_headers)
|
||||||
|
self.assertEqual(list_resp.status_code, 200)
|
||||||
|
self.assertEqual(detail_resp.status_code, 200)
|
||||||
|
|
||||||
|
def test_list_events_filters_by_event_type_and_search(self):
|
||||||
|
event = self._create_event(
|
||||||
|
title="Special Search",
|
||||||
|
description="Unique discovery",
|
||||||
|
event_type=Event.TypeChoices.ONLINE,
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/events/",
|
||||||
|
{
|
||||||
|
"event_type": Event.TypeChoices.ONLINE,
|
||||||
|
"search": "Unique discovery",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(any(item["id"] == event.id for item in response.json()))
|
||||||
|
|
||||||
|
def test_list_events_handles_comma_status_parameter(self):
|
||||||
|
event = self._create_event(
|
||||||
|
title="Comma Event",
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
)
|
||||||
|
results = list_events(
|
||||||
|
None,
|
||||||
|
status=f"{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}",
|
||||||
|
event_type=None,
|
||||||
|
search=None,
|
||||||
|
limit=10,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
self.assertIn(event, list(results))
|
||||||
|
|
||||||
|
def test_create_event_attaches_gallery_images(self):
|
||||||
|
gallery = self._create_gallery_image()
|
||||||
|
payload = {
|
||||||
|
"title": "Gallery Event",
|
||||||
|
"description": "Gallery desc",
|
||||||
|
"start_time": (timezone.now() + timedelta(days=1)).isoformat(),
|
||||||
|
"end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(),
|
||||||
|
"event_type": Event.TypeChoices.ON_SITE,
|
||||||
|
"status": Event.StatusChoices.DRAFT,
|
||||||
|
"price": 5000,
|
||||||
|
"gallery_image_ids": [gallery.id],
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/events/",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(body["gallery_images"])
|
||||||
|
|
||||||
|
updated = self.client.put(
|
||||||
|
f"/api/events/{body['id']}",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"title": "Gallery Event Updated",
|
||||||
|
"gallery_image_ids": [gallery.id],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.status_code, 200)
|
||||||
|
self.assertEqual(updated.json()["slug"], "gallery-event-updated")
|
||||||
|
self.assertTrue(updated.json()["gallery_images"])
|
||||||
|
|
||||||
|
def test_admin_registration_filters_include_university_major_and_search(self):
|
||||||
|
event = self.event
|
||||||
|
self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||||
|
headers = self._auth_headers(self.staff_token)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{event.id}/admin-registrations",
|
||||||
|
{
|
||||||
|
"university": self.user.university.code,
|
||||||
|
"major": self.user.major.code,
|
||||||
|
"search": self.user.username,
|
||||||
|
"status": [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.PENDING],
|
||||||
|
},
|
||||||
|
**headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["count"], 1)
|
||||||
|
|
||||||
|
def test_register_before_start_and_after_end_dates_fail(self):
|
||||||
|
future_event = self._create_event(registration_start_date=timezone.now() + timedelta(days=1))
|
||||||
|
future_response = self.client.post(
|
||||||
|
f"/api/events/{future_event.id}/register",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
)
|
||||||
|
self.assertEqual(future_response.status_code, 400)
|
||||||
|
|
||||||
|
closed_event = self._create_event(registration_end_date=timezone.now() - timedelta(hours=1))
|
||||||
|
closed_response = self.client.post(
|
||||||
|
f"/api/events/{closed_event.id}/register",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
)
|
||||||
|
self.assertEqual(closed_response.status_code, 400)
|
||||||
|
|
||||||
|
def test_register_recreates_after_cancelled_registration(self):
|
||||||
|
event = self._create_event(price=0)
|
||||||
|
Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CANCELLED,
|
||||||
|
final_price=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/events/{event.id}/register",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED)
|
||||||
|
|
||||||
|
def test_register_updates_final_price_when_none(self):
|
||||||
|
event = self._create_paid_event()
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
final_price=None,
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/events/{event.id}/register",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["final_price"], event.price)
|
||||||
|
|
||||||
|
def _create_discount_code(self, event):
|
||||||
|
code = DiscountCode.objects.create(
|
||||||
|
code=f"CODE-{uuid.uuid4().hex[:4]}",
|
||||||
|
value=50,
|
||||||
|
type=DiscountCode.Type.PERCENT,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
code.applicable_events.add(event)
|
||||||
|
return code
|
||||||
|
|
||||||
|
def test_register_for_event_with_free_price_confirms(self):
|
||||||
|
event = self._create_event(price=0)
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/events/{event.id}/register",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED)
|
||||||
|
|
||||||
|
def test_register_for_event_with_discount_updates_final_price(self):
|
||||||
|
event = self._create_paid_event()
|
||||||
|
code = self._create_discount_code(event)
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/events/{event.id}/register",
|
||||||
|
data=json.dumps({"discount_code": code.code}),
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(result["discount_code"], code.code)
|
||||||
|
self.assertEqual(result["discount_amount"], event.price // 2)
|
||||||
|
self.assertEqual(result["final_price"], event.price // 2)
|
||||||
|
|
||||||
|
def test_register_fails_when_capacity_full(self):
|
||||||
|
event = self._create_event(capacity=1)
|
||||||
|
other = self._create_event_user("other_user", "other@example.com")
|
||||||
|
Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=other,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/events/{event.id}/register",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def _create_event_user(self, username, email):
|
||||||
|
user = User.objects.create_user(username=username, email=email, password=self.password)
|
||||||
|
user.is_email_verified = True
|
||||||
|
user.save(update_fields=["is_email_verified"])
|
||||||
|
user.major = self.user.major
|
||||||
|
user.university = self.user.university
|
||||||
|
user.save(update_fields=["major", "university"])
|
||||||
|
return user
|
||||||
|
|
||||||
|
def test_register_rejects_duplicate_confirmed(self):
|
||||||
|
event = self._create_event(price=0)
|
||||||
|
Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/events/{event.id}/register",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_registration_status_update_and_cancel(self):
|
||||||
|
event = self._create_event(price=0)
|
||||||
|
registration = self._create_registration(event, self.user)
|
||||||
|
|
||||||
|
update = self.client.put(
|
||||||
|
f"/api/events/registrations/{registration.id}",
|
||||||
|
data=json.dumps({"status": Registration.StatusChoices.ATTENDED}),
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
)
|
||||||
|
self.assertEqual(update.status_code, 200)
|
||||||
|
self.assertEqual(update.json()["status"], Registration.StatusChoices.ATTENDED)
|
||||||
|
|
||||||
|
cancel = self.client.delete(
|
||||||
|
f"/api/events/registrations/{registration.id}",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
)
|
||||||
|
self.assertEqual(cancel.status_code, 200)
|
||||||
|
self.assertEqual(cancel.json()["message"], "ثبتنام شما لغو شد :(")
|
||||||
|
|
||||||
|
def test_verify_registration_and_my_registrations(self):
|
||||||
|
event = self._create_event(price=0)
|
||||||
|
registration = self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||||
|
|
||||||
|
verify = self.client.get(
|
||||||
|
f"/api/events/registerations/verify/{registration.ticket_id}",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
)
|
||||||
|
self.assertEqual(verify.status_code, 200)
|
||||||
|
self.assertEqual(verify.json()["ticket_id"], str(registration.ticket_id))
|
||||||
|
|
||||||
|
my_regs = self.client.get(
|
||||||
|
"/api/events/my-registrations",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
)
|
||||||
|
self.assertEqual(my_regs.status_code, 200)
|
||||||
|
self.assertGreater(len(my_regs.json()), 0)
|
||||||
|
|
||||||
|
status_resp = self.client.get(
|
||||||
|
f"/api/events/{event.id}/is-registered",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||||
|
)
|
||||||
|
self.assertEqual(status_resp.status_code, 200)
|
||||||
|
self.assertTrue(status_resp.json()["is_registered"])
|
||||||
|
|
||||||
|
def test_list_event_registrations(self):
|
||||||
|
event = self.event
|
||||||
|
self._create_registration(event, self.user)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/events/{event.id}/registrations")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.json())
|
||||||
|
|
||||||
|
def test_list_event_registrations_admin_filters(self):
|
||||||
|
event = self.event
|
||||||
|
self._create_registration(event, self.user, status=Registration.StatusChoices.PENDING)
|
||||||
|
headers = self._auth_headers(self.staff_token)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{event.id}/admin-registrations",
|
||||||
|
{"status": [Registration.StatusChoices.PENDING]},
|
||||||
|
**headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["count"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class EventSchemasIntegrationTests(TestCase):
|
||||||
|
password = "SchemaPass!123"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="schema_user",
|
||||||
|
email="schema.user@example.com",
|
||||||
|
password=self.password,
|
||||||
|
)
|
||||||
|
self.user.is_email_verified = True
|
||||||
|
self.user.save(update_fields=["is_email_verified"])
|
||||||
|
|
||||||
|
self.event = Event.objects.create(
|
||||||
|
title="Schema Event",
|
||||||
|
description="**bold**",
|
||||||
|
start_time=timezone.now(),
|
||||||
|
end_time=timezone.now() + timedelta(hours=1),
|
||||||
|
registration_start_date=timezone.now() - timedelta(days=1),
|
||||||
|
registration_end_date=timezone.now() + timedelta(days=1),
|
||||||
|
price=1000,
|
||||||
|
slug="schema-event",
|
||||||
|
)
|
||||||
|
Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=0,
|
||||||
|
)
|
||||||
|
Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.ATTENDED,
|
||||||
|
final_price=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mock_request(self):
|
||||||
|
return SimpleNamespace(build_absolute_uri=lambda path: f"https://test{path}")
|
||||||
|
|
||||||
|
def test_gallery_schema_returns_full_url(self):
|
||||||
|
obj = SimpleNamespace(image=SimpleNamespace(url="/media/gallery.png"))
|
||||||
|
result = EventGallerySchema.resolve_absolute_image_url(obj, {"request": self._mock_request()})
|
||||||
|
self.assertEqual(result, "https://test/media/gallery.png")
|
||||||
|
|
||||||
|
def test_event_schema_resolvers(self):
|
||||||
|
context = {"request": self._mock_request()}
|
||||||
|
event_obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations)
|
||||||
|
self.assertEqual(EventSchema.resolve_absolute_featured_image_url(event_obj, context), "https://test/media/feat.png")
|
||||||
|
self.assertEqual(EventSchema.resolve_registration_count(self.event), 2)
|
||||||
|
self.assertIn("<p>", EventSchema.resolve_description_html(self.event))
|
||||||
|
|
||||||
|
def test_event_list_schema_resolvers(self):
|
||||||
|
obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations)
|
||||||
|
context = {"request": self._mock_request()}
|
||||||
|
self.assertEqual(EventListSchema.resolve_absolute_featured_image_url(obj, context), "https://test/media/feat.png")
|
||||||
|
self.assertEqual(EventListSchema.resolve_registration_count(self.event), 2)
|
||||||
|
|
||||||
|
def test_registration_schema_resolves_discount_code(self):
|
||||||
|
discount = DiscountCode.objects.create(code="SCHEMA", type=DiscountCode.Type.FIXED, value=100, is_active=True)
|
||||||
|
discount.applicable_events.add(self.event)
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=900,
|
||||||
|
discount_code=discount,
|
||||||
|
)
|
||||||
|
self.assertEqual(RegistrationSchema.resolve_discount_code(registration), discount.code)
|
||||||
|
|
||||||
|
def test_payment_admin_schema_normalizes_discount_code(self):
|
||||||
|
self.assertIsNone(PaymentAdminSchema.normalize_discount_code(None))
|
||||||
|
self.assertEqual(PaymentAdminSchema.normalize_discount_code("123"), "123")
|
||||||
|
self.assertEqual(PaymentAdminSchema.normalize_discount_code(SimpleNamespace(code="ABC")), "ABC")
|
||||||
|
|
||||||
|
def test_event_admin_detail_resolves_registrations(self):
|
||||||
|
registrations = EventAdminDetailSchema.resolve_registrations(self.event)
|
||||||
|
self.assertTrue(list(registrations))
|
||||||
|
# TODO registration-related tests
|
||||||
0
apps/events/tests/unit/__init__.py
Normal file
0
apps/events/tests/unit/__init__.py
Normal file
1197
apps/events/tests/unit/test_events.py
Normal file
1197
apps/events/tests/unit/test_events.py
Normal file
File diff suppressed because it is too large
Load Diff
89
apps/gallery/admin.py
Normal file
89
apps/gallery/admin.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|
||||||
|
from apps.gallery.models import Gallery
|
||||||
|
from apps.gallery.resources import GalleryResource
|
||||||
|
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
|
||||||
|
@admin.register(Gallery)
|
||||||
|
class GalleryAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = GalleryResource
|
||||||
|
list_display = ('title', 'image_preview', 'uploaded_by', 'file_size_display', 'dimensions', 'is_public', 'created_at')
|
||||||
|
list_filter = ('is_public', 'created_at', SoftDeleteListFilter)
|
||||||
|
search_fields = ('title', 'description', 'alt_text')
|
||||||
|
readonly_fields = ('uploaded_by', 'file_size', 'width', 'height', 'image_preview_large', 'markdown_url')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Image Info', {
|
||||||
|
'fields': ('title', 'description', 'image', 'alt_text', 'is_public')
|
||||||
|
}),
|
||||||
|
('Uploader', {
|
||||||
|
'fields': ('uploaded_by',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('file_size', 'width', 'height'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Preview & Usage', {
|
||||||
|
'fields': ('image_preview_large', 'markdown_url'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + ['make_public', 'make_private', 'restore_images']
|
||||||
|
|
||||||
|
def image_preview(self, obj):
|
||||||
|
if obj.image:
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
|
||||||
|
obj.image.url
|
||||||
|
)
|
||||||
|
return "No Image"
|
||||||
|
image_preview.short_description = "Preview"
|
||||||
|
|
||||||
|
def image_preview_large(self, obj):
|
||||||
|
if obj.image:
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" style="max-width: 300px; max-height: 300px; object-fit: contain;" />',
|
||||||
|
obj.image.url
|
||||||
|
)
|
||||||
|
return "No Image"
|
||||||
|
image_preview_large.short_description = "Image Preview"
|
||||||
|
|
||||||
|
def file_size_display(self, obj):
|
||||||
|
return f"{obj.file_size_mb} MB" if obj.file_size else "Unknown"
|
||||||
|
file_size_display.short_description = "File Size"
|
||||||
|
|
||||||
|
def dimensions(self, obj):
|
||||||
|
if obj.width and obj.height:
|
||||||
|
return f"{obj.width} × {obj.height}"
|
||||||
|
return "Unknown"
|
||||||
|
dimensions.short_description = "Dimensions"
|
||||||
|
|
||||||
|
def make_public(self, request, queryset):
|
||||||
|
queryset.update(is_public=True)
|
||||||
|
self.message_user(request, f"Made {queryset.count()} images public.")
|
||||||
|
make_public.short_description = "Make selected images public"
|
||||||
|
|
||||||
|
def make_private(self, request, queryset):
|
||||||
|
queryset.update(is_public=False)
|
||||||
|
self.message_user(request, f"Made {queryset.count()} images private.")
|
||||||
|
make_private.short_description = "Make selected images private"
|
||||||
|
|
||||||
|
def restore_images(self, request, queryset):
|
||||||
|
for image in queryset:
|
||||||
|
image.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} images.")
|
||||||
|
restore_images.short_description = "Restore selected images"
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if not obj.uploaded_by_id:
|
||||||
|
obj.uploaded_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
0
apps/gallery/api/__init__.py
Normal file
0
apps/gallery/api/__init__.py
Normal file
27
apps/gallery/api/schemas.py
Normal file
27
apps/gallery/api/schemas.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Schemas for gallery resources."""
|
||||||
|
|
||||||
|
from ninja import Schema, ModelSchema
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from apps.blog.api.schemas import AuthorSchema
|
||||||
|
from apps.gallery.models import Gallery
|
||||||
|
|
||||||
|
|
||||||
|
class GallerySchema(ModelSchema):
|
||||||
|
"""Serialized representation of a gallery image."""
|
||||||
|
uploaded_by: AuthorSchema
|
||||||
|
file_size_mb: float
|
||||||
|
markdown_url: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Gallery
|
||||||
|
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
|
||||||
|
'width', 'height', 'is_public', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryCreateSchema(Schema):
|
||||||
|
"""Payload for creating a gallery entry."""
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
alt_text: Optional[str] = None
|
||||||
|
is_public: bool = True
|
||||||
128
apps/gallery/api/views.py
Normal file
128
apps/gallery/api/views.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
from ninja import Router, Query, File, UploadedFile
|
||||||
|
from typing import List
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from apps.gallery.api.schemas import GalleryCreateSchema, GallerySchema
|
||||||
|
from apps.gallery.models import Gallery
|
||||||
|
from apps.gallery.tasks import process_uploaded_image
|
||||||
|
from core.api.schemas import ErrorSchema, MessageSchema
|
||||||
|
from core.authentication import jwt_auth
|
||||||
|
|
||||||
|
gallery_router = Router()
|
||||||
|
|
||||||
|
@gallery_router.get("/images", response=List[GallerySchema])
|
||||||
|
def list_gallery_images(
|
||||||
|
request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=50),
|
||||||
|
public_only: bool = Query(True)
|
||||||
|
):
|
||||||
|
"""List gallery images"""
|
||||||
|
queryset = Gallery.objects.select_related('uploaded_by')
|
||||||
|
|
||||||
|
if public_only:
|
||||||
|
queryset = queryset.filter(is_public=True)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
images = queryset[offset:offset + limit]
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
@gallery_router.get("/images/{image_id}", response=GallerySchema)
|
||||||
|
def get_gallery_image(request, image_id: int):
|
||||||
|
"""Get single gallery image"""
|
||||||
|
image = get_object_or_404(Gallery, id=image_id, is_public=True)
|
||||||
|
return image
|
||||||
|
|
||||||
|
@gallery_router.post("/images", response={201: GallerySchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def upload_image(request, file: UploadedFile = File(...), data: GalleryCreateSchema = None):
|
||||||
|
"""Upload image to gallery (committee members only)"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
if not (user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "Only committee members can upload images"}
|
||||||
|
|
||||||
|
# Validate file type
|
||||||
|
if not file.content_type.startswith('image/'):
|
||||||
|
return 400, {"error": "File must be an image"}
|
||||||
|
|
||||||
|
# Validate file size (10MB max)
|
||||||
|
if file.size > 10 * 1024 * 1024:
|
||||||
|
return 400, {"error": "File size must be less than 10MB"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create gallery item
|
||||||
|
gallery_item = Gallery.objects.create(
|
||||||
|
title=data.title if data else file.name,
|
||||||
|
description=data.description if data else "",
|
||||||
|
uploaded_by=user,
|
||||||
|
alt_text=data.alt_text if data else "",
|
||||||
|
is_public=data.is_public if data else True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save image
|
||||||
|
filename = f"gallery/{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
|
||||||
|
gallery_item.image.save(filename, ContentFile(file.read()))
|
||||||
|
|
||||||
|
# Process image asynchronously
|
||||||
|
process_uploaded_image.delay(gallery_item.id)
|
||||||
|
|
||||||
|
return 201, gallery_item
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to upload image", "details": str(e)}
|
||||||
|
|
||||||
|
@gallery_router.put("/images/{image_id}", response={200: GallerySchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def update_image(request, image_id: int, data: GalleryCreateSchema):
|
||||||
|
"""Update gallery image metadata"""
|
||||||
|
user = request.auth
|
||||||
|
image = get_object_or_404(Gallery, id=image_id)
|
||||||
|
|
||||||
|
if not (image.uploaded_by == user or user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "You can only edit your own images"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for field, value in data.dict(exclude_unset=True).items():
|
||||||
|
setattr(image, field, value)
|
||||||
|
|
||||||
|
image.save()
|
||||||
|
return 200, image
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to update image", "details": str(e)}
|
||||||
|
|
||||||
|
@gallery_router.delete("/images/{image_id}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def delete_image(request, image_id: int):
|
||||||
|
"""Soft delete a gallery image owned by the requester or committee."""
|
||||||
|
user = request.auth
|
||||||
|
image = get_object_or_404(Gallery, id=image_id)
|
||||||
|
|
||||||
|
if not (image.uploaded_by == user or user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "You can only delete your own images"}
|
||||||
|
|
||||||
|
image.delete()
|
||||||
|
return 200, {"message": "Image deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@gallery_router.get("/deleted/images", response=List[GallerySchema], auth=jwt_auth)
|
||||||
|
def list_deleted_gallery_images(request):
|
||||||
|
"""List all soft-deleted gallery images (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Gallery.deleted_objects.all().select_related('uploaded_by')
|
||||||
|
|
||||||
|
@gallery_router.post("/deleted/images/{image_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_gallery_image(request, image_id: int):
|
||||||
|
"""Restore a soft-deleted gallery image (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
image = Gallery.deleted_objects.get(id=image_id)
|
||||||
|
image.restore()
|
||||||
|
return 200, {"message": f"Gallery image '{image.title}' restored successfully."}
|
||||||
|
except Gallery.DoesNotExist:
|
||||||
|
return 400, {"error": "Gallery image not found or not soft-deleted."}
|
||||||
6
apps/gallery/apps.py
Normal file
6
apps/gallery/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.gallery"
|
||||||
218
apps/gallery/fixtures/gallery.json
Normal file
218
apps/gallery/fixtures/gallery.json
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-15T10:30:00Z",
|
||||||
|
"updated_at": "2024-01-15T10:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "کارگاه یادگیری ماشین - تصویر ۱",
|
||||||
|
"description": "شرکتکنندگان در حال یادگیری مفاهیم یادگیری ماشین",
|
||||||
|
"image": "gallery/ml_workshop_1.jpg",
|
||||||
|
"uploaded_by": 1,
|
||||||
|
"alt_text": "دانشجویان در کارگاه یادگیری ماشین",
|
||||||
|
"file_size": 2048000,
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-20T14:15:00Z",
|
||||||
|
"updated_at": "2024-01-20T14:15:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "مسابقه برنامهنویسی - لحظه اعلام نتایج",
|
||||||
|
"description": "اعلام نتایج مسابقه برنامهنویسی و اهدای جوایز",
|
||||||
|
"image": "gallery/programming_contest_results.jpg",
|
||||||
|
"uploaded_by": 2,
|
||||||
|
"alt_text": "اهدای جوایز مسابقه برنامهنویسی",
|
||||||
|
"file_size": 1536000,
|
||||||
|
"width": 1600,
|
||||||
|
"height": 900,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-01-25T09:45:00Z",
|
||||||
|
"updated_at": "2024-01-25T09:45:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "سمینار امنیت سایبری",
|
||||||
|
"description": "دکتر رضایی در حال ارائه مطالب امنیت سایبری",
|
||||||
|
"image": "gallery/cybersecurity_seminar.jpg",
|
||||||
|
"uploaded_by": 5,
|
||||||
|
"alt_text": "سخنرانی در سمینار امنیت سایبری",
|
||||||
|
"file_size": 1792000,
|
||||||
|
"width": 1800,
|
||||||
|
"height": 1200,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 4,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-01T16:20:00Z",
|
||||||
|
"updated_at": "2024-02-01T16:20:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "کارگاه React.js - کدنویسی عملی",
|
||||||
|
"description": "شرکتکنندگان در حال کدنویسی با React.js",
|
||||||
|
"image": "gallery/react_workshop_coding.jpg",
|
||||||
|
"uploaded_by": 9,
|
||||||
|
"alt_text": "کدنویسی در کارگاه React.js",
|
||||||
|
"file_size": 2304000,
|
||||||
|
"width": 2048,
|
||||||
|
"height": 1152,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 5,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-05T11:30:00Z",
|
||||||
|
"updated_at": "2024-02-05T11:30:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "بازدید از دیجیکالا - ورودی شرکت",
|
||||||
|
"description": "دانشجویان در ورودی شرکت دیجیکالا",
|
||||||
|
"image": "gallery/digikala_visit_entrance.jpg",
|
||||||
|
"uploaded_by": 3,
|
||||||
|
"alt_text": "بازدید از شرکت دیجیکالا",
|
||||||
|
"file_size": 1920000,
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1280,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 6,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-10T22:45:00Z",
|
||||||
|
"updated_at": "2024-02-10T22:45:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "هکاتون هوش مصنوعی - شب اول",
|
||||||
|
"description": "تیمها در حال کار شبانه روزی در هکاتون",
|
||||||
|
"image": "gallery/ai_hackathon_night.jpg",
|
||||||
|
"uploaded_by": 6,
|
||||||
|
"alt_text": "کار شبانه در هکاتون هوش مصنوعی",
|
||||||
|
"file_size": 1664000,
|
||||||
|
"width": 1600,
|
||||||
|
"height": 1067,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 7,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-15T13:10:00Z",
|
||||||
|
"updated_at": "2024-02-15T13:10:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "سمینار کارآفرینی - پنل بحث",
|
||||||
|
"description": "پنل بحث با کارآفرینان موفق فناوری",
|
||||||
|
"image": "gallery/entrepreneurship_panel.jpg",
|
||||||
|
"uploaded_by": 1,
|
||||||
|
"alt_text": "پنل بحث کارآفرینی فناوری",
|
||||||
|
"file_size": 2176000,
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 8,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-20T15:25:00Z",
|
||||||
|
"updated_at": "2024-02-20T15:25:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "کارگاه DevOps - آموزش Docker",
|
||||||
|
"description": "آموزش عملی Docker و کانتینرها",
|
||||||
|
"image": "gallery/devops_docker_training.jpg",
|
||||||
|
"uploaded_by": 8,
|
||||||
|
"alt_text": "آموزش Docker در کارگاه DevOps",
|
||||||
|
"file_size": 1856000,
|
||||||
|
"width": 1728,
|
||||||
|
"height": 1152,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 9,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-02-25T12:40:00Z",
|
||||||
|
"updated_at": "2024-02-25T12:40:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "مسابقه طراحی UI/UX - آثار شرکتکنندگان",
|
||||||
|
"description": "نمایش آثار طراحی شده توسط شرکتکنندگان",
|
||||||
|
"image": "gallery/uiux_contest_designs.jpg",
|
||||||
|
"uploaded_by": 12,
|
||||||
|
"alt_text": "آثار مسابقه طراحی UI/UX",
|
||||||
|
"file_size": 2048000,
|
||||||
|
"width": 2048,
|
||||||
|
"height": 1365,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 10,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-01T17:55:00Z",
|
||||||
|
"updated_at": "2024-03-01T17:55:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "نشست فارغالتحصیلان - عکس گروهی",
|
||||||
|
"description": "عکس یادگاری با فارغالتحصیلان و دانشجویان فعلی",
|
||||||
|
"image": "gallery/alumni_group_photo.jpg",
|
||||||
|
"uploaded_by": 5,
|
||||||
|
"alt_text": "عکس گروهی نشست فارغالتحصیلان",
|
||||||
|
"file_size": 2560000,
|
||||||
|
"width": 2560,
|
||||||
|
"height": 1440,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 11,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-05T08:20:00Z",
|
||||||
|
"updated_at": "2024-03-05T08:20:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "آزمایشگاه کامپیوتر - محیط کار",
|
||||||
|
"description": "نمایی از آزمایشگاه کامپیوتر دانشکده",
|
||||||
|
"image": "gallery/computer_lab.jpg",
|
||||||
|
"uploaded_by": 9,
|
||||||
|
"alt_text": "آزمایشگاه کامپیوتر دانشکده",
|
||||||
|
"file_size": 1792000,
|
||||||
|
"width": 1792,
|
||||||
|
"height": 1024,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "gallery.gallery",
|
||||||
|
"pk": 12,
|
||||||
|
"fields": {
|
||||||
|
"created_at": "2024-03-10T14:35:00Z",
|
||||||
|
"updated_at": "2024-03-10T14:35:00Z",
|
||||||
|
"is_deleted": false,
|
||||||
|
"title": "کتابخانه دانشکده - بخش کتب فنی",
|
||||||
|
"description": "بخش کتب فنی و مهندسی کامپیوتر کتابخانه",
|
||||||
|
"image": "gallery/library_tech_books.jpg",
|
||||||
|
"uploaded_by": 4,
|
||||||
|
"alt_text": "کتب فنی کتابخانه دانشکده",
|
||||||
|
"file_size": 1536000,
|
||||||
|
"width": 1536,
|
||||||
|
"height": 1024,
|
||||||
|
"is_public": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
36
apps/gallery/migrations/0001_initial.py
Normal file
36
apps/gallery/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Gallery',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('image', models.ImageField(upload_to='gallery/')),
|
||||||
|
('alt_text', models.CharField(blank=True, max_length=200)),
|
||||||
|
('file_size', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('width', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('height', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('is_public', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Gallery Images',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
23
apps/gallery/migrations/0002_initial.py
Normal file
23
apps/gallery/migrations/0002_initial.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('gallery', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='gallery',
|
||||||
|
name='uploaded_by',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gallery_images', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/gallery/migrations/__init__.py
Normal file
0
apps/gallery/migrations/__init__.py
Normal file
82
apps/gallery/models.py
Normal file
82
apps/gallery/models.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from core.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
MAX_IMAGE_FILE_SIZE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
class Gallery(BaseModel):
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
image = models.ImageField(upload_to='gallery/')
|
||||||
|
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='gallery_images')
|
||||||
|
alt_text = models.CharField(max_length=200, blank=True)
|
||||||
|
file_size = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
width = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
height = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
is_public = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name_plural = "Gallery Images"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.image:
|
||||||
|
# Get file size
|
||||||
|
self.file_size = self.image.size
|
||||||
|
|
||||||
|
# Get image dimensions
|
||||||
|
with Image.open(self.image.path) as img:
|
||||||
|
self.width, self.height = img.size
|
||||||
|
|
||||||
|
# Compress image if it's too large
|
||||||
|
self.compress_image()
|
||||||
|
|
||||||
|
# Update fields without triggering save again
|
||||||
|
Gallery.objects.filter(pk=self.pk).update(
|
||||||
|
file_size=self.file_size,
|
||||||
|
width=self.width,
|
||||||
|
height=self.height
|
||||||
|
)
|
||||||
|
|
||||||
|
def compress_image(self):
|
||||||
|
"""Compress image if it's larger than 2MB or dimensions are too large"""
|
||||||
|
if not self.image:
|
||||||
|
return
|
||||||
|
|
||||||
|
with Image.open(self.image.path) as img:
|
||||||
|
# Convert to RGB if necessary
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# Resize if too large
|
||||||
|
max_size = (1920, 1080)
|
||||||
|
if img.size[0] > max_size[0] or img.size[1] > max_size[1]:
|
||||||
|
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Compress if file size is too large
|
||||||
|
quality = 85
|
||||||
|
if self.file_size and self.file_size > MAX_IMAGE_FILE_SIZE_BYTES:
|
||||||
|
quality = 70
|
||||||
|
|
||||||
|
img.save(self.image.path, "JPEG", quality=quality, optimize=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_size_mb(self):
|
||||||
|
"""Return file size in MB"""
|
||||||
|
if self.file_size:
|
||||||
|
return round(self.file_size / (1024 * 1024), 2)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def markdown_url(self):
|
||||||
|
"""Return URL for use in markdown"""
|
||||||
|
return f""
|
||||||
17
apps/gallery/resources.py
Normal file
17
apps/gallery/resources.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from import_export import resources, fields
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from apps.gallery.models import Gallery
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
class GalleryResource(resources.ModelResource):
|
||||||
|
uploaded_by = fields.Field(
|
||||||
|
column_name='uploaded_by',
|
||||||
|
attribute='uploaded_by',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Gallery
|
||||||
|
fields = ('id', 'title', 'description', 'image', 'uploaded_by',
|
||||||
|
'alt_text', 'file_size', 'width', 'height', 'is_public', 'created_at')
|
||||||
23
apps/gallery/tasks.py
Normal file
23
apps/gallery/tasks.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
from PIL import Image
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_uploaded_image(gallery_id):
|
||||||
|
"""Process uploaded image: compress, resize, extract metadata"""
|
||||||
|
try:
|
||||||
|
from .models import Gallery
|
||||||
|
gallery_item = Gallery.objects.get(id=gallery_id)
|
||||||
|
|
||||||
|
if gallery_item.image:
|
||||||
|
# This will trigger the compression and metadata extraction
|
||||||
|
gallery_item.compress_image()
|
||||||
|
|
||||||
|
logger.info(f"Processed image: {gallery_item.title}")
|
||||||
|
return f"Processed image: {gallery_item.title}"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to process image: {exc}")
|
||||||
|
raise exc
|
||||||
83
apps/payments/admin.py
Normal file
83
apps/payments/admin.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|
||||||
|
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
from apps.payments.resources import DiscountResource, PaymentResource
|
||||||
|
from apps.payments.models import Payment, DiscountCode
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DiscountCode)
|
||||||
|
class DiscountCodeAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = DiscountResource
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
'code', 'type', 'value', 'is_active', 'starts_at', 'ends_at',
|
||||||
|
'usage_limit_total', 'usage_limit_per_user', 'min_amount', 'is_deleted'
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
'type', 'is_active', 'starts_at', 'ends_at', 'applicable_events',
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
)
|
||||||
|
search_fields = ('code', )
|
||||||
|
readonly_fields = ('id', 'deleted_at', 'created_at', 'updated_at')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Discount Code Details', {
|
||||||
|
'fields': ('code', 'type', 'value', 'applicable_events', 'is_active')
|
||||||
|
}),
|
||||||
|
('Limitations', {
|
||||||
|
'fields': ('starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user', 'min_amount')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ('deleted_at', )
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + [
|
||||||
|
'deactivate_codes',
|
||||||
|
]
|
||||||
|
|
||||||
|
@admin.action(description="Deactivate selected discount codes")
|
||||||
|
def deactivate_codes(self, request, queryset):
|
||||||
|
queryset.update(is_active=False)
|
||||||
|
self.message_user(request, f"Deactivate {queryset.count()} discount codes.")
|
||||||
|
|
||||||
|
@admin.register(Payment)
|
||||||
|
class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = PaymentResource
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount',
|
||||||
|
'status', 'created_at', 'verified_at', 'is_deleted'
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
'status', 'event',
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
'user__email', 'authority', 'ref_id', 'discount_code__code'
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority',
|
||||||
|
'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at'
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Payment Details', {
|
||||||
|
'fields': ('user', 'event', 'status', 'created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
('Price Info', {
|
||||||
|
'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount')
|
||||||
|
}),
|
||||||
|
('Others', {
|
||||||
|
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
0
apps/payments/api/__init__.py
Normal file
0
apps/payments/api/__init__.py
Normal file
35
apps/payments/api/schemas.py
Normal file
35
apps/payments/api/schemas.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePaymentIn(Schema):
|
||||||
|
event_id: int
|
||||||
|
description: str
|
||||||
|
discount_code: str | None = None
|
||||||
|
mobile: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePaymentOut(Schema):
|
||||||
|
start_pay_url: str | None = None
|
||||||
|
authority: str | None = None
|
||||||
|
base_amount: int
|
||||||
|
discount_amount: int
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
class PaymentDetailOut(Schema):
|
||||||
|
ref_id: str | None = None
|
||||||
|
authority: str | None = None
|
||||||
|
base_amount: int
|
||||||
|
discount_amount: int
|
||||||
|
amount: int
|
||||||
|
status: str
|
||||||
|
verified_at: str | None = None
|
||||||
|
event: dict
|
||||||
|
|
||||||
|
class CouponVerifyIn(Schema):
|
||||||
|
event_id: int
|
||||||
|
code: str
|
||||||
|
|
||||||
|
class CouponVerifyOut(Schema):
|
||||||
|
discount_amount: int
|
||||||
|
final_price: int
|
||||||
240
apps/payments/api/views.py
Normal file
240
apps/payments/api/views.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from apps.payments.models import Payment, DiscountCode
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
from core.authentication import jwt_auth
|
||||||
|
from apps.payments.api.schemas import CouponVerifyIn, CouponVerifyOut, CreatePaymentIn, CreatePaymentOut, PaymentDetailOut
|
||||||
|
|
||||||
|
payments_router = Router(tags=["Payments"])
|
||||||
|
|
||||||
|
|
||||||
|
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
|
||||||
|
def create_payment(request, payload: CreatePaymentIn):
|
||||||
|
event = get_object_or_404(Event, pk=payload.event_id)
|
||||||
|
|
||||||
|
if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists():
|
||||||
|
raise HttpError(400, "You have already registered in this event")
|
||||||
|
|
||||||
|
registration = (
|
||||||
|
Registration.objects.filter(event=event, user=request.auth, is_deleted=False)
|
||||||
|
.order_by("-registered_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=request.auth,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
final_price=event.price,
|
||||||
|
)
|
||||||
|
elif registration.final_price is None:
|
||||||
|
registration.final_price = event.price
|
||||||
|
registration.save(update_fields=["final_price"])
|
||||||
|
|
||||||
|
discount_code = None
|
||||||
|
discount_amount = 0
|
||||||
|
final_amount = event.price
|
||||||
|
|
||||||
|
if payload.discount_code:
|
||||||
|
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first()
|
||||||
|
|
||||||
|
if discount_code:
|
||||||
|
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
|
||||||
|
|
||||||
|
registration_updates = []
|
||||||
|
if discount_code and registration.discount_code_id != discount_code.id:
|
||||||
|
registration.discount_code = discount_code
|
||||||
|
registration_updates.append("discount_code")
|
||||||
|
if registration.discount_amount != discount_amount:
|
||||||
|
registration.discount_amount = discount_amount
|
||||||
|
registration_updates.append("discount_amount")
|
||||||
|
if registration.final_price != final_amount:
|
||||||
|
registration.final_price = final_amount
|
||||||
|
registration_updates.append("final_price")
|
||||||
|
|
||||||
|
if final_amount == 0:
|
||||||
|
if registration.status != Registration.StatusChoices.CONFIRMED:
|
||||||
|
registration.status = Registration.StatusChoices.CONFIRMED
|
||||||
|
registration_updates.append("status")
|
||||||
|
if registration_updates:
|
||||||
|
registration.save(update_fields=list(set(registration_updates)))
|
||||||
|
else:
|
||||||
|
registration.save(update_fields=["status"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_pay_url": None,
|
||||||
|
"authority": None,
|
||||||
|
"base_amount": event.price,
|
||||||
|
"discount_amount": discount_amount if discount_amount else 0,
|
||||||
|
"amount": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if registration_updates:
|
||||||
|
registration.save(update_fields=list(set(registration_updates)))
|
||||||
|
|
||||||
|
pay = Payment.objects.create(
|
||||||
|
user=request.auth,
|
||||||
|
event=event,
|
||||||
|
base_amount=event.price,
|
||||||
|
discount_code=discount_code,
|
||||||
|
discount_amount=discount_amount,
|
||||||
|
amount=final_amount,
|
||||||
|
status=Payment.OrderStatusChoices.INIT,
|
||||||
|
registration=registration,
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
|
||||||
|
body = {
|
||||||
|
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||||
|
"amount": final_amount,
|
||||||
|
"callback_url": callback_url,
|
||||||
|
"description": payload.description,
|
||||||
|
"metadata": {
|
||||||
|
k: v for k, v in {
|
||||||
|
"mobile": payload.mobile,
|
||||||
|
"email": payload.email,
|
||||||
|
"event_id": event.id,
|
||||||
|
"user_id": request.auth.id,
|
||||||
|
"payment_id": pay.id,
|
||||||
|
"discount_code": discount_code.code if discount_code else None,
|
||||||
|
}.items() if v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
settings.ZARINPAL_REQUEST_URL,
|
||||||
|
json=body,
|
||||||
|
headers={"accept":"application/json","content-type":"application/json"},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
jd = response.json()
|
||||||
|
except Exception as e:
|
||||||
|
pay.delete()
|
||||||
|
raise HttpError(502, f"Gateway request failed: {e}")
|
||||||
|
|
||||||
|
code = (jd.get("data") or {}).get("code")
|
||||||
|
if code != 100:
|
||||||
|
pay.delete()
|
||||||
|
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}")
|
||||||
|
|
||||||
|
authority = jd["data"]["authority"]
|
||||||
|
pay.authority = authority
|
||||||
|
pay.status = Payment.OrderStatusChoices.PENDING
|
||||||
|
pay.save(update_fields=["authority","status"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
|
||||||
|
"authority": authority,
|
||||||
|
"base_amount": event.price,
|
||||||
|
"discount_amount": discount_amount if discount_amount else 0,
|
||||||
|
"amount": final_amount,
|
||||||
|
}
|
||||||
|
|
||||||
|
@payments_router.get("callback")
|
||||||
|
def callback(request, Authority: str | None = None, Status: str | None = None):
|
||||||
|
if not Authority:
|
||||||
|
raise HttpError(400, "Missing Authority")
|
||||||
|
|
||||||
|
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
|
||||||
|
if not pay:
|
||||||
|
raise HttpError(404, "Payment not found")
|
||||||
|
|
||||||
|
if Status != "OK":
|
||||||
|
pay.status = Payment.OrderStatusChoices.CANCELED
|
||||||
|
pay.save(update_fields=["status"])
|
||||||
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||||
|
|
||||||
|
verify_body = {
|
||||||
|
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||||
|
"amount": pay.amount,
|
||||||
|
"authority": Authority,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
vresp = requests.post(
|
||||||
|
settings.ZARINPAL_VERIFY_URL,
|
||||||
|
json=verify_body,
|
||||||
|
headers={"accept":"application/json","content-type":"application/json"},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
vjd = vresp.json()
|
||||||
|
except Exception:
|
||||||
|
pay.status = Payment.OrderStatusChoices.FAILED
|
||||||
|
pay.save(update_fields=["status"])
|
||||||
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||||
|
|
||||||
|
vcode = (vjd.get("data") or {}).get("code")
|
||||||
|
if vcode in (100, 101):
|
||||||
|
data = vjd.get("data") or {}
|
||||||
|
pay.status = Payment.OrderStatusChoices.PAID
|
||||||
|
pay.ref_id = data.get("ref_id")
|
||||||
|
pay.card_pan = data.get("card_pan")
|
||||||
|
pay.card_hash = data.get("card_hash")
|
||||||
|
pay.verified_at = timezone.now()
|
||||||
|
pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"])
|
||||||
|
|
||||||
|
registration = pay.registration or Registration.objects.filter(
|
||||||
|
user=pay.user,
|
||||||
|
event=pay.event,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
).first()
|
||||||
|
if registration:
|
||||||
|
registration.status = Registration.StatusChoices.CONFIRMED
|
||||||
|
updates = ["status"]
|
||||||
|
if registration.final_price is None:
|
||||||
|
registration.final_price = pay.amount
|
||||||
|
updates.append("final_price")
|
||||||
|
registration.save(update_fields=updates)
|
||||||
|
|
||||||
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}")
|
||||||
|
|
||||||
|
pay.status = Payment.OrderStatusChoices.FAILED
|
||||||
|
pay.save(update_fields=["status"])
|
||||||
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||||
|
|
||||||
|
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
|
||||||
|
def payment_by_ref(request, ref_id: str):
|
||||||
|
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
|
||||||
|
ev = pay.event
|
||||||
|
return {
|
||||||
|
"ref_id": pay.ref_id,
|
||||||
|
"authority": pay.authority,
|
||||||
|
"base_amount": pay.base_amount,
|
||||||
|
"discount_amount": pay.discount_amount or 0,
|
||||||
|
"amount": pay.amount,
|
||||||
|
"status": pay.get_status_display(),
|
||||||
|
"verified_at": pay.verified_at.isoformat() if pay.verified_at else None,
|
||||||
|
"event": {
|
||||||
|
"id": ev.id,
|
||||||
|
"title": ev.title,
|
||||||
|
"slug": ev.slug,
|
||||||
|
"image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None,
|
||||||
|
"success_markdown": ev.registration_success_markdown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
|
||||||
|
def check_coupon(request, payload: CouponVerifyIn):
|
||||||
|
event = get_object_or_404(Event, id=payload.event_id)
|
||||||
|
code = payload.code
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
raise HttpError(404, "لطفا کد تخفیف را وارد کنید")
|
||||||
|
|
||||||
|
try:
|
||||||
|
c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True)
|
||||||
|
final_price, disc = c.calculate_discount(event, request.auth)
|
||||||
|
return {
|
||||||
|
"discount_amount": disc,
|
||||||
|
"final_price": final_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
except DiscountCode.DoesNotExist:
|
||||||
|
raise HttpError(404, "کد تخفیف معتبر نیست")
|
||||||
6
apps/payments/apps.py
Normal file
6
apps/payments/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.payments"
|
||||||
64
apps/payments/migrations/0001_initial.py
Normal file
64
apps/payments/migrations/0001_initial.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0002_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DiscountCode',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('code', models.CharField(max_length=64, unique=True)),
|
||||||
|
('type', models.CharField(choices=[('percent', 'Percent'), ('fixed', 'Fixed (IRR)')], default='percent', max_length=10)),
|
||||||
|
('value', models.PositiveIntegerField()),
|
||||||
|
('max_discount', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('starts_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('ends_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('usage_limit_total', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('usage_limit_per_user', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('min_amount', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('applicable_events', models.ManyToManyField(blank=True, related_name='discount_codes', to='events.event')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Payment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('base_amount', models.PositiveIntegerField(editable=False)),
|
||||||
|
('discount_amount', models.PositiveIntegerField(default=0, editable=False)),
|
||||||
|
('amount', models.PositiveIntegerField(editable=False)),
|
||||||
|
('authority', models.CharField(blank=True, editable=False, max_length=64, null=True, unique=True)),
|
||||||
|
('status', models.IntegerField(choices=[(0, 'Initiated'), (1, 'Pending'), (2, 'Paid'), (3, 'Failed'), (4, 'Canceled')], default=0, editable=False)),
|
||||||
|
('ref_id', models.CharField(blank=True, editable=False, max_length=64, null=True)),
|
||||||
|
('card_pan', models.CharField(blank=True, editable=False, max_length=32, null=True)),
|
||||||
|
('card_hash', models.CharField(blank=True, editable=False, max_length=128, null=True)),
|
||||||
|
('verified_at', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||||
|
('discount_code', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='payments.discountcode')),
|
||||||
|
('event', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='events.event')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
23
apps/payments/migrations/0002_initial.py
Normal file
23
apps/payments/migrations/0002_initial.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payments', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
apps/payments/migrations/0003_payment_registration.py
Normal file
20
apps/payments/migrations/0003_payment_registration.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2025-11-17 13:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0009_registration_discount_amount_and_more'),
|
||||||
|
('payments', '0002_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='registration',
|
||||||
|
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to='events.registration'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/payments/migrations/__init__.py
Normal file
0
apps/payments/migrations/__init__.py
Normal file
122
apps/payments/models.py
Normal file
122
apps/payments/models.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.models import BaseModel
|
||||||
|
from apps.events.models import Event
|
||||||
|
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
User = settings.AUTH_USER_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
class DiscountCode(BaseModel):
|
||||||
|
class Type(models.TextChoices):
|
||||||
|
PERCENT = "percent", "Percent"
|
||||||
|
FIXED = "fixed", "Fixed (IRR)"
|
||||||
|
|
||||||
|
code = models.CharField(max_length=64, unique=True)
|
||||||
|
type = models.CharField(max_length=10, choices=Type.choices, default=Type.PERCENT)
|
||||||
|
value = models.PositiveIntegerField()
|
||||||
|
max_discount = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
starts_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
ends_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
usage_limit_total = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
usage_limit_per_user = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
min_amount = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
applicable_events = models.ManyToManyField(Event, blank=True, related_name="discount_codes")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.code} ({self.get_type_display()} {self.value})"
|
||||||
|
|
||||||
|
def calculate_discount(self, event: Event, user: User):
|
||||||
|
if not event.price:
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
if not self.is_active:
|
||||||
|
raise HttpError(400, "کد تخفیف نامعتبر یا غیرفعال است.")
|
||||||
|
|
||||||
|
n = timezone.now()
|
||||||
|
if self.starts_at and n < self.starts_at:
|
||||||
|
raise HttpError(400, "کد تخفیف هنوز فعال نشده است.")
|
||||||
|
if self.ends_at and n > self.ends_at:
|
||||||
|
raise HttpError(400, "کد تخفیف منقضی شده است.")
|
||||||
|
|
||||||
|
if self.applicable_events.exists() and not self.applicable_events.filter(pk=event.pk).exists():
|
||||||
|
raise HttpError(400, "کد تخفیف برای این رویداد قابل استفاده نیست.")
|
||||||
|
|
||||||
|
if self.min_amount and event.price < self.min_amount:
|
||||||
|
raise HttpError(400, "مبلغ سفارش کمتر از حداقل لازم برای این کد است.")
|
||||||
|
|
||||||
|
used_qs = Payment.objects.filter(discount_code=self, status__in=[Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.PENDING])
|
||||||
|
if self.usage_limit_total is not None and used_qs.count() >= self.usage_limit_total:
|
||||||
|
raise HttpError(400, "حداکثر تعداد استفاده از این کد تخفیف تکمیل شده است.")
|
||||||
|
|
||||||
|
used_by_user = used_qs.filter(user=user).count()
|
||||||
|
if self.usage_limit_per_user is not None and used_by_user >= self.usage_limit_per_user:
|
||||||
|
raise HttpError(400, "شما حداکثر تعداد مجاز استفاده از این کد تخفیف را مصرف کردهاید.")
|
||||||
|
|
||||||
|
if self.type == DiscountCode.Type.FIXED:
|
||||||
|
disc = min(self.value, event.price)
|
||||||
|
else:
|
||||||
|
disc = (event.price * self.value) // 100
|
||||||
|
if self.max_discount:
|
||||||
|
disc = min(disc, self.max_discount)
|
||||||
|
|
||||||
|
final_amount = max(event.price - disc, 0)
|
||||||
|
if 0 < final_amount < 10_000:
|
||||||
|
raise HttpError(400, "با این تخفیف مبلغ قابل پرداخت به کمتر از ۱۰٬۰۰۰ ریال میرسد.")
|
||||||
|
|
||||||
|
return (final_amount, disc)
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(BaseModel):
|
||||||
|
class OrderStatusChoices(models.IntegerChoices):
|
||||||
|
INIT = 0, "Initiated"
|
||||||
|
PENDING = 1, "Pending"
|
||||||
|
PAID = 2, "Paid"
|
||||||
|
FAILED = 3, "Failed"
|
||||||
|
CANCELED = 4, "Canceled"
|
||||||
|
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
|
||||||
|
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
|
||||||
|
|
||||||
|
base_amount = models.PositiveIntegerField(editable=False)
|
||||||
|
discount_code = models.ForeignKey(DiscountCode, on_delete=models.PROTECT, null=True, blank=True, editable=False, related_name="payments")
|
||||||
|
discount_amount = models.PositiveIntegerField(default=0, editable=False)
|
||||||
|
amount = models.PositiveIntegerField(editable=False)
|
||||||
|
|
||||||
|
registration = models.ForeignKey(
|
||||||
|
"events.Registration",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="payments",
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
authority = models.CharField(max_length=64, unique=True, null=True, blank=True, editable=False)
|
||||||
|
status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False)
|
||||||
|
ref_id = models.CharField(max_length=64, null=True, blank=True, editable=False)
|
||||||
|
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
|
||||||
|
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
|
||||||
|
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.discount_amount and self.amount + self.discount_amount != self.base_amount:
|
||||||
|
raise ValidationError({"amount": "amount + discount_amount must equal base_amount"})
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.full_clean()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_label(self):
|
||||||
|
"""Human-readable label for the payment status."""
|
||||||
|
return self.get_status_display()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.email}:{self.event} - {self.get_status_display()}"
|
||||||
|
|
||||||
44
apps/payments/resources.py
Normal file
44
apps/payments/resources.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from import_export import resources, fields
|
||||||
|
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||||
|
|
||||||
|
from apps.payments.models import Payment, DiscountCode
|
||||||
|
from apps.events.models import Event
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
class DiscountResource(resources.ModelResource):
|
||||||
|
event = fields.Field(
|
||||||
|
column_name='applicable_events',
|
||||||
|
attribute='applicable_events',
|
||||||
|
widget=ManyToManyWidget(Event, field='title', separator='||')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = (
|
||||||
|
'id', 'code', 'type', 'value', 'max_discount', 'is_active',
|
||||||
|
'starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user',
|
||||||
|
'min_amount', 'applicable_events', 'created_at', 'updated_at',
|
||||||
|
'is_deleted', 'deleted_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
class PaymentResource(resources.ModelResource):
|
||||||
|
event = fields.Field(
|
||||||
|
column_name='event',
|
||||||
|
attribute='event',
|
||||||
|
widget=ForeignKeyWidget(Event, 'title')
|
||||||
|
)
|
||||||
|
user = fields.Field(
|
||||||
|
column_name='user',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Payment
|
||||||
|
fields = (
|
||||||
|
'id', 'event', 'user', 'base_amount', 'discount_code', 'discount_amount', 'amount',
|
||||||
|
'authority', 'status', 'red_id', 'card_pan', 'card_hash', 'verified_at', 'created_at',
|
||||||
|
'updated_at', 'is_deleted', 'deleted_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
0
apps/payments/tests/__init__.py
Normal file
0
apps/payments/tests/__init__.py
Normal file
0
apps/payments/tests/integration/__init__.py
Normal file
0
apps/payments/tests/integration/__init__.py
Normal file
282
apps/payments/tests/integration/test_payments.py
Normal file
282
apps/payments/tests/integration/test_payments.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.authentication import create_jwt_token
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.payments.models import Payment, DiscountCode
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
ZARINPAL_MERCHANT_ID="MID",
|
||||||
|
ZARINPAL_REQUEST_URL="https://zarinpal/request",
|
||||||
|
ZARINPAL_STARTPAY="https://zarinpal/start/",
|
||||||
|
ZARINPAL_VERIFY_URL="https://zarinpal/verify",
|
||||||
|
ZARINPAL_CALLBACK_URL="https://frontend/callback",
|
||||||
|
)
|
||||||
|
class PaymentsAPIIntegrationTests(TestCase):
|
||||||
|
password = "PaymentPass!123"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = User.objects.create_user(
|
||||||
|
username="pay_user",
|
||||||
|
email="pay.user@example.com",
|
||||||
|
password=cls.password,
|
||||||
|
)
|
||||||
|
cls.user.is_email_verified = True
|
||||||
|
cls.user.save(update_fields=["is_email_verified"])
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.event = Event.objects.create(
|
||||||
|
title="Pay Event",
|
||||||
|
description="Payment event",
|
||||||
|
start_time=timezone.now(),
|
||||||
|
end_time=timezone.now() + timedelta(hours=2),
|
||||||
|
registration_start_date=timezone.now() - timedelta(days=1),
|
||||||
|
registration_end_date=timezone.now() + timedelta(days=1),
|
||||||
|
slug="pay-event",
|
||||||
|
price=50000,
|
||||||
|
capacity=10,
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
)
|
||||||
|
self.token = create_jwt_token(self.user)
|
||||||
|
|
||||||
|
def _headers(self):
|
||||||
|
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
def _create_paid_event(self):
|
||||||
|
return Event.objects.create(
|
||||||
|
title="Paid Event",
|
||||||
|
description="Paid",
|
||||||
|
start_time=timezone.now(),
|
||||||
|
end_time=timezone.now() + timedelta(hours=1),
|
||||||
|
registration_start_date=timezone.now() - timedelta(days=1),
|
||||||
|
registration_end_date=timezone.now() + timedelta(days=2),
|
||||||
|
slug=f"paid-{timezone.now().timestamp()}",
|
||||||
|
price=20000,
|
||||||
|
capacity=5,
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_discount_code(self, event):
|
||||||
|
code = DiscountCode.objects.create(
|
||||||
|
code="DISC50",
|
||||||
|
value=50,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
code.applicable_events.add(event)
|
||||||
|
return code
|
||||||
|
|
||||||
|
def test_create_payment_for_free_event(self):
|
||||||
|
free = Event.objects.create(
|
||||||
|
title="Free",
|
||||||
|
description="Zero",
|
||||||
|
start_time=timezone.now(),
|
||||||
|
end_time=timezone.now() + timedelta(hours=1),
|
||||||
|
registration_start_date=timezone.now() - timedelta(days=1),
|
||||||
|
registration_end_date=timezone.now() + timedelta(days=1),
|
||||||
|
slug="free-event",
|
||||||
|
price=0,
|
||||||
|
capacity=10,
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/payments/create",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"event_id": free.id,
|
||||||
|
"description": "Free registration",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["amount"], 0)
|
||||||
|
self.assertIsNone(data["start_pay_url"])
|
||||||
|
|
||||||
|
@mock.patch("apps.payments.api.views.requests.post")
|
||||||
|
def test_create_payment_with_discount(self, mock_post):
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
code = self._create_discount_code(self.event)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/payments/create",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"event_id": self.event.id,
|
||||||
|
"description": "Pay with discount",
|
||||||
|
"discount_code": code.code,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["discount_amount"], self.event.price // 2)
|
||||||
|
self.assertEqual(payload["amount"], self.event.price // 2)
|
||||||
|
self.assertIn("start_pay_url", payload)
|
||||||
|
payment = Payment.objects.get(user=self.user, event=self.event)
|
||||||
|
self.assertEqual(payment.discount_code, code)
|
||||||
|
|
||||||
|
@mock.patch("apps.payments.api.views.requests.post")
|
||||||
|
def test_callback_success_marks_paid(self, mock_post):
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PENDING,
|
||||||
|
authority="AUTH123",
|
||||||
|
)
|
||||||
|
mock_resp = mock.Mock()
|
||||||
|
mock_resp.json.return_value = {"data": {"code": 100, "ref_id": "REF", "card_pan": "123", "card_hash": "ABC"}}
|
||||||
|
mock_post.return_value = mock_resp
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/payments/callback",
|
||||||
|
{"Authority": "AUTH123", "Status": "OK"},
|
||||||
|
)
|
||||||
|
payment.refresh_from_db()
|
||||||
|
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
|
||||||
|
self.assertTrue("status=success" in response.url)
|
||||||
|
|
||||||
|
@mock.patch("apps.payments.api.views.requests.post")
|
||||||
|
def test_callback_failure_redirects_failed(self, mock_post):
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PENDING,
|
||||||
|
authority="AUTH456",
|
||||||
|
)
|
||||||
|
mock_resp = mock.Mock()
|
||||||
|
mock_resp.json.return_value = {"data": {"code": 101, "ref_id": "REF"}}
|
||||||
|
mock_post.return_value = mock_resp
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/payments/callback",
|
||||||
|
{"Authority": "AUTH456", "Status": "OK"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payment.refresh_from_db()
|
||||||
|
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
|
||||||
|
self.assertTrue("status=success" in response.url)
|
||||||
|
|
||||||
|
def test_callback_missing_authority_returns_error(self):
|
||||||
|
response = self.client.get("/api/payments/callback", {"Status": "OK"})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_callback_not_ok_cancels(self):
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PENDING,
|
||||||
|
authority="AUTH789",
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/payments/callback",
|
||||||
|
{"Authority": "AUTH789", "Status": "NOK"},
|
||||||
|
)
|
||||||
|
payment.refresh_from_db()
|
||||||
|
self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED)
|
||||||
|
self.assertIn("status=failed", response.url)
|
||||||
|
|
||||||
|
@mock.patch("apps.payments.api.views.requests.post", side_effect=RuntimeError("down"))
|
||||||
|
def test_create_payment_gateway_failure(self, mock_post):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/payments/create",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"event_id": self.event.id,
|
||||||
|
"description": "Gateway fail",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 502)
|
||||||
|
self.assertFalse(Payment.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_create_payment_when_already_paid(self):
|
||||||
|
Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/payments/create",
|
||||||
|
data=json.dumps({"event_id": self.event.id, "description": "Duplicate"}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
@mock.patch("apps.payments.api.views.requests.post")
|
||||||
|
def test_registration_final_price_none_updates(self, mock_post):
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
final_price=None,
|
||||||
|
)
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/payments/create",
|
||||||
|
data=json.dumps({"event_id": self.event.id, "description": "Update"}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
registration.refresh_from_db()
|
||||||
|
if registration.final_price is None:
|
||||||
|
self.fail("final_price should be populated")
|
||||||
|
|
||||||
|
def test_coupon_check_success_and_errors(self):
|
||||||
|
code = DiscountCode.objects.create(code="PAYCO", value=20, is_active=True, type=DiscountCode.Type.PERCENT)
|
||||||
|
code.applicable_events.add(self.event)
|
||||||
|
|
||||||
|
# missing code
|
||||||
|
missing = self.client.post(
|
||||||
|
"/api/payments/coupon/check",
|
||||||
|
data=json.dumps({"event_id": self.event.id}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(missing.status_code, 422)
|
||||||
|
|
||||||
|
# invalid code
|
||||||
|
invalid = self.client.post(
|
||||||
|
"/api/payments/coupon/check",
|
||||||
|
data=json.dumps({"event_id": self.event.id, "code": "INVALID"}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(invalid.status_code, 404)
|
||||||
|
|
||||||
|
success = self.client.post(
|
||||||
|
"/api/payments/coupon/check",
|
||||||
|
data=json.dumps({"event_id": self.event.id, "code": code.code}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self._headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(success.status_code, 200)
|
||||||
|
self.assertIn("final_price", success.json())
|
||||||
0
apps/payments/tests/unit/__init__.py
Normal file
0
apps/payments/tests/unit/__init__.py
Normal file
194
apps/payments/tests/unit/test_payments.py
Normal file
194
apps/payments/tests/unit/test_payments.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from django.contrib.admin import AdminSite
|
||||||
|
from apps.payments.admin import DiscountCodeAdmin
|
||||||
|
from apps.payments.models import DiscountCode, Payment
|
||||||
|
from apps.payments.resources import DiscountResource, PaymentResource
|
||||||
|
from apps.events.models import Event
|
||||||
|
from apps.users.models import User
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentTestMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _create_user(**overrides):
|
||||||
|
data = {
|
||||||
|
"username": f"user_{uuid.uuid4().hex[:6]}",
|
||||||
|
"email": f"user_{uuid.uuid4().hex[:6]}@example.com",
|
||||||
|
"password": "Test!1234",
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return User.objects.create_user(**data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_event(**overrides):
|
||||||
|
now = timezone.now()
|
||||||
|
defaults = {
|
||||||
|
"title": "Sample",
|
||||||
|
"description": "Desc",
|
||||||
|
"start_time": now,
|
||||||
|
"end_time": now + timedelta(hours=2),
|
||||||
|
"registration_start_date": now - timedelta(days=1),
|
||||||
|
"registration_end_date": now + timedelta(days=5),
|
||||||
|
"slug": f"event-{uuid.uuid4().hex[:6]}",
|
||||||
|
"price": 100000,
|
||||||
|
"capacity": 10,
|
||||||
|
"status": Event.StatusChoices.PUBLISHED,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return Event.objects.create(**defaults)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _discount_code(**overrides):
|
||||||
|
defaults = {
|
||||||
|
"code": f"CODE{uuid.uuid4().hex[:4]}",
|
||||||
|
"value": 50,
|
||||||
|
"is_active": True,
|
||||||
|
"type": DiscountCode.Type.PERCENT,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return DiscountCode.objects.create(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscountCodeModelTests(TestCase, PaymentTestMixin):
|
||||||
|
def setUp(self):
|
||||||
|
self.event = self._create_event()
|
||||||
|
self.user = self._create_user(is_email_verified=True)
|
||||||
|
|
||||||
|
def test_zero_price_returns_zero_discount(self):
|
||||||
|
event = self._create_event(price=0)
|
||||||
|
code = self._discount_code()
|
||||||
|
code.applicable_events.add(event)
|
||||||
|
self.assertEqual(code.calculate_discount(event, self.user), (0, 0))
|
||||||
|
|
||||||
|
def test_inactive_raises_error(self):
|
||||||
|
code = self._discount_code(is_active=False)
|
||||||
|
code.applicable_events.add(self.event)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
code.calculate_discount(self.event, self.user)
|
||||||
|
|
||||||
|
def test_start_date_validation(self):
|
||||||
|
code = self._discount_code(starts_at=timezone.now() + timedelta(days=1))
|
||||||
|
code.applicable_events.add(self.event)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
code.calculate_discount(self.event, self.user)
|
||||||
|
|
||||||
|
def test_end_date_validation(self):
|
||||||
|
code = self._discount_code(ends_at=timezone.now() - timedelta(days=1))
|
||||||
|
code.applicable_events.add(self.event)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
code.calculate_discount(self.event, self.user)
|
||||||
|
|
||||||
|
def test_applicable_events_enforcement(self):
|
||||||
|
code = self._discount_code()
|
||||||
|
other_event = self._create_event()
|
||||||
|
code.applicable_events.add(other_event)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
code.calculate_discount(self.event, self.user)
|
||||||
|
|
||||||
|
def test_min_amount_guard(self):
|
||||||
|
code = self._discount_code(min_amount=200000)
|
||||||
|
code.applicable_events.add(self.event)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
code.calculate_discount(self.event, self.user)
|
||||||
|
|
||||||
|
def test_usage_limit_total(self):
|
||||||
|
code = self._discount_code(usage_limit_total=1)
|
||||||
|
code.applicable_events.add(self.event)
|
||||||
|
Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
discount_amount=0,
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
discount_code=code,
|
||||||
|
)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
code.calculate_discount(self.event, self.user)
|
||||||
|
|
||||||
|
def test_usage_limit_per_user(self):
|
||||||
|
code = self._discount_code(usage_limit_per_user=1)
|
||||||
|
code.applicable_events.add(self.event)
|
||||||
|
Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
discount_amount=0,
|
||||||
|
status=Payment.OrderStatusChoices.PENDING,
|
||||||
|
discount_code=code,
|
||||||
|
)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
code.calculate_discount(self.event, self.user)
|
||||||
|
|
||||||
|
def test_final_price_below_min_post_discount(self):
|
||||||
|
event = self._create_event(price=15000)
|
||||||
|
code = self._discount_code(value=80)
|
||||||
|
code.applicable_events.add(event)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
code.calculate_discount(event, self.user)
|
||||||
|
|
||||||
|
def test_fixed_discount_type(self):
|
||||||
|
code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000)
|
||||||
|
code.applicable_events.add(self.event)
|
||||||
|
final, disc = code.calculate_discount(self.event, self.user)
|
||||||
|
self.assertEqual(disc, 5000)
|
||||||
|
self.assertEqual(final, self.event.price - 5000)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentModelAndResourceTests(TestCase, PaymentTestMixin):
|
||||||
|
def setUp(self):
|
||||||
|
self.event = self._create_event()
|
||||||
|
self.user = self._create_user(is_email_verified=True)
|
||||||
|
|
||||||
|
def test_payment_clean_validates_amount(self):
|
||||||
|
payment = Payment(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=1000,
|
||||||
|
amount=500,
|
||||||
|
discount_amount=400,
|
||||||
|
status=Payment.OrderStatusChoices.INIT,
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
payment.full_clean()
|
||||||
|
|
||||||
|
def test_payment_resource_defers_user_event(self):
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=1000,
|
||||||
|
amount=1000,
|
||||||
|
discount_amount=0,
|
||||||
|
status=Payment.OrderStatusChoices.INIT,
|
||||||
|
)
|
||||||
|
resource = PaymentResource()
|
||||||
|
user_cell = resource.fields["user"].widget.clean(self.user.username, None)
|
||||||
|
self.assertEqual(user_cell, self.user)
|
||||||
|
event_cell = resource.fields["event"].widget.clean(self.event.title, None)
|
||||||
|
self.assertEqual(event_cell, self.event)
|
||||||
|
|
||||||
|
def test_discount_resource_expands_events(self):
|
||||||
|
resource = DiscountResource()
|
||||||
|
widget = resource.fields["event"].widget
|
||||||
|
self.assertEqual(widget.separator, "||")
|
||||||
|
|
||||||
|
|
||||||
|
class DiscountCodeAdminTests(TestCase, PaymentTestMixin):
|
||||||
|
def setUp(self):
|
||||||
|
self.admin = DiscountCodeAdmin(DiscountCode, AdminSite())
|
||||||
|
|
||||||
|
def test_deactivate_codes_action(self):
|
||||||
|
code = self._discount_code()
|
||||||
|
queryset = DiscountCode.objects.filter(pk=code.pk)
|
||||||
|
request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None))
|
||||||
|
self.admin.deactivate_codes(request, queryset)
|
||||||
|
code.refresh_from_db()
|
||||||
|
self.assertFalse(code.is_active)
|
||||||
122
apps/users/admin.py
Normal file
122
apps/users/admin.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
from simplemde.widgets import SimpleMDEEditor
|
||||||
|
|
||||||
|
from apps.users.models import User, University, Major
|
||||||
|
from apps.users.resources import UserResource
|
||||||
|
from apps.users.tasks import send_verification_email
|
||||||
|
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdminForm(forms.ModelForm):
|
||||||
|
bio = forms.CharField(widget=SimpleMDEEditor(), required=False)
|
||||||
|
student_id = forms.CharField(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
form = UserAdminForm
|
||||||
|
resource_class = UserResource
|
||||||
|
list_display = ('email', 'username', 'university', 'is_email_verified', 'date_joined')
|
||||||
|
list_filter = ('is_email_verified', 'is_staff', 'year_of_study', SoftDeleteListFilter)
|
||||||
|
search_fields = ('email', 'username', 'student_id', 'first_name', 'last_name')
|
||||||
|
ordering = ('-date_joined',)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Auth Credentials', {'fields': ('username', 'email', 'password')}),
|
||||||
|
('Personal info', {
|
||||||
|
'fields': ('first_name', 'last_name', 'student_id', 'university', 'year_of_study', 'major', 'bio', 'profile_picture')
|
||||||
|
}),
|
||||||
|
('Permissions', {
|
||||||
|
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions',),
|
||||||
|
}),
|
||||||
|
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||||
|
|
||||||
|
('Email Verification', {
|
||||||
|
'fields': ('is_email_verified', 'email_verification_token', 'email_verification_sent_at')
|
||||||
|
}),
|
||||||
|
('Password Reset', {
|
||||||
|
'fields': ('password_reset_token', 'password_reset_token_expires_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
add_fieldsets = (
|
||||||
|
(
|
||||||
|
'Step 1',
|
||||||
|
{
|
||||||
|
'classes': ('wide',),
|
||||||
|
'fields': ('email', 'student_id', 'password1', 'password2', 'usable_password'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ('email_verification_token', 'email_verification_sent_at', 'deleted_at',
|
||||||
|
'password_reset_token', 'password_reset_token_expires_at')
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + [
|
||||||
|
'verify_emails',
|
||||||
|
'resend_verification_email',
|
||||||
|
]
|
||||||
|
|
||||||
|
@admin.action(description='Verify selected user emails')
|
||||||
|
def verify_emails(self, request, queryset):
|
||||||
|
queryset.update(is_email_verified=True)
|
||||||
|
self.message_user(request, f'Verified {queryset.count()} user emails.')
|
||||||
|
|
||||||
|
@admin.action(description="Resend verification email")
|
||||||
|
def resend_verification_email(self, request, queryset):
|
||||||
|
qs = queryset.filter(is_email_verified=False).exclude(email__isnull=True).exclude(email="")
|
||||||
|
|
||||||
|
total = queryset.count()
|
||||||
|
to_send = qs.count()
|
||||||
|
skipped = total - to_send
|
||||||
|
sent = failed = 0
|
||||||
|
|
||||||
|
for user in qs:
|
||||||
|
try:
|
||||||
|
user.regenerate_verification_token()
|
||||||
|
user.email_verification_sent_at = timezone.now()
|
||||||
|
user.save(update_fields=["email_verification_sent_at"])
|
||||||
|
|
||||||
|
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}"
|
||||||
|
send_verification_email.delay(user.id, verification_url)
|
||||||
|
sent += 1
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if sent:
|
||||||
|
self.message_user(request, f"ایمیل تأیید برای {sent} کاربر ارسال شد.", level=messages.SUCCESS)
|
||||||
|
if skipped:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{skipped} کاربر کنار گذاشته شدند (یا قبلاً تأیید شدهاند یا ایمیل ندارند).",
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
|
if failed:
|
||||||
|
self.message_user(request, f"ارسال برای {failed} کاربر با خطا مواجه شد.", level=messages.ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(University)
|
||||||
|
class UniversityAdmin(BaseModelAdmin):
|
||||||
|
list_display = ('name', 'code', 'is_active', 'created_at')
|
||||||
|
list_filter = ('is_active', SoftDeleteListFilter)
|
||||||
|
search_fields = ('name', 'code')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Major)
|
||||||
|
class MajorAdmin(BaseModelAdmin):
|
||||||
|
list_display = ('name', 'code', 'is_active', 'created_at')
|
||||||
|
list_filter = ('is_active', SoftDeleteListFilter)
|
||||||
|
search_fields = ('name', 'code')
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user