init
This commit is contained in:
31
backend/.coveragerc
Normal file
31
backend/.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
|
||||
139
backend/.gitignore
vendored
Normal file
139
backend/.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
|
||||
34
backend/Dockerfile
Normal file
34
backend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
postgresql-client \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy project
|
||||
COPY . /app/
|
||||
|
||||
# Create directories for static and media files
|
||||
RUN mkdir -p /app/static /app/media
|
||||
# COPY ./static/ /app/static/
|
||||
|
||||
# Collect static files
|
||||
RUN python manage.py collectstatic --noinput || true
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers=3", "--threads=2", "--timeout=60"]
|
||||
38
backend/README.md
Normal file
38
backend/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Backend
|
||||
|
||||
## Stack
|
||||
- Django 5+ with Ninja API routers, JWT auth, and Ninja schemas.
|
||||
- PostgreSQL + Redis + Celery + Gunicorn orchestrated via Docker Compose.
|
||||
- Traefik handles TLS termination and routing to `/api`, `/admin`, `/static`, `/media`.
|
||||
- Metrics exporters (Prometheus, node exporter, PostgreSQL exporter) are wired in `docker-compose.yml`.
|
||||
|
||||
## Key apps
|
||||
|
||||
| App | Responsibilities |
|
||||
| --- | --- |
|
||||
| `users` | Custom `User` model, email verification, password resets, soft deletes. |
|
||||
| `blog` | Posts, comments, categories/tags, likes, admin delete/restore operations. |
|
||||
| `events` | Events, registrations, invitations, registration emails, Celery tasks. |
|
||||
| `payments` | Discount codes, payment tracking linked to registrations. |
|
||||
|
||||
## API highlights
|
||||
- **Authentication** (`/api/auth/*`): register, login, refresh, profile, delete profile picture, deleted users, filtered user lists.
|
||||
- **Blog** (`/api/blog/*`): posts/comments, soft delete/restore, likes, categories/tags APIs.
|
||||
- **Events** (`/api/events/*`): list, detail, create/update/delete, admin endpoints for event/registration detail and paginated/filterable registrations.
|
||||
- **Payments** (`/api/payments/*`): create payment, get by ref, discounts.
|
||||
|
||||
## Running locally
|
||||
```bash
|
||||
docker compose build backend
|
||||
docker compose run --rm backend python manage.py migrate
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
docker compose run --rm backend python manage.py test --settings=config.settings.test
|
||||
```
|
||||
|
||||
### Admin tooling
|
||||
- Ninja routers live under `backend/api/views`. Schemas are in `backend/api/schemas`.
|
||||
- JWT auth files: `backend/api/authentication.py`.
|
||||
- Celery configs in `backend/config/services/celery.py` and tasks (events, users, communications).
|
||||
41
backend/api/authentication.py
Normal file
41
backend/api/authentication.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.conf import settings
|
||||
|
||||
from ninja.security import HttpBearer
|
||||
from datetime import datetime, timedelta, UTC
|
||||
import jwt
|
||||
|
||||
from users.models import User
|
||||
|
||||
class JWTAuth(HttpBearer):
|
||||
def authenticate(self, request, token):
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
user_id = payload.get('user_id')
|
||||
if user_id:
|
||||
user = User.objects.get(id=user_id, is_email_verified=True, is_active=True)
|
||||
return user
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist):
|
||||
pass
|
||||
return None
|
||||
|
||||
def create_jwt_token(user):
|
||||
"""Create JWT token for user"""
|
||||
payload = {
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'exp': datetime.now(UTC) + timedelta(seconds=settings.JWT_ACCESS_TOKEN_LIFETIME),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
def create_refresh_token(user):
|
||||
"""Create refresh token for user"""
|
||||
payload = {
|
||||
'user_id': user.id,
|
||||
'type': 'refresh',
|
||||
'exp': datetime.now(UTC) + timedelta(seconds=settings.JWT_REFRESH_TOKEN_LIFETIME),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
jwt_auth = JWTAuth()
|
||||
31
backend/api/schemas/__init__.py
Normal file
31
backend/api/schemas/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Aggregate exports for API schemas and shared response payloads."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
from api.schemas.auth import *
|
||||
from api.schemas.blog import *
|
||||
from api.schemas.gallery import *
|
||||
from api.schemas.events import *
|
||||
from api.schemas.communications import *
|
||||
from api.schemas.certificates import *
|
||||
|
||||
|
||||
class MessageSchema(Schema):
|
||||
"""Basic success response containing a message."""
|
||||
message: str
|
||||
|
||||
|
||||
class ErrorSchema(Schema):
|
||||
"""Standard error payload with optional details."""
|
||||
error: str
|
||||
details: Optional[str] = None
|
||||
|
||||
|
||||
def rebuild_comment_schema() -> None:
|
||||
"""Ensure the self-referential CommentSchema is fully initialized."""
|
||||
CommentSchema.model_rebuild()
|
||||
|
||||
|
||||
rebuild_comment_schema()
|
||||
129
backend/api/schemas/auth.py
Normal file
129
backend/api/schemas/auth.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Authentication-related API schemas."""
|
||||
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import Optional
|
||||
|
||||
from users.models import User
|
||||
|
||||
|
||||
class UserRegistrationSchema(Schema):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
year_of_study: Optional[int] = None
|
||||
major: Optional[str] = None
|
||||
|
||||
class UserLoginSchema(Schema):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class UserProfileSchema(ModelSchema):
|
||||
profile_picture: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'student_id',
|
||||
'year_of_study',
|
||||
'major',
|
||||
'university',
|
||||
'bio',
|
||||
'date_joined',
|
||||
'is_email_verified',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'is_deleted',
|
||||
'deleted_at',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_major(obj):
|
||||
return obj.get_major_display()
|
||||
|
||||
@staticmethod
|
||||
def resolve_university(obj):
|
||||
return obj.get_university_display()
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture(obj, context):
|
||||
"""
|
||||
Resolves the absolute URL for the profile picture.
|
||||
`context` contains the request object, which is needed for build_absolute_uri.
|
||||
"""
|
||||
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 UserListSchema(ModelSchema):
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'date_joined',
|
||||
'major',
|
||||
'university',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_full_name(obj):
|
||||
return obj.get_full_name()
|
||||
|
||||
@staticmethod
|
||||
def resolve_major(obj):
|
||||
return obj.get_major_display()
|
||||
|
||||
@staticmethod
|
||||
def resolve_university(obj):
|
||||
return obj.get_university_display()
|
||||
|
||||
class UserUpdateSchema(Schema):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
year_of_study: Optional[int] = None
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
|
||||
class TokenSchema(Schema):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class TokenRefreshIn(Schema):
|
||||
refresh_token: str
|
||||
|
||||
class PasswordResetRequestSchema(Schema):
|
||||
email: str
|
||||
|
||||
class PasswordResetConfirmSchema(Schema):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
class UsernameCheckSchema(Schema):
|
||||
exists: bool
|
||||
87
backend/api/schemas/blog.py
Normal file
87
backend/api/schemas/blog.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 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
|
||||
70
backend/api/schemas/certificates.py
Normal file
70
backend/api/schemas/certificates.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]
|
||||
124
backend/api/schemas/communications.py
Normal file
124
backend/api/schemas/communications.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 api.schemas import AuthorSchema
|
||||
from 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
|
||||
247
backend/api/schemas/events.py
Normal file
247
backend/api/schemas/events.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 api.schemas.blog import AuthorSchema
|
||||
from events.models import Event, Registration
|
||||
from gallery.models import Gallery
|
||||
from 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
|
||||
27
backend/api/schemas/gallery.py
Normal file
27
backend/api/schemas/gallery.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Schemas for gallery resources."""
|
||||
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import Optional
|
||||
|
||||
from api.schemas.blog import AuthorSchema
|
||||
from 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
|
||||
35
backend/api/schemas/payments.py
Normal file
35
backend/api/schemas/payments.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
|
||||
16
backend/api/urls.py
Normal file
16
backend/api/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from ninja import Router
|
||||
|
||||
from api.views import *
|
||||
from api.views import certificates_router
|
||||
|
||||
router = Router()
|
||||
|
||||
router.add_router("auth/", auth_router, tags=["Authentication"])
|
||||
router.add_router("blog/", blog_router, tags=["Blog"])
|
||||
router.add_router("gallery/", gallery_router, tags=["Gallery"])
|
||||
router.add_router("events/", events_router, tags=["Events"])
|
||||
router.add_router("communications/", communications_router, tags=["Communications"])
|
||||
router.add_router("payments/", payments_router, tags=["Payments"])
|
||||
router.add_router("certificates/", certificates_router, tags=["Certificates"])
|
||||
router.add_router("meta/", meta_router, tags=["Meta"])
|
||||
router.add_router("/", health_router, tags=["Health"])
|
||||
9
backend/api/views/__init__.py
Normal file
9
backend/api/views/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from api.views.auth import auth_router
|
||||
from api.views.blog import blog_router
|
||||
from api.views.gallery import gallery_router
|
||||
from api.views.events import events_router
|
||||
from api.views.certificates import certificates_router
|
||||
from api.views.communications import communications_router
|
||||
from api.views.payments import payments_router
|
||||
from api.views.meta import meta_router
|
||||
from api.views.health import health_router
|
||||
397
backend/api/views/auth.py
Normal file
397
backend/api/views/auth.py
Normal file
@@ -0,0 +1,397 @@
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import uuid
|
||||
import jwt
|
||||
from ninja import Query, Router
|
||||
|
||||
from users.models import User, Major, University
|
||||
from users.tasks import send_verification_email, send_password_reset_email
|
||||
from api.authentication import create_jwt_token, create_refresh_token, jwt_auth
|
||||
from api.schemas import (
|
||||
UserRegistrationSchema, UserLoginSchema, UserProfileSchema,
|
||||
UserUpdateSchema, TokenSchema, TokenRefreshIn, MessageSchema, ErrorSchema,
|
||||
PasswordResetRequestSchema, PasswordResetConfirmSchema, UsernameCheckSchema,
|
||||
UserListSchema
|
||||
)
|
||||
|
||||
auth_router = Router()
|
||||
|
||||
def _get_major_from_code(code: str | None):
|
||||
if not code:
|
||||
return None
|
||||
return Major.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
def _get_university_from_code(code: str | None):
|
||||
if not code:
|
||||
return None
|
||||
return University.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
@auth_router.post("/register", response={201: MessageSchema, 400: ErrorSchema})
|
||||
def register(request, data: UserRegistrationSchema):
|
||||
"""Register a new user"""
|
||||
try:
|
||||
if data.student_id and len(str(data.student_id)) < 10:
|
||||
return 400, {"error": "Student ID must be at least 10 characters long."}
|
||||
|
||||
major_obj = None
|
||||
if data.major:
|
||||
major_obj = _get_major_from_code(data.major)
|
||||
if not major_obj:
|
||||
return 400, {"error": "Selected major is not recognized."}
|
||||
|
||||
university_obj = None
|
||||
if data.university:
|
||||
university_obj = _get_university_from_code(data.university)
|
||||
if not university_obj:
|
||||
return 400, {"error": "Selected university is not recognized."}
|
||||
|
||||
if User.objects.filter(username=data.username).exists():
|
||||
return 400, {"error": "Username is already in use."}
|
||||
|
||||
if User.objects.filter(email=data.email).exists():
|
||||
return 400, {"error": "Email is already registered."}
|
||||
|
||||
if (
|
||||
data.student_id
|
||||
and university_obj
|
||||
and User.objects.filter(
|
||||
university=university_obj, student_id=data.student_id
|
||||
).exists()
|
||||
):
|
||||
return 400, {"error": "This student ID is already registered at that university."}
|
||||
|
||||
User.objects.create_user(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password=data.password,
|
||||
student_id=data.student_id,
|
||||
first_name=data.first_name or "",
|
||||
last_name=data.last_name or "",
|
||||
year_of_study=data.year_of_study,
|
||||
major=major_obj,
|
||||
university=university_obj,
|
||||
)
|
||||
|
||||
return 201, {"message": "Registration successful. Please check your inbox to verify your email."}
|
||||
|
||||
except Exception as e:
|
||||
return 400, {
|
||||
"error": "Unable to register user.",
|
||||
"details": str(e),
|
||||
}
|
||||
|
||||
@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema})
|
||||
def login(request, data: UserLoginSchema):
|
||||
"""Login user and return JWT tokens"""
|
||||
user = authenticate(email=data.email, password=data.password)
|
||||
|
||||
if not user:
|
||||
return 401, {"error": "ایمیل یا رمز عبور نادرست است."}
|
||||
|
||||
if not user.is_email_verified:
|
||||
return 401, {"error": "برای ورود، ابتدا ایمیل خود را تأیید کنید."}
|
||||
|
||||
if not user.is_active:
|
||||
return 401, {"error": "حساب کاربری شما غیرفعال است."}
|
||||
|
||||
access_token = create_jwt_token(user)
|
||||
refresh_token = create_refresh_token(user)
|
||||
|
||||
return 200, {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
|
||||
@auth_router.post("/refresh", response={200: TokenSchema, 401: ErrorSchema})
|
||||
def refresh_tokens(request, data: TokenRefreshIn):
|
||||
"""Exchange a valid refresh token for a new access (and refresh) token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
data.refresh_token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
if payload.get("type") != "refresh":
|
||||
return 401, {"error": "نوع توکن نامعتبر است."}
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
return 401, {"error": "دادههای توکن نامعتبر است."}
|
||||
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
|
||||
if not user.is_email_verified:
|
||||
return 401, {"error": "برای استفاده، ابتدا ایمیل خود را تأیید کنید."}
|
||||
|
||||
if not user.is_active:
|
||||
return 401, {"error": "حساب کاربری شما غیرفعال است."}
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return 401, {"error": "رفرشتوکن منقضی شده است."}
|
||||
|
||||
except jwt.InvalidTokenError:
|
||||
return 401, {"error": "رفرشتوکن نامعتبر است."}
|
||||
|
||||
access_token = create_jwt_token(user)
|
||||
refresh_token = create_refresh_token(user)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
@auth_router.get("/verify-email/{token}", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def verify_email(request, token: str):
|
||||
"""Verify user email with token"""
|
||||
try:
|
||||
user = get_object_or_404(User, email_verification_token=token)
|
||||
|
||||
if user.is_email_verified:
|
||||
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
|
||||
|
||||
user.is_email_verified = True
|
||||
user.save(update_fields=['is_email_verified'])
|
||||
|
||||
return 200, {"message": "ایمیل شما با موفقیت تأیید شد."}
|
||||
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "توکن تأیید نامعتبر است."}
|
||||
|
||||
@auth_router.post("/resend-verification", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def resend_verification(request, email: str):
|
||||
"""Resend verification email"""
|
||||
try:
|
||||
user = get_object_or_404(User, email=email)
|
||||
|
||||
if user.is_email_verified:
|
||||
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
|
||||
|
||||
# Generate new token
|
||||
user.regenerate_verification_token()
|
||||
user.email_verification_sent_at = timezone.now()
|
||||
user.save(update_fields=['email_verification_sent_at'])
|
||||
|
||||
# Send verification email
|
||||
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}"
|
||||
send_verification_email.delay(user.id, verification_url)
|
||||
|
||||
return 200, {"message": "ایمیل تأیید برای شما ارسال شد."}
|
||||
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "کاربر یافت نشد."}
|
||||
|
||||
@auth_router.get("/profile", response=UserProfileSchema, auth=jwt_auth)
|
||||
def get_profile(request):
|
||||
"""Get current user profile"""
|
||||
return request.auth
|
||||
|
||||
@auth_router.put("/profile", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def update_profile(request, data: UserUpdateSchema):
|
||||
"""Update current user profile"""
|
||||
user = request.auth
|
||||
payload = data.dict(exclude_unset=True)
|
||||
|
||||
if "major" in payload:
|
||||
code = payload.pop("major")
|
||||
if code:
|
||||
major_obj = _get_major_from_code(code)
|
||||
if not major_obj:
|
||||
return 400, {"error": "UcO_ O<>OrU?UOU? O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
|
||||
payload["major"] = major_obj
|
||||
else:
|
||||
payload["major"] = None
|
||||
|
||||
if "university" in payload:
|
||||
code = payload.pop("university")
|
||||
if code:
|
||||
uni_obj = _get_university_from_code(code)
|
||||
if not uni_obj:
|
||||
return 400, {"error": "UcO U.U^OO<>O_ O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
|
||||
payload["university"] = uni_obj
|
||||
else:
|
||||
payload["university"] = None
|
||||
|
||||
for field, value in payload.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
user.save()
|
||||
return 200, user
|
||||
|
||||
@auth_router.post("/profile/picture", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def upload_profile_picture(request):
|
||||
"""Upload profile picture"""
|
||||
if 'file' not in request.FILES:
|
||||
return 400, {"error": "فایلی ارسال نشده است."}
|
||||
|
||||
file = request.FILES['file']
|
||||
|
||||
# Validate file type
|
||||
if not file.content_type.startswith('image/'):
|
||||
return 400, {"error": "فایل باید از نوع تصویر باشد."}
|
||||
|
||||
# Validate file size (5MB max)
|
||||
if file.size > 5 * 1024 * 1024:
|
||||
return 400, {"error": "حجم فایل باید کمتر از ۵ مگابایت باشد."}
|
||||
|
||||
user = request.auth
|
||||
|
||||
# Delete old profile picture if exists
|
||||
if user.profile_picture:
|
||||
default_storage.delete(user.profile_picture.name)
|
||||
|
||||
# Save new profile picture
|
||||
filename = f"profile_pictures/{user.id}_{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
|
||||
user.profile_picture.save(filename, ContentFile(file.read()))
|
||||
|
||||
return 200, {"message": "تصویر پروفایل با موفقیت بهروزرسانی شد."}
|
||||
|
||||
@auth_router.delete("/profile/picture", response={200: MessageSchema}, auth=jwt_auth)
|
||||
def delete_profile_picture(request):
|
||||
"""Delete current user's profile picture"""
|
||||
user = request.auth
|
||||
|
||||
if user.profile_picture:
|
||||
default_storage.delete(user.profile_picture.name)
|
||||
user.profile_picture = None
|
||||
user.save(update_fields=['profile_picture'])
|
||||
|
||||
return 200, {"message": "تصویر پروفایل با موفقیت حذف شد."}
|
||||
|
||||
@auth_router.post("/request-password-reset", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def request_password_reset(request, data: PasswordResetRequestSchema):
|
||||
"""Request a password reset email"""
|
||||
try:
|
||||
user = get_object_or_404(User, email=data.email)
|
||||
user.set_password_reset_token()
|
||||
|
||||
reset_url = f"{settings.FRONTEND_PASSWORD_RESET_PAGE}/{user.password_reset_token}"
|
||||
send_password_reset_email.delay(user.id, reset_url)
|
||||
|
||||
# پیام عمومیِ یکسان برای جلوگیری از افشای وجود/عدم وجود ایمیل
|
||||
return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."}
|
||||
|
||||
except User.DoesNotExist:
|
||||
return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."}
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "درخواست بازنشانی رمز عبور انجام نشد.", "details": str(e)}
|
||||
|
||||
@auth_router.post("/reset-password-confirm", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def reset_password_confirm(request, data: PasswordResetConfirmSchema):
|
||||
"""Confirm password reset with token and new password"""
|
||||
try:
|
||||
user = get_object_or_404(User, password_reset_token=data.token)
|
||||
|
||||
if user.password_reset_token_expires_at < timezone.now():
|
||||
user.password_reset_token = None
|
||||
user.password_reset_token_expires_at = None
|
||||
user.save(update_fields=['password_reset_token', 'password_reset_token_expires_at'])
|
||||
return 400, {"error": "زمان استفاده از لینک تغییر رمز عبور به پایان رسیده است. لطفاً دوباره اقدام کنید."}
|
||||
|
||||
user.set_password(data.new_password)
|
||||
user.password_reset_token = None
|
||||
user.password_reset_token_expires_at = None
|
||||
user.save(update_fields=['password', 'password_reset_token', 'password_reset_token_expires_at'])
|
||||
|
||||
return 200, {"message": "رمز عبور شما با موفقیت تغییر کرد."}
|
||||
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "توکن بازنشانی رمز عبور نامعتبر یا منقضی شده است."}
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "تغییر رمز عبور انجام نشد.", "details": str(e)}
|
||||
|
||||
@auth_router.get("/users/deleted", response={200: List[UserProfileSchema], 403: ErrorSchema}, auth=jwt_auth)
|
||||
def list_deleted_users(request):
|
||||
"""List soft-deleted users via the dedicated manager (Admin/Committee only)."""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
return User.deleted_objects.all()
|
||||
|
||||
@auth_router.post("/users/{user_id}/restore", response={200: MessageSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
||||
def restore_user(request, user_id: int):
|
||||
"""Restore a soft-deleted user (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
try:
|
||||
user = User.deleted_objects.get(id=user_id)
|
||||
user.restore()
|
||||
return 200, {"message": f"کاربر {user.username} با موفقیت بازیابی شد."}
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "کاربر یافت نشد یا حذف نرم نشده است."}
|
||||
except Exception as e:
|
||||
return 400, {"error": "بازیابی کاربر انجام نشد.", "details": str(e)}
|
||||
|
||||
@auth_router.get("/users", response={200: List[UserListSchema], 403: ErrorSchema}, auth=jwt_auth)
|
||||
def list_users(
|
||||
request,
|
||||
search: str | None = Query(None),
|
||||
role: str | None = Query(None, description="staff or superuser"),
|
||||
student_id: str | None = Query(None),
|
||||
university: str | None = Query(None),
|
||||
major: str | None = Query(None),
|
||||
is_active: str | None = Query(None, description="true or false"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
user = request.auth
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
queryset = User.objects.order_by("-date_joined")
|
||||
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search)
|
||||
| Q(email__icontains=search)
|
||||
| Q(first_name__icontains=search)
|
||||
| Q(last_name__icontains=search)
|
||||
)
|
||||
|
||||
if role == "staff":
|
||||
queryset = queryset.filter(is_staff=True)
|
||||
elif role == "superuser":
|
||||
queryset = queryset.filter(is_superuser=True)
|
||||
|
||||
if student_id:
|
||||
queryset = queryset.filter(student_id__icontains=student_id)
|
||||
|
||||
if university:
|
||||
queryset = queryset.filter(
|
||||
Q(university__code__icontains=university) | Q(university__name__icontains=university)
|
||||
)
|
||||
|
||||
if major:
|
||||
queryset = queryset.filter(
|
||||
Q(major__code__icontains=major) | Q(major__name__icontains=major)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
if is_active.lower() in ("true", "1"):
|
||||
queryset = queryset.filter(is_active=True)
|
||||
elif is_active.lower() in ("false", "0"):
|
||||
queryset = queryset.filter(is_active=False)
|
||||
|
||||
return queryset[offset : offset + limit]
|
||||
|
||||
@auth_router.get("/check-username", response=UsernameCheckSchema)
|
||||
def check_username_availability(request, username: str):
|
||||
"""Check if a username is available for registration"""
|
||||
exists = User.objects.filter(username=username).exists()
|
||||
return {"exists": exists}
|
||||
|
||||
|
||||
299
backend/api/views/blog.py
Normal file
299
backend/api/views/blog.py
Normal file
@@ -0,0 +1,299 @@
|
||||
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 users.models import User
|
||||
from blog.models import Post, Category, Tag, Comment, Like
|
||||
from api.authentication import jwt_auth
|
||||
from api.schemas import (
|
||||
PostListSchema, PostDetailSchema, PostCreateSchema,
|
||||
CategorySchema, TagSchema, CommentSchema, CommentCreateSchema,
|
||||
MessageSchema, ErrorSchema
|
||||
)
|
||||
|
||||
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."}
|
||||
138
backend/api/views/certificates.py
Normal file
138
backend/api/views/certificates.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 api.authentication import jwt_auth
|
||||
from api.schemas.certificates import (
|
||||
CertificateTemplateOut,
|
||||
CertificateGenerationPayload,
|
||||
CertificateGenerationResponse,
|
||||
CertificateVerificationOut,
|
||||
SkillSchema,
|
||||
UserCertificateOut,
|
||||
)
|
||||
from 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()],
|
||||
)
|
||||
329
backend/api/views/communications.py
Normal file
329
backend/api/views/communications.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 communications.models import (
|
||||
Announcement, NewsletterSubscription, PushNotificationDevice,
|
||||
AnnouncementType, AnnouncementPriority
|
||||
)
|
||||
from communications.utils import (
|
||||
send_announcement_email, send_newsletter_confirmation,
|
||||
get_announcement_recipients
|
||||
)
|
||||
from communications.push_notifications import push_service
|
||||
from api.schemas import (
|
||||
AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema,
|
||||
NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema,
|
||||
PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema,
|
||||
PushNotificationSchema, MessageResponseSchema,
|
||||
AnnouncementStatsSchema, NewsletterStatsSchema
|
||||
)
|
||||
from api.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]
|
||||
371
backend/api/views/events.py
Normal file
371
backend/api/views/events.py
Normal file
@@ -0,0 +1,371 @@
|
||||
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 api.authentication import jwt_auth
|
||||
from events.models import Event, Registration
|
||||
from payments.models import DiscountCode
|
||||
from api.schemas import (
|
||||
EventSchema,
|
||||
EventCreateSchema,
|
||||
EventUpdateSchema,
|
||||
EventListSchema,
|
||||
RegistrationSchema,
|
||||
RegistrationStatusUpdateSchema,
|
||||
RegisterationDetailSchema,
|
||||
MyEventRegistrationOut,
|
||||
RegistrationStatusOut,
|
||||
EventBriefSchema,
|
||||
EventAdminDetailSchema,
|
||||
PaginatedRegistrationSchema,
|
||||
MessageSchema,
|
||||
ErrorSchema,
|
||||
RegistrationCreateSchema,
|
||||
)
|
||||
|
||||
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
|
||||
127
backend/api/views/gallery.py
Normal file
127
backend/api/views/gallery.py
Normal file
@@ -0,0 +1,127 @@
|
||||
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 gallery.models import Gallery
|
||||
from gallery.tasks import process_uploaded_image
|
||||
from api.authentication import jwt_auth
|
||||
from api.schemas import GallerySchema, GalleryCreateSchema, MessageSchema, ErrorSchema
|
||||
|
||||
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."}
|
||||
15
backend/api/views/health.py
Normal file
15
backend/api/views/health.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from ninja import Router
|
||||
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
|
||||
health_router = Router()
|
||||
|
||||
@health_router.get("/health")
|
||||
def health(request):
|
||||
try:
|
||||
with connection.cursor() as c:
|
||||
c.execute("SELECT 1;")
|
||||
return {"status": "ok", "time": timezone.now().isoformat()}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}, 500
|
||||
15
backend/api/views/meta.py
Normal file
15
backend/api/views/meta.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from ninja import Router
|
||||
|
||||
from users.models import Major, University
|
||||
|
||||
meta_router = Router(tags=['meta'])
|
||||
|
||||
@meta_router.get("/majors")
|
||||
def list_majors(request):
|
||||
majors = Major.objects.filter(is_deleted=False, is_active=True).order_by("name")
|
||||
return [{"id": m.id, "code": m.code, "label": m.name} for m in majors]
|
||||
|
||||
@meta_router.get("/universities")
|
||||
def list_universities(request):
|
||||
universities = University.objects.filter(is_deleted=False, is_active=True).order_by("name")
|
||||
return [{"id": u.id, "code": u.code, "label": u.name} for u in universities]
|
||||
240
backend/api/views/payments.py
Normal file
240
backend/api/views/payments.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 payments.models import Payment, DiscountCode
|
||||
from events.models import Event, Registration
|
||||
from api.authentication import jwt_auth
|
||||
from api.schemas.payments 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, "کد تخفیف معتبر نیست")
|
||||
159
backend/blog/admin.py
Normal file
159
backend/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 blog.models import Category, Tag, Post, Comment, Like
|
||||
from blog.resources import PostResource, CategoryResource
|
||||
from utils.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')
|
||||
5
backend/blog/apps.py
Normal file
5
backend/blog/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'blog'
|
||||
672
backend/blog/fixtures/blog.json
Normal file
672
backend/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
backend/blog/migrations/0001_initial.py
Normal file
89
backend/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
backend/blog/migrations/0002_initial.py
Normal file
78
backend/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
backend/blog/migrations/__init__.py
Normal file
0
backend/blog/migrations/__init__.py
Normal file
137
backend/blog/models.py
Normal file
137
backend/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 utils.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
backend/blog/resources.py
Normal file
32
backend/blog/resources.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from import_export import resources, fields
|
||||
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||
|
||||
from users.models import User
|
||||
from 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')
|
||||
BIN
backend/celerybeat-schedule
Normal file
BIN
backend/celerybeat-schedule
Normal file
Binary file not shown.
1
backend/certificates/__init__.py
Normal file
1
backend/certificates/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
""""""
|
||||
24
backend/certificates/admin.py
Normal file
24
backend/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',)
|
||||
6
backend/certificates/apps.py
Normal file
6
backend/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 = 'certificates'
|
||||
80
backend/certificates/migrations/0001_initial.py
Normal file
80
backend/certificates/migrations/0001_initial.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# Generated by Django 4.2.13 on 2025-11-18 09:47
|
||||
|
||||
import certificates.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
|
||||
|
||||
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=certificates.models._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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/certificates/migrations/__init__.py
Normal file
1
backend/certificates/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
""""""
|
||||
316
backend/certificates/models.py
Normal file
316
backend/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 events.models import Registration
|
||||
from users.models import User
|
||||
from utils.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
backend/communications/admin.py
Normal file
122
backend/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 utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
from 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',)
|
||||
}),
|
||||
)
|
||||
7
backend/communications/apps.py
Normal file
7
backend/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 = 'communications'
|
||||
verbose_name = 'Communications'
|
||||
536
backend/communications/fixtures/communications.json
Normal file
536
backend/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
backend/communications/migrations/0001_initial.py
Normal file
78
backend/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
backend/communications/migrations/0002_initial.py
Normal file
37
backend/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
backend/communications/migrations/__init__.py
Normal file
0
backend/communications/migrations/__init__.py
Normal file
142
backend/communications/models.py
Normal file
142
backend/communications/models.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from utils.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
backend/communications/push_notifications.py
Normal file
194
backend/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 communications.models import PushNotificationDevice
|
||||
from 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
backend/communications/resources.py
Normal file
56
backend/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 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
backend/communications/tasks.py
Normal file
278
backend/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 events.models import Event, Registration
|
||||
from communications.models import Announcement, NewsletterSubscription
|
||||
from communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients
|
||||
from 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
backend/communications/utils.py
Normal file
140
backend/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 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
|
||||
3
backend/config/__init__.py
Normal file
3
backend/config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from config.services.celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
7
backend/config/asgi.py
Normal file
7
backend/config/asgi.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
|
||||
|
||||
application = get_asgi_application()
|
||||
56
backend/config/services/celery.py
Normal file
56
backend/config/services/celery.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Celery application configuration and scheduling."""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from decouple import config
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
|
||||
|
||||
app = Celery('config')
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
app.autodiscover_tasks()
|
||||
|
||||
app.conf.update(
|
||||
broker_url=config('REDIS_URL', default='redis://localhost:6379/0'),
|
||||
result_backend=config('REDIS_URL', default='redis://localhost:6379/0'),
|
||||
task_serializer='json',
|
||||
accept_content=['json'],
|
||||
result_serializer='json',
|
||||
timezone='UTC',
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_time_limit=30 * 60,
|
||||
task_soft_time_limit=60,
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_max_tasks_per_child=1000,
|
||||
)
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
'send-event-reminders': {
|
||||
'task': 'communications.tasks.send_event_reminders',
|
||||
'schedule': crontab(minute=0, hour='*/1'),
|
||||
'description': 'Runs hourly to notify about upcoming events.',
|
||||
},
|
||||
'send-weekly-newsletter': {
|
||||
'task': 'communications.tasks.send_weekly_newsletter',
|
||||
'schedule': crontab(hour=9, minute=0, day_of_week=1),
|
||||
'description': 'Runs every Monday at 09:00 UTC.',
|
||||
},
|
||||
'cleanup-expired-tokens': {
|
||||
'task': 'communications.tasks.cleanup_expired_tokens',
|
||||
'schedule': crontab(hour=2, minute=0),
|
||||
'description': 'Runs daily at 02:00 UTC.',
|
||||
},
|
||||
'process-scheduled-announcements': {
|
||||
'task': 'communications.tasks.process_scheduled_announcements',
|
||||
'schedule': crontab(minute='*/15'),
|
||||
'description': 'Runs every 15 minutes to dispatch scheduled announcements.',
|
||||
},
|
||||
}
|
||||
|
||||
EMAIL_TIMEOUT_SECONDS = 10
|
||||
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 20
|
||||
CELERY_TASK_TIME_LIMIT = 30
|
||||
14
backend/config/services/location.py
Normal file
14
backend/config/services/location.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Configuration for Django location fields backed by OpenStreetMap."""
|
||||
|
||||
DEFAULT_MAP_CENTER = [37.0629098, 50.4232464]
|
||||
|
||||
LOCATION_FIELD = {
|
||||
'map.provider': 'openstreetmap',
|
||||
'map.zoom': 13,
|
||||
'map.center': DEFAULT_MAP_CENTER,
|
||||
'map.language': 'fa',
|
||||
'search.provider': 'nominatim',
|
||||
'search.url': 'https://nominatim.openstreetmap.org/search/',
|
||||
'search.params': {'format': 'json', 'addressdetails': 1},
|
||||
'search.headers': {'User-Agent': 'Django CS Association App'},
|
||||
}
|
||||
12
backend/config/services/notifications.py
Normal file
12
backend/config/services/notifications.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from decouple import config
|
||||
|
||||
# Added VAPID configuration for web push notifications
|
||||
# VAPID Configuration for Web Push Notifications
|
||||
VAPID_PUBLIC_KEY = config('VAPID_PUBLIC_KEY', default='')
|
||||
VAPID_PRIVATE_KEY = config('VAPID_PRIVATE_KEY', default='')
|
||||
VAPID_CLAIMS = {
|
||||
"sub": config('VAPID_SUBJECT', default='mailto:admin@csassociation.com')
|
||||
}
|
||||
|
||||
# Site URL for push notification links
|
||||
SITE_URL = config('SITE_URL', default='http://localhost:8000')
|
||||
94
backend/config/services/unfold.py
Normal file
94
backend/config/services/unfold.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from django.conf import settings
|
||||
from django.templatetags.static import static
|
||||
|
||||
# Django Unfold Configuration
|
||||
UNFOLD = {
|
||||
"SITE_TITLE": "GuilanCE Association Admin",
|
||||
"SITE_HEADER": "GuilanCE Association",
|
||||
"SITE_URL": "/",
|
||||
"SITE_ICON": lambda request: static("img/logo.png"),
|
||||
# "SITE_LOGO": lambda request: static("img/logo.png"),
|
||||
"SITE_SYMBOL": "speed",
|
||||
"SHOW_HISTORY": True,
|
||||
"SHOW_VIEW_ON_SITE": True,
|
||||
# "SHOW_BACK_BUTTON": True,
|
||||
"ENVIRONMENT": "config.services.unfold.environment_callback",
|
||||
"LOGIN": {
|
||||
"image": lambda request: request.build_absolute_uri("/static/images/login-bg.jpg"),
|
||||
"redirect_after": lambda request: request.build_absolute_uri("/admin/"),
|
||||
},
|
||||
"STYLES": [
|
||||
lambda request: request.build_absolute_uri("/static/css/styles.css"),
|
||||
],
|
||||
"SCRIPTS": [
|
||||
lambda request: request.build_absolute_uri("/static/js/scripts.js"),
|
||||
],
|
||||
"COLORS": {
|
||||
"primary": {
|
||||
"50": "250 245 255",
|
||||
"100": "243 232 255",
|
||||
"200": "233 213 255",
|
||||
"300": "216 180 254",
|
||||
"400": "196 144 254",
|
||||
"500": "168 85 247",
|
||||
"600": "147 51 234",
|
||||
"700": "126 34 206",
|
||||
"800": "107 33 168",
|
||||
"900": "88 28 135",
|
||||
},
|
||||
},
|
||||
"EXTENSIONS": {
|
||||
"modeltranslation": {
|
||||
"flags": {
|
||||
"en": "🇺🇸",
|
||||
"fa": "🇮🇷",
|
||||
},
|
||||
},
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"show_search": True,
|
||||
"show_all_applications": True,
|
||||
"navigation": [
|
||||
{
|
||||
"title": "Navigation",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "Dashboard",
|
||||
"icon": "dashboard",
|
||||
"link": lambda request: request.build_absolute_uri("/admin/"),
|
||||
# "badge": 3
|
||||
},
|
||||
{
|
||||
"title": "Users",
|
||||
"icon": "account_circle",
|
||||
"link": lambda request: request.build_absolute_uri("/admin/users/user/"),
|
||||
},
|
||||
{
|
||||
"title": "Blog",
|
||||
"icon": "post",
|
||||
"link": lambda request: request.build_absolute_uri("/admin/blog/"),
|
||||
},
|
||||
{
|
||||
"title": "Events",
|
||||
"icon": "event",
|
||||
"link": lambda request: request.build_absolute_uri("/admin/events/"),
|
||||
},
|
||||
{
|
||||
"title": "Gallery",
|
||||
"icon": "filter",
|
||||
"link": lambda request: request.build_absolute_uri("/admin/gallery/gallery/"),
|
||||
},
|
||||
{
|
||||
"title": "Communications",
|
||||
"icon": "call",
|
||||
"link": lambda request: request.build_absolute_uri("/admin/communications/"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def environment_callback(request):
|
||||
return ["Development", "warning"] if settings.DEBUG else ["Production", "success"]
|
||||
10
backend/config/services/zarinpal.py
Normal file
10
backend/config/services/zarinpal.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from decouple import config
|
||||
|
||||
ZARINPAL_MERCHANT_ID = config('ZARINPAL_MERCHANT_ID', default='')
|
||||
ZARINPAL_USE_SANDBOX = config('ZARINPAL_USE_SANDBOX', default=False, cast=bool)
|
||||
|
||||
ZARINPAL_API_BASE = "https://sandbox.zarinpal.com" if ZARINPAL_USE_SANDBOX else "https://payment.zarinpal.com"
|
||||
ZARINPAL_REQUEST_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/request.json"
|
||||
ZARINPAL_VERIFY_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/verify.json"
|
||||
ZARINPAL_STARTPAY = f"{ZARINPAL_API_BASE}/pg/StartPay/"
|
||||
ZARINPAL_CALLBACK_URL = config('ZARINPAL_CALLBACK_URL', default='http://localhost:8000/api/payments/callback')
|
||||
233
backend/config/settings/base.py
Normal file
233
backend/config/settings/base.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from decouple import config
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
SECRET_KEY = config('SECRET_KEY')
|
||||
|
||||
DEBUG = config('DEBUG', default=False, cast=bool)
|
||||
|
||||
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
|
||||
|
||||
DJANGO_APPS = [
|
||||
'unfold',
|
||||
'unfold.contrib.filters',
|
||||
'unfold.contrib.forms',
|
||||
'unfold.contrib.import_export',
|
||||
'unfold.contrib.location_field',
|
||||
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
]
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
'corsheaders',
|
||||
'import_export',
|
||||
'simplemde',
|
||||
'location_field',
|
||||
"django_prometheus",
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
'users',
|
||||
'blog',
|
||||
'gallery',
|
||||
'events',
|
||||
'certificates',
|
||||
'communications',
|
||||
'payments',
|
||||
'utils',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django_prometheus.middleware.PrometheusAfterMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': config('DB_ENGINE', 'django.db.backends.sqlite3'),
|
||||
'NAME': config('DB_NAME', BASE_DIR / 'db.sqlite3'),
|
||||
'USER': config('DB_USER'),
|
||||
'PASSWORD': config('DB_PASSWORD'),
|
||||
'HOST': config('DB_HOST', default='localhost'),
|
||||
'PORT': config('DB_PORT', default='5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'Asia/Tehran'
|
||||
|
||||
LANGUAGES = [
|
||||
('en', 'English'),
|
||||
('fa', 'فارسی'),
|
||||
]
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
# For RTL support in admin
|
||||
LOCALE_PATHS = [BASE_DIR / 'locale']
|
||||
|
||||
STATIC_URL = config('STATIC_URL', default='/static/')
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||
|
||||
MEDIA_URL = config('MEDIA_URL', default='/media/')
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
|
||||
# CORS Settings
|
||||
CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', default='https://east-guilan-ce.ir').split(',')
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CSRF_TRUSTED_ORIGINS = ["https://east-guilan-ce.ir"]
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
# Email Configuration
|
||||
EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend')
|
||||
EMAIL_HOST = config('EMAIL_HOST', default='')
|
||||
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
|
||||
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
|
||||
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
|
||||
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
|
||||
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='webmaster@localhost')
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET_KEY = config('JWT_SECRET_KEY', default=SECRET_KEY)
|
||||
JWT_ALGORITHM = config('JWT_ALGORITHM', default='HS256')
|
||||
JWT_ACCESS_TOKEN_LIFETIME = config('JWT_ACCESS_TOKEN_LIFETIME', default=3600, cast=int)
|
||||
JWT_REFRESH_TOKEN_LIFETIME = config('JWT_REFRESH_TOKEN_LIFETIME', default=86400, cast=int)
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL = config('REDIS_URL', default='redis://localhost:6379/0')
|
||||
|
||||
# Cache Configuration
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django_prometheus.cache.backends.redis.RedisCache',
|
||||
'LOCATION': REDIS_URL,
|
||||
}
|
||||
}
|
||||
|
||||
# Celery Configuration
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
|
||||
|
||||
# Logging Configuration
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'file': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': BASE_DIR / 'logs' / 'django.log',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'console': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'simple',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['file', 'console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'apps': {
|
||||
'handlers': ['file', 'console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Create logs directory
|
||||
os.makedirs(BASE_DIR / 'logs', exist_ok=True)
|
||||
|
||||
BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/')
|
||||
FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/')
|
||||
FRONTEND_PASSWORD_RESET_PAGE = config('FRONTEND_PASSWORD_RESET_PAGE', default='http://localhost:3000/api/auth/reset-password-confirm/')
|
||||
FRONTEND_CALLBACK_URL = config('FRONTEND_CALLBACK_URL', default='http://localhost:3000/payments/result')
|
||||
|
||||
DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql"
|
||||
|
||||
from config.services.unfold import *
|
||||
from config.services.location import *
|
||||
from config.services.notifications import *
|
||||
from config.services.zarinpal import *
|
||||
18
backend/config/settings/development.py
Normal file
18
backend/config/settings/development.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from .base import *
|
||||
|
||||
DEBUG = True
|
||||
|
||||
# Additional development settings
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
]
|
||||
|
||||
# Email backend for development
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
|
||||
# Disable caching in development
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
|
||||
}
|
||||
}
|
||||
21
backend/config/settings/production.py
Normal file
21
backend/config/settings/production.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from .base import *
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Security settings for production
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_REDIRECT_EXEMPT = []
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
|
||||
# 🔹 Exempt /metrics from the redirect so Prometheus can scrape over HTTP
|
||||
SECURE_REDIRECT_EXEMPT = [r"^metrics$"]
|
||||
|
||||
# Logging for production
|
||||
# LOGGING['handlers']['file']['filename'] = '/var/log/django/django.log'
|
||||
46
backend/config/settings/test.py
Normal file
46
backend/config/settings/test.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from .base import *
|
||||
|
||||
# Lightweight defaults keep local/CI test runs isolated from production infra.
|
||||
|
||||
TEST_DB_ENGINE = config("TEST_DB_ENGINE", default="django.db.backends.sqlite3")
|
||||
TEST_DB_NAME = config("TEST_DB_NAME", default=str(BASE_DIR / "db.test.sqlite3"))
|
||||
TEST_DB_USER = config("TEST_DB_USER", default="")
|
||||
TEST_DB_PASSWORD = config("TEST_DB_PASSWORD", default="")
|
||||
TEST_DB_HOST = config("TEST_DB_HOST", default="")
|
||||
TEST_DB_PORT = config("TEST_DB_PORT", default="")
|
||||
|
||||
DATABASES["default"] = {
|
||||
"ENGINE": TEST_DB_ENGINE,
|
||||
"NAME": TEST_DB_NAME,
|
||||
"USER": TEST_DB_USER,
|
||||
"PASSWORD": TEST_DB_PASSWORD,
|
||||
"HOST": TEST_DB_HOST,
|
||||
"PORT": TEST_DB_PORT,
|
||||
}
|
||||
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# Tests should not enforce HTTPS-only cookies to simplify client simulations.
|
||||
CSRF_COOKIE_SECURE = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
|
||||
# Silence verbose INFO logs (e.g., Celery task output) during tests.
|
||||
LOGGING["handlers"]["console"]["level"] = "ERROR" # type: ignore[index]
|
||||
LOGGING["root"]["level"] = "ERROR" # type: ignore[index]
|
||||
if "django" in LOGGING["loggers"]:
|
||||
LOGGING["loggers"]["django"]["level"] = "ERROR" # type: ignore[index]
|
||||
if "apps" in LOGGING["loggers"]:
|
||||
LOGGING["loggers"]["apps"]["level"] = "ERROR" # type: ignore[index]
|
||||
24
backend/config/urls.py
Normal file
24
backend/config/urls.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from ninja import NinjaAPI
|
||||
from api.urls import router as api_router
|
||||
|
||||
api = NinjaAPI(
|
||||
title="CS Association API",
|
||||
version="1.0.0",
|
||||
description="API for University Computer Science Association",
|
||||
)
|
||||
|
||||
api.add_router("", api_router)
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', api.urls),
|
||||
path("", include("django_prometheus.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
7
backend/config/wsgi.py
Normal file
7
backend/config/wsgi.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
|
||||
|
||||
application = get_wsgi_application()
|
||||
26
backend/docker/entrypoint.sh
Normal file
26
backend/docker/entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${DJANGO_WSGI_MODULE:=config.wsgi:application}"
|
||||
: "${DATABASE_URL:=postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-app}}"
|
||||
|
||||
# wait for db
|
||||
host="db"
|
||||
port="5432"
|
||||
for i in {1..60}; do
|
||||
if nc -z "$host" "$port"; then
|
||||
echo "DB ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for DB... ($i)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
python manage.py migrate --noinput || true
|
||||
python manage.py collectstatic --noinput || true
|
||||
|
||||
# Start gunicorn (API)
|
||||
( exec gunicorn "$DJANGO_WSGI_MODULE" --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} --threads ${GUNICORN_THREADS:-2} --timeout 60 ) &
|
||||
|
||||
# Start nginx (Frontend)
|
||||
exec nginx -g "daemon off;"
|
||||
23
backend/docker/nginx.conf
Normal file
23
backend/docker/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /app/staticfiles/;
|
||||
access_log off;
|
||||
expires 30d;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /app/media/;
|
||||
access_log off;
|
||||
expires 30d;
|
||||
}
|
||||
}
|
||||
418
backend/events/admin.py
Normal file
418
backend/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 utils.templatetags.jalali import jdate
|
||||
from unfold.decorators import action as unfold_action
|
||||
|
||||
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
from events.models import Event, Registration, EventEmailLog
|
||||
from events.resources import EventResource, RegistrationResource
|
||||
from 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 events.admin_forms import AnnouncementForm
|
||||
from 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 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
backend/events/admin_forms.py
Normal file
25
backend/events/admin_forms.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django import forms
|
||||
|
||||
from unfold.widgets import UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget
|
||||
|
||||
from 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,
|
||||
)
|
||||
6
backend/events/apps.py
Normal file
6
backend/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 = 'events'
|
||||
379
backend/events/fixtures/events.json
Normal file
379
backend/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
backend/events/migrations/0001_initial.py
Normal file
60
backend/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
backend/events/migrations/0002_initial.py
Normal file
27
backend/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
backend/events/migrations/0003_initial.py
Normal file
39
backend/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
backend/events/migrations/0008_alter_eventemaillog_kind.py
Normal file
18
backend/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
backend/events/migrations/0011_eventemaillog_context_hash.py
Normal file
22
backend/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
backend/events/migrations/0012_alter_eventemaillog_kind.py
Normal file
18
backend/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),
|
||||
),
|
||||
]
|
||||
0
backend/events/migrations/__init__.py
Normal file
0
backend/events/migrations/__init__.py
Normal file
269
backend/events/models.py
Normal file
269
backend/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 utils.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 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 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 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
backend/events/resources.py
Normal file
86
backend/events/resources.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from import_export import resources, fields
|
||||
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||
|
||||
from events.models import Event, Registration
|
||||
from users.models import User
|
||||
from gallery.models import Gallery
|
||||
from 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
backend/events/tasks.py
Normal file
584
backend/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 users.models import User
|
||||
from events.models import Event, Registration, EventEmailLog
|
||||
from utils.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
|
||||
89
backend/gallery/admin.py
Normal file
89
backend/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 gallery.models import Gallery
|
||||
from gallery.resources import GalleryResource
|
||||
from utils.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)
|
||||
5
backend/gallery/apps.py
Normal file
5
backend/gallery/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class GalleryConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'gallery'
|
||||
218
backend/gallery/fixtures/gallery.json
Normal file
218
backend/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
backend/gallery/migrations/0001_initial.py
Normal file
36
backend/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
backend/gallery/migrations/0002_initial.py
Normal file
23
backend/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
backend/gallery/migrations/__init__.py
Normal file
0
backend/gallery/migrations/__init__.py
Normal file
82
backend/gallery/models.py
Normal file
82
backend/gallery/models.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from utils.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
backend/gallery/resources.py
Normal file
17
backend/gallery/resources.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from import_export import resources, fields
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from gallery.models import Gallery
|
||||
from 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
backend/gallery/tasks.py
Normal file
23
backend/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
|
||||
22
backend/manage.py
Normal file
22
backend/manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
83
backend/payments/admin.py
Normal file
83
backend/payments/admin.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
from payments.resources import DiscountResource, PaymentResource
|
||||
from 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',)
|
||||
}),
|
||||
)
|
||||
6
backend/payments/apps.py
Normal file
6
backend/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 = 'payments'
|
||||
64
backend/payments/migrations/0001_initial.py
Normal file
64
backend/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
backend/payments/migrations/0002_initial.py
Normal file
23
backend/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
backend/payments/migrations/0003_payment_registration.py
Normal file
20
backend/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'),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user