initial commit
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-19 20:53:08 +03:30
commit 88b793ed9f
169 changed files with 16763 additions and 0 deletions

0
apps/__init__.py Normal file
View File

159
apps/blog/admin.py Normal file
View File

@@ -0,0 +1,159 @@
from django import forms
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from simplemde.widgets import SimpleMDEEditor
from apps.blog.models import Category, Tag, Post, Comment, Like
from apps.blog.resources import PostResource, CategoryResource
from core.admin import SoftDeleteListFilter, BaseModelAdmin
@admin.register(Category)
class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = CategoryResource
list_display = ('name', 'slug', 'created_at', 'is_deleted')
list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter)
search_fields = ('name', 'description')
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
fieldsets = (
('Content', {
'fields': ('name', 'slug', 'description')
}),
('Metadata', {
'fields': ('created_at', 'updated_at')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
})
)
actions = BaseModelAdmin.actions + ['restore_categories']
def restore_categories(self, request, queryset):
for category in queryset:
category.restore()
self.message_user(request, f"Restored {queryset.count()} categories.")
restore_categories.short_description = "Restore selected categories"
@admin.register(Tag)
class TagAdmin(BaseModelAdmin, ImportExportModelAdmin):
list_display = ('name', 'slug', 'created_at', 'is_deleted')
list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter)
search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
fieldsets = (
('Content', {
'fields': ('name', 'slug')
}),
('Metadata', {
'fields': ('created_at', 'updated_at')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
})
)
class PostAdminForm(forms.ModelForm):
content = forms.CharField(widget=SimpleMDEEditor())
excerpt = forms.CharField(widget=SimpleMDEEditor())
class Meta:
model = Post
fields = '__all__'
@admin.register(Post)
class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
form = PostAdminForm
resource_class = PostResource
list_display = ('title', 'author', 'status', 'category', 'is_featured', 'published_at', 'created_at')
list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter)
search_fields = ('title', 'content', 'author__username')
prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ('tags',)
date_hierarchy = 'published_at'
fieldsets = (
('Content', {
'fields': ('title', 'slug', 'content', 'excerpt', 'featured_image')
}),
('Metadata', {
'fields': ('author', 'category', 'tags', 'status', 'is_featured', 'published_at')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('deleted_at',)
actions = BaseModelAdmin.actions + ['make_published', 'make_draft', 'make_featured', 'restore_posts']
def make_published(self, request, queryset):
queryset.update(status='published')
self.message_user(request, f"Published {queryset.count()} posts.")
make_published.short_description = "Mark selected posts as published"
def make_draft(self, request, queryset):
queryset.update(status='draft')
self.message_user(request, f"Marked {queryset.count()} posts as draft.")
make_draft.short_description = "Mark selected posts as draft"
def make_featured(self, request, queryset):
queryset.update(is_featured=True)
self.message_user(request, f"Featured {queryset.count()} posts.")
make_featured.short_description = "Mark selected posts as featured"
def restore_posts(self, request, queryset):
for post in queryset:
post.restore()
self.message_user(request, f"Restored {queryset.count()} posts.")
restore_posts.short_description = "Restore selected posts"
@admin.register(Comment)
class CommentAdmin(BaseModelAdmin):
list_display = ('author', 'post', 'content_preview', 'is_approved', 'created_at')
list_filter = ('is_approved', 'created_at', 'post', SoftDeleteListFilter)
search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title')
readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at')
fieldsets = (
('Content', {
'fields': ('post', 'author', 'content')
}),
('Metadata', {
'fields': ('is_approved', 'created_at', 'updated_at')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
})
)
actions = BaseModelAdmin.actions + ['approve_comments', 'disapprove_comments']
def content_preview(self, obj):
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
content_preview.short_description = 'Content Preview'
def approve_comments(self, request, queryset):
queryset.update(is_approved=True)
self.message_user(request, f"Approved {queryset.count()} comments.")
approve_comments.short_description = "Approve selected comments"
def disapprove_comments(self, request, queryset):
queryset.update(is_approved=False)
self.message_user(request, f"Disapproved {queryset.count()} comments.")
disapprove_comments.short_description = "Disapprove selected comments"
@admin.register(Like)
class LikeAdmin(BaseModelAdmin):
list_display = ('user', 'post', 'created_at')
list_filter = ('created_at', 'post')
search_fields = ('user__username', 'post__title')

View File

87
apps/blog/api/schemas.py Normal file
View File

@@ -0,0 +1,87 @@
"""Blog API schemas."""
from ninja import Schema, ModelSchema
from typing import Optional, List
from datetime import datetime
from apps.blog.models import Category, Tag, Comment
class CategorySchema(ModelSchema):
class Config:
model = Category
model_fields = ['id', 'name', 'slug', 'description']
class TagSchema(ModelSchema):
class Config:
model = Tag
model_fields = ['id', 'name', 'slug']
class AuthorSchema(Schema):
id: int
username: str
first_name: str
last_name: str
profile_picture: Optional[str] = None
@staticmethod
def resolve_profile_picture(obj, context):
request = context['request']
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
return request.build_absolute_uri(obj.profile_picture.url)
return None
class PostListSchema(Schema):
id: int
title: str
slug: str
excerpt: str
author: AuthorSchema
featured_image: Optional[str] = None
status: str
published_at: Optional[datetime] = None
category: Optional[CategorySchema] = None
tags: List[TagSchema]
is_featured: bool
created_at: datetime
reading_time: int
class PostDetailSchema(PostListSchema):
content: str
content_html: str
class PostCreateSchema(Schema):
title: str
content: str
excerpt: Optional[str] = None
category_id: Optional[int] = None
tag_ids: Optional[List[int]] = []
status: str = "draft"
is_featured: bool = False
class CommentSchema(ModelSchema):
author: AuthorSchema
replies: List['CommentSchema'] = []
post_id: int
post_title: str
post_slug: str
class Config:
model = Comment
model_fields = ['id', 'content', 'created_at', 'is_approved']
@staticmethod
def resolve_post_id(obj):
return obj.post_id
@staticmethod
def resolve_post_title(obj):
return obj.post.title
@staticmethod
def resolve_post_slug(obj):
return obj.post.slug
class CommentCreateSchema(Schema):
content: str
parent_id: Optional[int] = None

304
apps/blog/api/views.py Normal file
View File

@@ -0,0 +1,304 @@
from django.shortcuts import get_object_or_404
from django.db.models import Q, Prefetch
from ninja import Router, Query
from typing import List, Optional
from apps.users.models import User
from apps.blog.api.schemas import (
CategorySchema,
CommentCreateSchema,
CommentSchema,
PostCreateSchema,
PostDetailSchema,
PostListSchema,
TagSchema,
)
from apps.blog.models import Post, Category, Tag, Comment, Like
from core.api.schemas import ErrorSchema, MessageSchema
from core.authentication import jwt_auth
blog_router = Router()
# Post endpoints
@blog_router.get("/posts", response=List[PostListSchema])
def list_posts(
request,
page: int = Query(1, ge=1),
limit: int = Query(10, ge=1, le=50),
category: Optional[str] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
featured: Optional[bool] = None,
author: Optional[str] = None
):
"""List published posts with filtering and pagination"""
queryset = Post.objects.filter(status=Post.StatusChoices.PUBLISHED).select_related(
'author', 'category'
).prefetch_related('tags')
# Apply filters
if category:
queryset = queryset.filter(category__slug=category)
if tag:
queryset = queryset.filter(tags__slug=tag)
if search:
queryset = queryset.filter(
Q(title__icontains=search) |
Q(content__icontains=search) |
Q(excerpt__icontains=search)
)
if featured is not None:
queryset = queryset.filter(is_featured=featured)
if author:
queryset = queryset.filter(author__username=author)
# Pagination
offset = (page - 1) * limit
posts = queryset[offset:offset + limit]
return posts
@blog_router.get("/posts/{slug}", response=PostDetailSchema)
def get_post(request, slug: str):
"""Get single post by slug"""
post = get_object_or_404(
Post.objects.select_related('author', 'category').prefetch_related('tags'),
slug=slug,
status=Post.StatusChoices.PUBLISHED
)
return post
@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
def create_post(request, data: PostCreateSchema):
"""Create a new post (committee members only)"""
user = request.auth
if not (user.is_superuser or user.is_staff):
return 400, {"error": "Only committee members can create posts"}
try:
post = Post.objects.create(
title=data.title,
content=data.content,
excerpt=data.excerpt,
author=user,
category_id=data.category_id,
status=data.status,
is_featured=data.is_featured
)
if data.tag_ids:
post.tags.set(data.tag_ids)
return 201, post
except Exception as e:
return 400, {"error": "Failed to create post", "details": str(e)}
@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
def update_post(request, slug: str, data: PostCreateSchema):
"""Update a post (author or committee only)"""
user = request.auth
post = get_object_or_404(Post, slug=slug)
if not (post.author == user or user.is_superuser or user.is_staff):
return 400, {"error": "You can only edit your own posts"}
try:
for field, value in data.dict(exclude_unset=True).items():
if field == 'tag_ids':
if value:
post.tags.set(value)
elif field == 'category_id':
post.category_id = value
else:
setattr(post, field, value)
post.save()
return 200, post
except Exception as e:
return 400, {"error": "Failed to update post", "details": str(e)}
@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def delete_post(request, slug: str):
"""Soft delete a post owned by the requester or committee."""
user = request.auth
post = get_object_or_404(Post, slug=slug)
if not (post.author == user or user.is_superuser or user.is_staff):
return 400, {"error": "You can only delete your own posts"}
post.delete()
return 200, {"message": "Post deleted successfully"}
@blog_router.get("/deleted/posts", response=List[PostListSchema], auth=jwt_auth)
def list_deleted_posts(request):
"""List all soft-deleted posts (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
return Post.deleted_objects.all().select_related('author', 'category').prefetch_related('tags')
@blog_router.post("deleted/posts/{post_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def restore_post(request, post_id: int):
"""Restore a soft-deleted post (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
try:
post = Post.deleted_objects.get(id=post_id)
post.restore()
return 200, {"message": f"Post '{post.title}' restored successfully."}
except Post.DoesNotExist:
return 400, {"error": "Post not found or not soft-deleted."}
# Comment endpoints
@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema])
def list_comments(request, slug: str):
"""List approved comments for a post"""
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
comments = Comment.objects.filter(
post=post,
is_approved=True,
parent=None
).select_related('author').prefetch_related(
Prefetch(
'replies',
queryset=Comment.objects.filter(is_approved=True).select_related('author')
)
)
return comments
@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth)
def create_comment(request, slug: str, data: CommentCreateSchema):
"""Create a comment on a post"""
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
user = request.auth
try:
comment = Comment.objects.create(
post=post,
author=user,
content=data.content,
parent_id=data.parent_id
)
return 201, comment
except Exception as e:
return 400, {"error": "Failed to create comment", "details": str(e)}
@blog_router.get("/deleted/comments", response=List[CommentSchema], auth=jwt_auth)
def list_deleted_comments(request):
"""List all soft-deleted comments (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
return Comment.deleted_objects.all().select_related('author', 'post')
@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def restore_comment(request, comment_id: int):
"""Restore a soft-deleted comment (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
try:
comment = Comment.deleted_objects.get(id=comment_id)
comment.restore()
return 200, {"message": f"Comment by {comment.author.username} restored successfully."}
except Comment.DoesNotExist:
return 400, {"error": "Comment not found or not soft-deleted."}
# Like endpoints
@blog_router.post("/posts/{slug}/like", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def toggle_like(request, slug: str):
"""Toggle like on a post"""
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
user = request.auth
like, created = Like.objects.get_or_create(post=post, user=user)
if not created:
like.delete()
return 200, {"message": "Post unliked"}
return 200, {"message": "Post liked"}
@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema})
def get_likes_count(request, slug: str):
"""Get likes count for a post"""
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
count = post.likes.count()
return {"message": f"{count}"}
# Category endpoints
@blog_router.get("/categories", response=List[CategorySchema])
def list_categories(request):
"""List all categories"""
return Category.objects.all()
@blog_router.get("/categories/{slug}", response=CategorySchema)
def get_category(request, slug: str):
"""Get single category by slug"""
return get_object_or_404(Category, slug=slug)
@blog_router.get("/deleted/categories", response=List[CategorySchema], auth=jwt_auth)
def list_deleted_categories(request):
"""List all soft-deleted categories (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
return Category.deleted_objects.all()
@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def restore_category(request, category_id: int):
"""Restore a soft-deleted category (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
try:
category = Category.deleted_objects.get(id=category_id)
category.restore()
return 200, {"message": f"Category '{category.name}' restored successfully."}
except Category.DoesNotExist:
return 400, {"error": "Category not found or not soft-deleted."}
# Tag endpoints
@blog_router.get("/tags", response=List[TagSchema])
def list_tags(request):
"""List all tags"""
return Tag.objects.all()
@blog_router.get("/tags/{slug}", response=TagSchema)
def get_tag(request, slug: str):
"""Get single tag by slug"""
return get_object_or_404(Tag, slug=slug)
@blog_router.get("/deleted/tags", response=List[TagSchema], auth=jwt_auth)
def list_deleted_tags(request):
"""List all soft-deleted tags (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
return Tag.all_objects.all()
@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def restore_tag(request, tag_id: int):
"""Restore a soft-deleted tag (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
try:
tag = Tag.deleted_objects.get(id=tag_id)
tag.restore()
return 200, {"message": f"Tag '{tag.name}' restored successfully."}
except Tag.DoesNotExist:
return 400, {"error": "Tag not found or not soft-deleted."}

6
apps/blog/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.blog"

View 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"
}
}
]

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

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

View File

137
apps/blog/models.py Normal file
View File

@@ -0,0 +1,137 @@
from django.db import models
from django.conf import settings
from django.utils.text import slugify
from django.utils import timezone
import markdown
from core.models import BaseModel
class Category(BaseModel):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=100, unique=True, blank=True)
description = models.TextField(blank=True)
class Meta:
verbose_name_plural = "Categories"
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Tag(BaseModel):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True, blank=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Post(BaseModel):
class StatusChoices(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True, blank=True)
content = models.TextField(help_text="Content in Markdown format")
excerpt = models.TextField(max_length=300, blank=True)
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts')
featured_image = models.ImageField(upload_to='blog/featured/', null=True, blank=True)
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
published_at = models.DateTimeField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='posts')
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
is_featured = models.BooleanField(default=False)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'published_at']),
models.Index(fields=['is_featured']),
]
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
# Auto-generate excerpt if not provided
if not self.excerpt and self.content:
# Convert markdown to plain text for excerpt
plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra'])
# Remove HTML tags and truncate
import re
plain_text = re.sub('<[^<]+?>', '', plain_text)
self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
self.published_at = timezone.now()
super().save(*args, **kwargs)
@property
def content_html(self):
"""Convert markdown content to HTML"""
return markdown.markdown(
self.content,
extensions=[
'markdown.extensions.extra',
'markdown.extensions.codehilite',
'markdown.extensions.toc',
]
)
@property
def reading_time(self):
"""Estimate reading time in minutes assuming 200 words per minute."""
word_count = len(self.content.split())
return max(1, word_count // 200)
class Comment(BaseModel):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments')
content = models.TextField()
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies')
is_approved = models.BooleanField(default=True)
class Meta:
ordering = ['created_at']
indexes = [
models.Index(fields=['post', 'is_approved']),
]
def __str__(self):
return f'Comment by {self.author.username} on {self.post.title}'
@property
def is_reply(self):
return self.parent is not None
class Like(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='likes')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='likes')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['post', 'user']
indexes = [
models.Index(fields=['post']),
]
def __str__(self):
return f'{self.user.username} likes {self.post.title}'

32
apps/blog/resources.py Normal file
View File

@@ -0,0 +1,32 @@
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
from apps.users.models import User
from apps.blog.models import Post, Category, Tag
class CategoryResource(resources.ModelResource):
class Meta:
model = Category
fields = ('id', 'name', 'slug', 'description', 'created_at')
class PostResource(resources.ModelResource):
author = fields.Field(
column_name='author',
attribute='author',
widget=ForeignKeyWidget(User, 'username')
)
category = fields.Field(
column_name='category',
attribute='category',
widget=ForeignKeyWidget(Category, 'name')
)
tags = fields.Field(
column_name='tags',
attribute='tags',
widget=ManyToManyWidget(Tag, field='name', separator='|')
)
class Meta:
model = Post
fields = ('id', 'title', 'slug', 'content', 'excerpt', 'author',
'category', 'tags', 'status', 'is_featured', 'published_at', 'created_at')

View File

@@ -0,0 +1 @@
""""""

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

View File

View 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]

View File

@@ -0,0 +1,138 @@
from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError
from ninja import Router
from ninja.errors import HttpError
from core.authentication import jwt_auth
from apps.certificates.api.schemas import (
CertificateTemplateOut,
CertificateGenerationPayload,
CertificateGenerationResponse,
CertificateVerificationOut,
SkillSchema,
UserCertificateOut,
)
from apps.certificates.models import CertificateTemplate, UserCertificate
certificates_router = Router(tags=["Certificates"])
def _ensure_staff(user):
if not user or not user.is_staff:
raise HttpError(403, "Only staff users can access certificate management.")
@certificates_router.get(
"templates/{int:event_id}",
response=CertificateTemplateOut,
auth=jwt_auth,
)
def get_template(request, event_id: int):
_ensure_staff(request.auth)
template = get_object_or_404(
CertificateTemplate.objects.select_related('event').prefetch_related('skills'),
event_id=event_id,
is_deleted=False,
)
skills = [
SkillSchema(
id=skill.id,
name=skill.name,
description=skill.description,
)
for skill in template.skills.all()
]
image_url = None
if template.image and hasattr(template.image, 'url'):
image_url = request.build_absolute_uri(template.image.url)
return CertificateTemplateOut(
id=template.id,
event_id=template.event_id,
event_title=template.event.title,
image_url=image_url,
skill_ids=list(template.skills.values_list('id', flat=True)),
skills=skills,
)
@certificates_router.post(
"templates/{int:event_id}/generate",
response=CertificateGenerationResponse,
auth=jwt_auth,
)
def generate_certificates(request, event_id: int, payload: CertificateGenerationPayload):
_ensure_staff(request.auth)
template = get_object_or_404(
CertificateTemplate.objects.select_related('event').prefetch_related('skills'),
event_id=event_id,
is_deleted=False,
)
try:
entries = [entry.model_dump() for entry in payload.entries]
certificates = template.generate_certificates(
entries,
default_title=payload.default_title,
default_description=payload.default_description,
)
except ValidationError as exc:
raise HttpError(400, str(exc))
result = []
for certificate in certificates:
image_url = None
if certificate.image and hasattr(certificate.image, 'url'):
image_url = request.build_absolute_uri(certificate.image.url)
result.append(
UserCertificateOut(
id=certificate.id,
user_id=certificate.user_id,
user_name=certificate.user.get_full_name() or certificate.user.email,
event_id=certificate.event_id,
title=certificate.title,
certificate_id=str(certificate.certificate_id),
certificate_code=certificate.code,
score=certificate.score,
score_label=certificate.score_label,
image_url=image_url,
)
)
return CertificateGenerationResponse(certificates=result)
@certificates_router.get(
"verify/{str:certificate_code}",
response=CertificateVerificationOut,
)
def verify_certificate(request, certificate_code):
certificate = get_object_or_404(
UserCertificate.objects.select_related('event', 'user').prefetch_related('skills'),
code=certificate_code,
is_deleted=False,
)
image_url = None
if certificate.image and hasattr(certificate.image, 'url'):
image_url = request.build_absolute_uri(certificate.image.url)
return CertificateVerificationOut(
certificate_id=str(certificate.certificate_id),
certificate_code=certificate.code,
user_id=certificate.user_id,
user_name=certificate.user.get_full_name() or certificate.user.email,
event_id=certificate.event_id,
event_title=certificate.event.title,
title=certificate.title,
score=certificate.score,
score_label=certificate.score_label,
issued_at=certificate.issued_at,
expires_at=certificate.expires_at,
image_url=image_url,
skills=[skill.name for skill in certificate.skills.all()],
)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CertificatesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.certificates"

View File

@@ -0,0 +1,86 @@
# Generated by Django 4.2.13 on 2025-11-18 09:47
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
SHORT_CERTIFICATE_CODE_LENGTH = 10
def generate_certificate_code():
return uuid.uuid4().hex[:SHORT_CERTIFICATE_CODE_LENGTH]
class Migration(migrations.Migration):
initial = True
dependencies = [
('events', '0012_alter_eventemaillog_kind'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Skill',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('name', models.CharField(max_length=120, unique=True)),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='CertificateTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('image', models.ImageField(upload_to='certificates/templates/')),
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='certificate_template', to='events.event')),
('skills', models.ManyToManyField(blank=True, help_text='Skills covered by this event.', related_name='certificate_templates', to='certificates.skill')),
],
options={
'verbose_name': 'Certificate template',
'verbose_name_plural': 'Certificate templates',
},
),
migrations.CreateModel(
name='UserCertificate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('certificate_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('code', models.CharField(default=generate_certificate_code, editable=False, max_length=10, unique=True)),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('score', models.PositiveSmallIntegerField(default=0)),
('issued_at', models.DateTimeField(default=django.utils.timezone.now)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('image', models.ImageField(blank=True, null=True, upload_to='certificates/generated/')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_certificates', to='events.event')),
('skills', models.ManyToManyField(blank=True, help_text='Skills demonstrated on this certificate.', related_name='user_certificates', to='certificates.skill')),
('template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='awarded_certificates', to='certificates.certificatetemplate')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-issued_at'],
'indexes': [models.Index(fields=['user', 'event'], name='certificate_user_id_61901c_idx'), models.Index(fields=['event', 'score'], name='certificate_event_i_25b8ab_idx')],
'unique_together': {('user', 'event')},
},
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.5 on 2026-05-19 14:07
import apps.certificates.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('certificates', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='usercertificate',
name='code',
field=models.CharField(default=apps.certificates.models._generate_certificate_code, editable=False, max_length=10, unique=True),
),
]

View File

@@ -0,0 +1 @@
""""""

316
apps/certificates/models.py Normal file
View File

@@ -0,0 +1,316 @@
from io import BytesIO
from typing import Optional, Sequence
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.utils import timezone
from PIL import Image, ImageDraw, ImageFont
from apps.events.models import Registration
from apps.users.models import User
from core.models import BaseModel
SHORT_CERTIFICATE_CODE_LENGTH = 10
def _generate_certificate_code() -> str:
return uuid4().hex[:SHORT_CERTIFICATE_CODE_LENGTH]
class Skill(BaseModel):
name = models.CharField(max_length=120, unique=True)
description = models.TextField(blank=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class CertificateTemplate(BaseModel):
event = models.OneToOneField(
'events.Event',
on_delete=models.CASCADE,
related_name='certificate_template',
)
image = models.ImageField(upload_to='certificates/templates/')
skills = models.ManyToManyField(
Skill,
blank=True,
related_name='certificate_templates',
help_text='Skills covered by this event.',
)
class Meta:
verbose_name = 'Certificate template'
verbose_name_plural = 'Certificate templates'
def __str__(self):
return f'{self.event.title} template'
def _validate_score(self, score: Optional[int]) -> int:
"""Normalize score values and ensure they stay within 0-100."""
if score is None:
raise ValidationError("Score is required")
try:
normalized = int(score)
except (TypeError, ValueError):
raise ValidationError("Score must be an integer between 0 and 100")
if normalized < 0 or normalized > 100:
raise ValidationError("Score must be between 0 and 100")
return normalized
def _resolve_skill_ids(self, skill_ids: Optional[Sequence[int]]) -> list[int]:
"""Return a cleaned list of skill IDs, defaulting to the template skills."""
if skill_ids is None:
return list(self.skills.values_list('id', flat=True))
normalized = []
seen = set()
for skill_id in skill_ids:
if skill_id is None:
continue
try:
skill_int = int(skill_id)
except (TypeError, ValueError):
continue
if skill_int not in seen:
seen.add(skill_int)
normalized.append(skill_int)
if not normalized:
return []
existing = set(Skill.objects.filter(id__in=normalized).values_list('id', flat=True))
missing = set(normalized) - existing
if missing:
raise ValidationError(f"Skills not found: {', '.join(str(mid) for mid in sorted(missing))}")
return normalized
def _ensure_user_registration(self, user: User) -> Registration:
"""Require that the user has a confirmed or attended registration for the event."""
registration = Registration.objects.filter(
event=self.event,
user=user,
status__in=[
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
],
is_deleted=False,
).order_by('-registered_at').first()
if not registration:
raise ValidationError("User must have a confirmed or attended registration for this event.")
return registration
def _load_font(self, size: int = 48):
try:
return ImageFont.truetype("arial.ttf", size)
except Exception:
return ImageFont.load_default()
def _render_certificate_image(self, certificate: 'UserCertificate') -> None:
"""Overlay user-specific text on the template image and attach it to the certificate."""
if not self.image:
return
try:
template_path = self.image.path
except (AttributeError, ValueError):
return
try:
base_image = Image.open(template_path).convert("RGB")
except FileNotFoundError:
return
draw = ImageDraw.Draw(base_image)
font = self._load_font(size=48)
width, height = base_image.size
lines = [
certificate.user.get_full_name() or certificate.user.email,
self.event.title,
f"Score: {certificate.score} ({certificate.score_label})",
timezone.localtime(certificate.issued_at).strftime('%Y-%m-%d'),
]
margin = 40
total_height = 0
measurements = []
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
line_height = bbox[3] - bbox[1]
line_width = bbox[2] - bbox[0]
measurements.append((line, line_width, line_height))
total_height += line_height + 10
y = height - margin - total_height
for line, line_width, line_height in measurements:
x = (width - line_width) / 2
draw.text((x, y), line, fill='black', font=font)
y += line_height + 10
buffer = BytesIO()
base_image.save(buffer, format='PNG')
buffer.seek(0)
filename = f"{self.event.slug}_{certificate.user_id}_{uuid4().hex}.png"
certificate.image.save(filename, ContentFile(buffer.read()), save=False)
certificate.save(update_fields=['image'])
def award_certificate(
self,
*,
user: User,
title: str,
description: str = '',
score: Optional[int] = None,
skill_ids: Optional[Sequence[int]] = None,
issued_at=None,
expires_at=None,
) -> 'UserCertificate':
"""
Create or update the certificate for a single user.
"""
self._ensure_user_registration(user)
resolved_score = self._validate_score(score)
resolved_skills = self._resolve_skill_ids(skill_ids)
issued_at = issued_at or timezone.now()
title = title or f"{self.event.title} Certificate"
description = description or ''
certificate, _ = UserCertificate.objects.update_or_create(
user=user,
event=self.event,
defaults={
'template': self,
'title': title,
'description': description,
'score': resolved_score,
'issued_at': issued_at,
'expires_at': expires_at,
},
)
certificate.skills.set(resolved_skills)
self._render_certificate_image(certificate)
return certificate
def generate_certificates(
self,
entries: Sequence[dict],
*,
default_title: Optional[str] = None,
default_description: Optional[str] = None,
) -> list['UserCertificate']:
"""
Create certificates for a batch of users.
Entries expect dicts with at least `user_id` and `score`.
"""
if not entries:
raise ValidationError("Entries payload must contain at least one item.")
user_ids = {entry.get('user_id') for entry in entries if entry.get('user_id') is not None}
if not user_ids:
raise ValidationError("No valid user IDs were provided.")
users = {user.id: user for user in User.objects.filter(id__in=user_ids)}
missing = user_ids - users.keys()
if missing:
raise ValidationError(f"Users not found: {', '.join(str(uid) for uid in sorted(missing))}")
certificates = []
for entry in entries:
user = users.get(entry.get('user_id'))
if not user:
continue
certificate = self.award_certificate(
user=user,
title=entry.get('title') or default_title or f"{self.event.title} Certificate",
description=entry.get('description') or default_description or '',
score=entry.get('score'),
skill_ids=entry.get('skill_ids'),
issued_at=entry.get('issued_at'),
expires_at=entry.get('expires_at'),
)
certificates.append(certificate)
return certificates
class UserCertificate(BaseModel):
SCORE_RANGES = [
(0, 24, 'Fair'),
(25, 49, 'Good'),
(50, 74, 'Very Good'),
(75, 100, 'Perfect'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='certificates',
)
event = models.ForeignKey(
'events.Event',
on_delete=models.CASCADE,
related_name='user_certificates',
)
template = models.ForeignKey(
CertificateTemplate,
on_delete=models.PROTECT,
related_name='awarded_certificates',
)
certificate_id = models.UUIDField(default=uuid4, unique=True, editable=False)
code = models.CharField(
max_length=SHORT_CERTIFICATE_CODE_LENGTH,
unique=True,
editable=False,
default=_generate_certificate_code,
)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
score = models.PositiveSmallIntegerField(default=0)
issued_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField(null=True, blank=True)
image = models.ImageField(
upload_to='certificates/generated/',
null=True,
blank=True,
)
skills = models.ManyToManyField(
Skill,
blank=True,
related_name='user_certificates',
help_text='Skills demonstrated on this certificate.',
)
class Meta:
unique_together = ('user', 'event')
ordering = ['-issued_at']
indexes = [
models.Index(fields=['user', 'event']),
models.Index(fields=['event', 'score']),
]
def __str__(self):
return f'{self.user} - {self.title} ({self.certificate_id})'
@property
def score_label(self) -> str:
for lower, upper, label in self.SCORE_RANGES:
if lower <= self.score <= upper:
return label
return 'Unknown'
@staticmethod
def _make_unique_code() -> str:
"""Generate a short certificate code without collisions."""
for _ in range(5):
candidate = _generate_certificate_code()
if not UserCertificate.objects.filter(code=candidate).exists():
return candidate
raise RuntimeError("Unable to generate a unique certificate code.")
def save(self, *args, **kwargs):
if not self.code or UserCertificate.objects.filter(code=self.code).exclude(pk=self.pk).exists():
self.code = self._make_unique_code()
super().save(*args, **kwargs)

View File

@@ -0,0 +1,122 @@
from django import forms
from django.contrib import admin
from django.utils import timezone
from simplemde.widgets import SimpleMDEEditor
from import_export.admin import ImportExportModelAdmin
from core.admin import SoftDeleteListFilter, BaseModelAdmin
from apps.communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
class AnnouncementAdminForm(forms.ModelForm):
content = forms.CharField(
widget=SimpleMDEEditor(),
help_text="Announcement content in Markdown format with live preview"
)
class Meta:
model = Announcement
fields = '__all__'
@admin.register(Announcement)
class AnnouncementAdmin(BaseModelAdmin, ImportExportModelAdmin):
form = AnnouncementAdminForm
list_display = [
'title', 'announcement_type', 'priority', 'author',
'is_published', 'publish_date', 'email_sent', 'push_sent', 'created_at'
]
list_filter = [
'announcement_type', 'priority', 'is_published',
'send_email', 'send_push', 'target_audience',
SoftDeleteListFilter, 'created_at'
]
search_fields = ['title', 'content', 'author__username']
readonly_fields = ['email_sent', 'push_sent', 'created_at', 'updated_at']
fieldsets = (
('Content', {
'fields': ('title', 'content', 'author')
}),
('Settings', {
'fields': ('announcement_type', 'priority', 'target_audience', 'is_published', 'publish_date')
}),
('Notifications', {
'fields': ('send_email', 'send_push', 'email_sent', 'push_sent')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
actions = BaseModelAdmin.actions + ['publish_announcements', 'send_notifications']
def publish_announcements(self, request, queryset):
queryset.update(is_published=True, publish_date=timezone.now())
self.message_user(request, f"{queryset.count()} announcements published.")
publish_announcements.short_description = "Publish selected announcements"
def send_notifications(self, request, queryset):
# This will be implemented with Celery tasks
for announcement in queryset:
if announcement.send_email and not announcement.email_sent:
# Trigger email task
pass
if announcement.send_push and not announcement.push_sent:
# Trigger push notification task
pass
self.message_user(request, f"Notifications queued for {queryset.count()} announcements.")
send_notifications.short_description = "Send notifications for selected announcements"
@admin.register(NewsletterSubscription)
class NewsletterSubscriptionAdmin(BaseModelAdmin, ImportExportModelAdmin):
list_display = ['email', 'user', 'is_active', 'confirmed_at', 'created_at']
list_filter = ['is_active', SoftDeleteListFilter, 'created_at', 'confirmed_at']
search_fields = ['email', 'user__username', 'user__email']
readonly_fields = ['confirmation_token', 'unsubscribe_token', 'created_at', 'updated_at']
fieldsets = (
('Subscription', {
'fields': ('email', 'user', 'is_active', 'subscribed_categories')
}),
('Confirmation', {
'fields': ('confirmed_at', 'confirmation_token', 'unsubscribe_token')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
actions = BaseModelAdmin.actions + ['activate_subscriptions', 'deactivate_subscriptions']
def activate_subscriptions(self, request, queryset):
queryset.update(is_active=True)
self.message_user(request, f"{queryset.count()} subscriptions activated.")
activate_subscriptions.short_description = "Activate selected subscriptions"
def deactivate_subscriptions(self, request, queryset):
queryset.update(is_active=False)
self.message_user(request, f"{queryset.count()} subscriptions deactivated.")
deactivate_subscriptions.short_description = "Deactivate selected subscriptions"
@admin.register(PushNotificationDevice)
class PushNotificationDeviceAdmin(BaseModelAdmin, ImportExportModelAdmin):
list_display = ['user', 'device_type', 'is_active', 'created_at']
list_filter = ['device_type', 'is_active', SoftDeleteListFilter, 'created_at']
search_fields = ['user__username', 'user__email', 'device_token']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Device', {
'fields': ('user', 'device_token', 'device_type', 'is_active')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)

View File

View File

@@ -0,0 +1,124 @@
"""Schemas for communications-related endpoints."""
from datetime import datetime
from typing import Optional, List
from ninja import Schema, ModelSchema
from apps.blog.api.schemas import AuthorSchema
from apps.communications.models import (
Announcement,
NewsletterSubscription,
PushNotificationDevice
)
class AnnouncementSchema(ModelSchema):
author: AuthorSchema
content_html: str
class Config:
model = Announcement
model_fields = [
'id', 'title', 'content', 'announcement_type', 'priority',
'is_published', 'publish_date', 'send_email', 'send_push',
'target_audience', 'email_sent', 'push_sent', 'created_at', 'updated_at'
]
@staticmethod
def resolve_content_html(obj):
return obj.content_html
class AnnouncementListSchema(Schema):
id: int
title: str
content: str
announcement_type: str
priority: str
author: AuthorSchema
is_published: bool
publish_date: Optional[datetime] = None
target_audience: str
created_at: datetime
class AnnouncementCreateSchema(Schema):
title: str
content: str
announcement_type: str = "general"
priority: str = "normal"
target_audience: str = "all"
is_published: bool = False
publish_date: Optional[datetime] = None
send_email: bool = False
send_push: bool = False
class AnnouncementUpdateSchema(Schema):
title: Optional[str] = None
content: Optional[str] = None
announcement_type: Optional[str] = None
priority: Optional[str] = None
target_audience: Optional[str] = None
is_published: Optional[bool] = None
publish_date: Optional[datetime] = None
send_email: Optional[bool] = None
send_push: Optional[bool] = None
class NewsletterSubscriptionSchema(ModelSchema):
user: Optional[AuthorSchema] = None
class Config:
model = NewsletterSubscription
model_fields = [
'id', 'email', 'is_active', 'subscribed_categories',
'confirmed_at', 'created_at'
]
class NewsletterSubscribeSchema(Schema):
email: str
subscribed_categories: Optional[List[str]] = []
class NewsletterUnsubscribeSchema(Schema):
email: str
class PushDeviceSchema(ModelSchema):
user: AuthorSchema
class Config:
model = PushNotificationDevice
model_fields = [
'id', 'device_token', 'device_type', 'is_active', 'created_at'
]
class PushDeviceCreateSchema(Schema):
device_token: str
device_type: str = "web"
class PushDeviceUpdateSchema(Schema):
is_active: bool
class PushNotificationSchema(Schema):
title: str
body: str
data: Optional[dict] = None
target_audience: str = "all"
class MessageResponseSchema(Schema):
"""Simple message payload for API responses."""
message: str
success: bool = True
class AnnouncementStatsSchema(Schema):
"""Summary statistics for announcements."""
total_announcements: int
published_announcements: int
draft_announcements: int
urgent_announcements: int
email_sent_count: int
push_sent_count: int
class NewsletterStatsSchema(Schema):
"""Summary statistics for newsletter subscriptions."""
total_subscriptions: int
active_subscriptions: int
confirmed_subscriptions: int
recent_subscriptions: int

View File

@@ -0,0 +1,329 @@
from django.shortcuts import get_object_or_404
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.db.models import Q, Count
from ninja import Router
from ninja.pagination import paginate
from typing import List
import logging
from apps.communications.models import (
Announcement, NewsletterSubscription, PushNotificationDevice,
AnnouncementType, AnnouncementPriority
)
from apps.communications.utils import (
send_announcement_email, send_newsletter_confirmation,
get_announcement_recipients
)
from apps.communications.push_notifications import push_service
from apps.communications.api.schemas import (
AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema,
NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema,
PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema,
PushNotificationSchema, MessageResponseSchema,
AnnouncementStatsSchema, NewsletterStatsSchema
)
from core.authentication import jwt_auth
User = get_user_model()
logger = logging.getLogger(__name__)
communications_router = Router()
# Announcement endpoints
@communications_router.get("/announcements/", response=List[AnnouncementListSchema])
@paginate
def list_announcements(request, published_only: bool = True):
"""List announcements"""
queryset = Announcement.objects.select_related('author').filter(is_deleted=False)
if published_only:
queryset = queryset.filter(is_published=True, publish_date__lte=timezone.now())
return queryset.order_by('-created_at')
@communications_router.get("/announcements/{announcement_id}/", response=AnnouncementSchema)
def get_announcement(request, announcement_id: int):
"""Get single announcement"""
announcement = get_object_or_404(
Announcement.objects.select_related('author').filter(is_deleted=False),
id=announcement_id
)
# Check if published or user has permission
if not announcement.is_published:
# Only allow access to unpublished announcements for staff/committee
if not hasattr(request, 'auth') or not request.auth:
return {"error": "Announcement not found"}, 404
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Announcement not found"}, 404
return announcement
@communications_router.post("/announcements/", response=AnnouncementSchema, auth=jwt_auth)
def create_announcement(request, payload: AnnouncementCreateSchema):
"""Create new announcement (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
announcement = Announcement.objects.create(
author=user,
**payload.dict()
)
# Send notifications if requested and published
if announcement.is_published and announcement.publish_date <= timezone.now():
if announcement.send_email:
recipients = get_announcement_recipients(announcement)
if recipients:
send_announcement_email(announcement, recipients)
announcement.email_sent = True
if announcement.send_push:
push_service.send_announcement_notification(announcement)
announcement.push_sent = True
announcement.save()
return announcement
@communications_router.put("/announcements/{announcement_id}/", response=AnnouncementSchema, auth=jwt_auth)
def update_announcement(request, announcement_id: int, payload: AnnouncementUpdateSchema):
"""Update announcement (author/committee/staff only)"""
user = request.auth
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
# Check permissions
if not (user.is_staff or user.is_committee or announcement.author == user):
return {"error": "Permission denied"}, 403
# Update fields
for field, value in payload.dict(exclude_unset=True).items():
setattr(announcement, field, value)
announcement.save()
# Send notifications if newly published
if (announcement.is_published and announcement.publish_date <= timezone.now() and
not announcement.email_sent and announcement.send_email):
recipients = get_announcement_recipients(announcement)
if recipients:
send_announcement_email(announcement, recipients)
announcement.email_sent = True
announcement.save()
if (announcement.is_published and announcement.publish_date <= timezone.now() and
not announcement.push_sent and announcement.send_push):
push_service.send_announcement_notification(announcement)
announcement.push_sent = True
announcement.save()
return announcement
@communications_router.delete("/announcements/{announcement_id}/", response=MessageResponseSchema, auth=jwt_auth)
def delete_announcement(request, announcement_id: int):
"""Delete announcement (author/committee/staff only)"""
user = request.auth
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
# Check permissions
if not (user.is_staff or user.is_committee or announcement.author == user):
return {"error": "Permission denied"}, 403
announcement.soft_delete()
return {"message": "Announcement deleted successfully"}
@communications_router.get("/announcements/stats/", response=AnnouncementStatsSchema, auth=jwt_auth)
def get_announcement_stats(request):
"""Get announcement statistics (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
stats = Announcement.objects.filter(is_deleted=False).aggregate(
total_announcements=Count('id'),
published_announcements=Count('id', filter=Q(is_published=True)),
draft_announcements=Count('id', filter=Q(is_published=False)),
urgent_announcements=Count('id', filter=Q(priority='urgent')),
email_sent_count=Count('id', filter=Q(email_sent=True)),
push_sent_count=Count('id', filter=Q(push_sent=True))
)
return stats
# Newsletter endpoints
@communications_router.post("/newsletter/subscribe/", response=MessageResponseSchema)
def subscribe_newsletter(request, payload: NewsletterSubscribeSchema):
"""Subscribe to newsletter"""
try:
subscription, created = NewsletterSubscription.objects.get_or_create(
email=payload.email,
defaults={
'subscribed_categories': payload.subscribed_categories,
'is_active': True
}
)
if not created and not subscription.is_active:
subscription.is_active = True
subscription.subscribed_categories = payload.subscribed_categories
subscription.save()
# Send confirmation email
send_newsletter_confirmation(subscription)
message = (
"عضویت در خبرنامه با موفقیت انجام شد! لطفاً برای تأیید، ایمیل خود را بررسی کنید."
if created
else "اشتراک خبرنامه به‌روزرسانی شد!"
)
return {"message": message}
except Exception as e:
logger.error(f"Newsletter subscription failed: {str(e)}")
return {"message": "Subscription failed", "success": False}, 400
@communications_router.post("/newsletter/unsubscribe/", response=MessageResponseSchema)
def unsubscribe_newsletter(request, payload: NewsletterUnsubscribeSchema):
"""Unsubscribe from newsletter"""
try:
subscription = NewsletterSubscription.objects.get(email=payload.email)
subscription.is_active = False
subscription.save()
return {"message": "Successfully unsubscribed from newsletter"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Email not found in subscription list"}, 404
@communications_router.get("/newsletter/confirm/{token}/", response=MessageResponseSchema)
def confirm_newsletter_subscription(request, token: str):
"""Confirm newsletter subscription"""
try:
subscription = NewsletterSubscription.objects.get(confirmation_token=token)
subscription.confirmed_at = timezone.now()
subscription.is_active = True
subscription.save()
return {"message": "Newsletter subscription confirmed successfully!"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Invalid confirmation token"}, 400
@communications_router.get("/newsletter/unsubscribe/{token}/", response=MessageResponseSchema)
def unsubscribe_newsletter_token(request, token: str):
"""Unsubscribe using token from email"""
try:
subscription = NewsletterSubscription.objects.get(unsubscribe_token=token)
subscription.is_active = False
subscription.save()
return {"message": "Successfully unsubscribed from newsletter"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Invalid unsubscribe token"}, 400
@communications_router.get("/newsletter/subscriptions/", response=List[NewsletterSubscriptionSchema], auth=jwt_auth)
@paginate
def list_newsletter_subscriptions(request):
"""List newsletter subscriptions (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
return NewsletterSubscription.objects.select_related('user').filter(is_deleted=False).order_by('-created_at')
@communications_router.get("/newsletter/stats/", response=NewsletterStatsSchema, auth=jwt_auth)
def get_newsletter_stats(request):
"""Get newsletter statistics (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
stats = NewsletterSubscription.objects.filter(is_deleted=False).aggregate(
total_subscriptions=Count('id'),
active_subscriptions=Count('id', filter=Q(is_active=True)),
confirmed_subscriptions=Count('id', filter=Q(confirmed_at__isnull=False)),
recent_subscriptions=Count('id', filter=Q(created_at__gte=timezone.now() - timezone.timedelta(days=30)))
)
return stats
# Push notification endpoints
@communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth)
def register_push_device(request, payload: PushDeviceCreateSchema):
"""Register push notification device"""
user = request.auth
device, created = PushNotificationDevice.objects.get_or_create(
user=user,
device_token=payload.device_token,
defaults={'device_type': payload.device_type, 'is_active': True}
)
if not created:
device.is_active = True
device.device_type = payload.device_type
device.save()
return device
@communications_router.delete("/push-devices/", response=MessageResponseSchema, auth=jwt_auth)
def unregister_push_device(request, device_token: str):
"""Unregister push notification device"""
user = request.auth
try:
device = PushNotificationDevice.objects.get(user=user, device_token=device_token)
device.delete()
return {"message": "Device unregistered successfully"}
except PushNotificationDevice.DoesNotExist:
return {"message": "Device not found"}, 404
@communications_router.get("/push-devices/", response=List[PushDeviceSchema], auth=jwt_auth)
def list_user_push_devices(request):
"""List user's push notification devices"""
user = request.auth
return PushNotificationDevice.objects.filter(user=user, is_deleted=False).order_by('-created_at')
@communications_router.put("/push-devices/{device_id}/", response=PushDeviceSchema, auth=jwt_auth)
def update_push_device(request, device_id: int, payload: PushDeviceUpdateSchema):
"""Update push notification device"""
user = request.auth
device = get_object_or_404(PushNotificationDevice, id=device_id, user=user, is_deleted=False)
device.is_active = payload.is_active
device.save()
return device
@communications_router.post("/push-notifications/send/", response=MessageResponseSchema, auth=jwt_auth)
def send_push_notification(request, payload: PushNotificationSchema):
"""Send push notification (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
# Get target users
users = []
if payload.target_audience == 'all':
users = User.objects.filter(is_active=True)
elif payload.target_audience == 'members':
users = User.objects.filter(is_member=True, is_active=True)
elif payload.target_audience == 'committee':
users = User.objects.filter(is_committee=True, is_active=True)
# Send notifications
total_sent = push_service.send_to_multiple_users(
users, payload.title, payload.body, payload.data
)
return {"message": f"Push notification sent to {total_sent} devices"}
# Utility endpoints
@communications_router.get("/announcement-types/", response=List[dict])
def get_announcement_types(request):
"""Get available announcement types"""
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementType.choices]
@communications_router.get("/announcement-priorities/", response=List[dict])
def get_announcement_priorities(request):
"""Get available announcement priorities"""
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementPriority.choices]

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CommunicationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.communications"
verbose_name = "Communications"

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

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

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

View File

@@ -0,0 +1,142 @@
from django.db import models
from django.contrib.auth import get_user_model
from core.models import BaseModel
User = get_user_model()
class AnnouncementType(models.TextChoices):
GENERAL = 'general', 'General'
EVENT = 'event', 'Event'
ACADEMIC = 'academic', 'Academic'
URGENT = 'urgent', 'Urgent'
NEWSLETTER = 'newsletter', 'Newsletter'
class AnnouncementPriority(models.TextChoices):
LOW = 'low', 'Low'
NORMAL = 'normal', 'Normal'
HIGH = 'high', 'High'
URGENT = 'urgent', 'Urgent'
class Announcement(BaseModel):
title = models.CharField(max_length=200, verbose_name='Title')
content = models.TextField(verbose_name='Content')
announcement_type = models.CharField(
max_length=20,
choices=AnnouncementType.choices,
default=AnnouncementType.GENERAL,
verbose_name='Type'
)
priority = models.CharField(
max_length=10,
choices=AnnouncementPriority.choices,
default=AnnouncementPriority.NORMAL,
verbose_name='Priority'
)
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='announcements',
verbose_name='Author'
)
is_published = models.BooleanField(default=False, verbose_name='Published')
publish_date = models.DateTimeField(null=True, blank=True, verbose_name='Publish Date')
send_email = models.BooleanField(default=False, verbose_name='Send Email Notification')
send_push = models.BooleanField(default=False, verbose_name='Send Push Notification')
email_sent = models.BooleanField(default=False, verbose_name='Email Sent')
push_sent = models.BooleanField(default=False, verbose_name='Push Sent')
target_audience = models.CharField(
max_length=20,
choices=[
('all', 'All Users'),
('members', 'Members Only'),
('committee', 'Committee Only'),
('subscribers', 'Newsletter Subscribers Only'),
],
default='all',
verbose_name='Target Audience'
)
class Meta:
verbose_name = 'Announcement'
verbose_name_plural = 'Announcements'
ordering = ['-created_at']
def __str__(self):
return self.title
@property
def content_html(self):
"""Convert markdown content to HTML"""
import markdown
return markdown.markdown(self.content)
class NewsletterSubscription(BaseModel):
email = models.EmailField(unique=True, verbose_name='Email')
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='newsletter_subscription',
verbose_name='User'
)
is_active = models.BooleanField(default=True, verbose_name='Active')
subscribed_categories = models.JSONField(
default=list,
blank=True,
verbose_name='Subscribed Categories',
help_text='List of announcement types to receive'
)
confirmation_token = models.CharField(max_length=100, blank=True, verbose_name='Confirmation Token')
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='Confirmed At')
unsubscribe_token = models.CharField(max_length=100, blank=True, verbose_name='Unsubscribe Token')
class Meta:
verbose_name = 'Newsletter Subscription'
verbose_name_plural = 'Newsletter Subscriptions'
ordering = ['-created_at']
def __str__(self):
return self.email
def save(self, *args, **kwargs):
if not self.confirmation_token:
import uuid
self.confirmation_token = str(uuid.uuid4())
if not self.unsubscribe_token:
import uuid
self.unsubscribe_token = str(uuid.uuid4())
super().save(*args, **kwargs)
class PushNotificationDevice(BaseModel):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='push_devices',
verbose_name='User'
)
device_token = models.TextField(verbose_name='Device Token')
device_type = models.CharField(
max_length=10,
choices=[
('web', 'Web'),
('android', 'Android'),
('ios', 'iOS'),
],
verbose_name='Device Type'
)
is_active = models.BooleanField(default=True, verbose_name='Active')
class Meta:
verbose_name = 'Push Notification Device'
verbose_name_plural = 'Push Notification Devices'
unique_together = ['user', 'device_token']
def __str__(self):
return f"{self.user.username} - {self.device_type}"

View File

@@ -0,0 +1,194 @@
from django.conf import settings
import json
import logging
from typing import List, Dict, Any, Optional
from pywebpush import webpush, WebPushException
from apps.communications.models import PushNotificationDevice
from apps.events.models import Registration
logger = logging.getLogger(__name__)
class PushNotificationService:
"""Service for handling web push notifications"""
def __init__(self):
self.vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
self.vapid_public_key = getattr(settings, 'VAPID_PUBLIC_KEY', None)
self.vapid_claims = getattr(settings, 'VAPID_CLAIMS', {})
def send_notification(
self,
subscription_info: Dict[str, Any],
data: Dict[str, Any],
ttl: int = 86400
) -> bool:
"""
Send a push notification to a single device
Args:
subscription_info: Device subscription information
data: Notification payload
ttl: Time to live in seconds (default 24 hours)
Returns:
bool: True if successful, False otherwise
"""
try:
webpush(
subscription_info=subscription_info,
data=json.dumps(data),
vapid_private_key=self.vapid_private_key,
vapid_claims=self.vapid_claims,
ttl=ttl
)
return True
except WebPushException as e:
logger.error(f"Push notification failed: {e}")
if e.response and e.response.status_code in [410, 413]:
# Subscription is no longer valid, should be removed
self._remove_invalid_subscription(subscription_info)
return False
except Exception as e:
logger.error(f"Unexpected error sending push notification: {e}")
return False
def send_to_multiple(
self,
devices: List[PushNotificationDevice],
data: Dict[str, Any],
ttl: int = 86400
) -> Dict[str, int]:
"""
Send push notification to multiple devices
Args:
devices: List of PushNotificationDevice objects
data: Notification payload
ttl: Time to live in seconds
Returns:
dict: Statistics of sent/failed notifications
"""
stats = {'sent': 0, 'failed': 0}
for device in devices:
subscription_info = {
'endpoint': device.endpoint,
'keys': {
'p256dh': device.p256dh_key,
'auth': device.auth_key
}
}
if self.send_notification(subscription_info, data, ttl):
stats['sent'] += 1
else:
stats['failed'] += 1
return stats
def send_announcement_notification(
self,
announcement,
devices: Optional[List[PushNotificationDevice]] = None
) -> Dict[str, int]:
"""
Send push notification for an announcement
Args:
announcement: Announcement model instance
devices: Optional list of specific devices to send to
Returns:
dict: Statistics of sent/failed notifications
"""
if devices is None:
# Get devices based on announcement audience
if announcement.audience == 'all':
devices = PushNotificationDevice.objects.filter(is_active=True)
elif announcement.audience == 'members':
devices = PushNotificationDevice.objects.filter(
user__is_member=True,
is_active=True
)
elif announcement.audience == 'committee':
devices = PushNotificationDevice.objects.filter(
user__is_committee_member=True,
is_active=True
)
else:
devices = PushNotificationDevice.objects.none()
# Prepare notification data
data = {
'title': announcement.title,
'body': announcement.content[:100] + '...' if len(announcement.content) > 100 else announcement.content,
'icon': '/static/images/logo.png',
'badge': '/static/images/badge.png',
'data': {
'type': 'announcement',
'id': announcement.id,
'url': f'/announcements/{announcement.id}/'
}
}
return self.send_to_multiple(devices, data)
def send_event_reminder_notification(
self,
event,
devices: Optional[List[PushNotificationDevice]] = None
) -> Dict[str, int]:
"""
Send push notification for event reminder
Args:
event: Event model instance
devices: Optional list of specific devices to send to
Returns:
dict: Statistics of sent/failed notifications
"""
if devices is None:
# Get devices of registered users
registered_users = Registration.objects.filter(
event=event,
status='confirmed'
).values_list('user_id', flat=True)
devices = PushNotificationDevice.objects.filter(
user_id__in=registered_users,
is_active=True
)
# Prepare notification data
data = {
'title': f'Event Reminder: {event.title}',
'body': f'Your event "{event.title}" starts in 24 hours!',
'icon': '/static/images/logo.png',
'badge': '/static/images/badge.png',
'data': {
'type': 'event_reminder',
'id': event.id,
'url': f'/events/{event.id}/'
}
}
return self.send_to_multiple(devices, data)
def _remove_invalid_subscription(self, subscription_info: Dict[str, Any]):
"""Remove invalid subscription from database"""
try:
PushNotificationDevice.objects.filter(
endpoint=subscription_info['endpoint']
).delete()
logger.info(f"Removed invalid subscription: {subscription_info['endpoint']}")
except Exception as e:
logger.error(f"Error removing invalid subscription: {e}")
# Create a singleton instance
push_service = PushNotificationService()

View File

@@ -0,0 +1,56 @@
from django.contrib.auth import get_user_model
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget
from apps.communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
User = get_user_model()
class AnnouncementResource(resources.ModelResource):
author = fields.Field(
column_name='author',
attribute='author',
widget=ForeignKeyWidget(User, 'username')
)
class Meta:
model = Announcement
fields = (
'id', 'title', 'content', 'announcement_type', 'priority',
'author', 'is_published', 'publish_date', 'send_email', 'send_push',
'target_audience', 'created_at', 'updated_at'
)
export_order = fields
class NewsletterSubscriptionResource(resources.ModelResource):
user = fields.Field(
column_name='user',
attribute='user',
widget=ForeignKeyWidget(User, 'username')
)
class Meta:
model = NewsletterSubscription
fields = (
'id', 'email', 'user', 'is_active', 'subscribed_categories',
'confirmed_at', 'created_at', 'updated_at'
)
export_order = fields
class PushNotificationDeviceResource(resources.ModelResource):
user = fields.Field(
column_name='user',
attribute='user',
widget=ForeignKeyWidget(User, 'username')
)
class Meta:
model = PushNotificationDevice
fields = (
'id', 'user', 'device_type', 'is_active', 'created_at', 'updated_at'
)
export_order = fields

View File

@@ -0,0 +1,278 @@
from django.utils import timezone
from django.contrib.auth import get_user_model
import logging
from celery import shared_task
from datetime import timedelta
from apps.events.models import Event, Registration
from apps.communications.models import Announcement, NewsletterSubscription
from apps.communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients
from apps.communications.push_notifications import push_service
User = get_user_model()
logger = logging.getLogger(__name__)
SYSTEM_USER_ID = 1
@shared_task(bind=True, max_retries=3)
def send_announcement_notifications(self, announcement_id):
"""Send email and push notifications for an announcement"""
try:
announcement = Announcement.objects.get(id=announcement_id)
# Send email notifications
if announcement.send_email and not announcement.email_sent:
recipients = get_announcement_recipients(announcement)
if recipients:
success = send_announcement_email(announcement, recipients)
if success:
announcement.email_sent = True
announcement.save()
logger.info(f"Email notifications sent for announcement {announcement.id}")
# Send push notifications
if announcement.send_push and not announcement.push_sent:
sent_count = push_service.send_announcement_notification(announcement)
if sent_count > 0:
announcement.push_sent = True
announcement.save()
logger.info(f"Push notifications sent to {sent_count} devices for announcement {announcement.id}")
return f"Notifications sent for announcement: {announcement.title}"
except Announcement.DoesNotExist:
logger.error(f"Announcement {announcement_id} not found")
return f"Announcement {announcement_id} not found"
except Exception as exc:
logger.error(f"Failed to send announcement notifications: {exc}")
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3)
def send_newsletter_confirmation_task(self, subscription_id):
"""Send newsletter confirmation email"""
try:
from .utils import send_newsletter_confirmation
subscription = NewsletterSubscription.objects.get(id=subscription_id)
success = send_newsletter_confirmation(subscription)
if success:
logger.info(f"Newsletter confirmation sent to {subscription.email}")
return f"Newsletter confirmation sent to {subscription.email}"
else:
raise Exception("Failed to send newsletter confirmation")
except NewsletterSubscription.DoesNotExist:
logger.error(f"Newsletter subscription {subscription_id} not found")
return f"Newsletter subscription {subscription_id} not found"
except Exception as exc:
logger.error(f"Failed to send newsletter confirmation: {exc}")
raise self.retry(exc=exc, countdown=60)
@shared_task
def send_event_reminders():
"""Send reminders for events starting about 24 hours from now within a 30-minute window."""
try:
reminder_target = timezone.now() + timedelta(hours=24)
window = timedelta(minutes=30)
start_range = reminder_target - window
end_range = reminder_target + window
events = Event.objects.filter(
start_time__range=(start_range, end_range),
status='published',
is_deleted=False
)
total_sent = 0
for event in events:
# Get confirmed registrations
registrations = Registration.objects.filter(
event=event,
status='confirmed',
is_deleted=False
).select_related('user')
for registration in registrations:
try:
# Send email reminder
send_event_reminder(event, registration.user)
# Send push notification reminder
push_service.send_event_reminder_notification(event, registration.user)
total_sent += 1
except Exception as e:
logger.error(f"Failed to send reminder to {registration.user.email}: {str(e)}")
logger.info(f"Event reminders sent to {total_sent} users")
return f"Event reminders sent to {total_sent} users"
except Exception as exc:
logger.error(f"Failed to send event reminders: {exc}")
raise exc
@shared_task
def send_weekly_newsletter():
"""Send the weekly newsletter as the system user with recent announcements and upcoming events."""
try:
# Get active newsletter subscribers
subscribers = NewsletterSubscription.objects.filter(
is_active=True,
confirmed_at__isnull=False,
is_deleted=False
)
if not subscribers.exists():
logger.info("No active newsletter subscribers found")
return "No active newsletter subscribers found"
# Get recent announcements (last 7 days)
week_ago = timezone.now() - timedelta(days=7)
recent_announcements = Announcement.objects.filter(
is_published=True,
publish_date__gte=week_ago,
announcement_type__in=['general', 'academic', 'newsletter'],
is_deleted=False
).order_by('-publish_date')[:5]
# Get upcoming events (next 14 days)
two_weeks_ahead = timezone.now() + timedelta(days=14)
upcoming_events = Event.objects.filter(
start_time__range=(timezone.now(), two_weeks_ahead),
status='published',
is_deleted=False
).order_by('start_time')[:5]
newsletter_content = f"""
# Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}
## Recent Announcements
"""
for announcement in recent_announcements:
newsletter_content += f"- **{announcement.title}** ({announcement.publish_date.strftime('%B %d')})\n"
newsletter_content += "\n## Upcoming Events\n"
for event in upcoming_events:
newsletter_content += f"- **{event.title}** - {event.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
if not recent_announcements.exists() and not upcoming_events.exists():
newsletter_content += "\nNo recent announcements or upcoming events this week."
newsletter = Announcement.objects.create(
title=f"Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}",
content=newsletter_content,
announcement_type='newsletter',
priority='normal',
author_id=SYSTEM_USER_ID,
is_published=True,
publish_date=timezone.now(),
send_email=True,
target_audience='subscribers'
)
# Send to subscribers
subscriber_emails = list(subscribers.values_list('email', flat=True))
success = send_announcement_email(newsletter, subscriber_emails)
if success:
newsletter.email_sent = True
newsletter.save()
logger.info(f"Weekly newsletter sent to {len(subscriber_emails)} subscribers")
return f"Weekly newsletter sent to {len(subscriber_emails)} subscribers"
else:
raise Exception("Failed to send weekly newsletter")
except Exception as exc:
logger.error(f"Failed to send weekly newsletter: {exc}")
raise exc
@shared_task
def cleanup_expired_tokens():
"""Clean up expired newsletter confirmation tokens"""
try:
# Remove unconfirmed subscriptions older than 7 days
week_ago = timezone.now() - timedelta(days=7)
expired_subscriptions = NewsletterSubscription.objects.filter(
confirmed_at__isnull=True,
created_at__lt=week_ago
)
count = expired_subscriptions.count()
expired_subscriptions.delete()
logger.info(f"Cleaned up {count} expired newsletter subscriptions")
return f"Cleaned up {count} expired newsletter subscriptions"
except Exception as exc:
logger.error(f"Failed to cleanup expired tokens: {exc}")
raise exc
@shared_task
def send_bulk_announcement(announcement_id, recipient_emails):
"""Send announcement to a specific list of recipients"""
try:
announcement = Announcement.objects.get(id=announcement_id)
# Split recipients into batches to avoid overwhelming the email server
batch_size = 50
total_sent = 0
for i in range(0, len(recipient_emails), batch_size):
batch = recipient_emails[i:i + batch_size]
success = send_announcement_email(announcement, batch)
if success:
total_sent += len(batch)
logger.info(f"Sent announcement to batch of {len(batch)} recipients")
# Small delay between batches
import time
time.sleep(1)
logger.info(f"Bulk announcement sent to {total_sent} recipients")
return f"Bulk announcement sent to {total_sent} recipients"
except Exception as exc:
logger.error(f"Failed to send bulk announcement: {exc}")
raise exc
@shared_task
def process_scheduled_announcements():
"""Process announcements scheduled for publication"""
try:
now = timezone.now()
# Get announcements scheduled for publication
scheduled_announcements = Announcement.objects.filter(
is_published=True,
publish_date__lte=now,
email_sent=False,
send_email=True,
is_deleted=False
)
processed_count = 0
for announcement in scheduled_announcements:
# Send notifications
send_announcement_notifications.delay(announcement.id)
processed_count += 1
logger.info(f"Processed {processed_count} scheduled announcements")
return f"Processed {processed_count} scheduled announcements"
except Exception as exc:
logger.error(f"Failed to process scheduled announcements: {exc}")
raise exc

View File

@@ -0,0 +1,140 @@
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
import logging
from apps.communications.models import NewsletterSubscription
logger = logging.getLogger(__name__)
def send_announcement_email(announcement, recipients):
"""Send announcement email to recipients"""
try:
template_name = f'emails/announcement_email.html'
context = {
'announcement': announcement,
'unsubscribe_url': f"{settings.FRONTEND_ROOT}newsletter/unsubscribe/",
'manage_subscription_url': f"{settings.FRONTEND_ROOT}newsletter/manage-subscription",
}
html_message = render_to_string(template_name, context)
plain_message = strip_tags(html_message)
subject = f"انجمن علمی کامپیوتر گیلان | {announcement.title}"
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=recipients,
html_message=html_message,
fail_silently=False,
)
logger.info(f"Announcement email sent to {len(recipients)} recipients")
return True
except Exception as e:
logger.error(f"Failed to send announcement email: {str(e)}")
return False
def send_newsletter_confirmation(subscription):
"""Send newsletter confirmation email"""
try:
template_name = f'emails/newsletter_confirmation.html'
confirmation_url = f"{settings.FRONTEND_ROOT}confirm-subscription/{subscription.confirmation_token}"
context = {
'subscription': subscription,
'confirmation_url': confirmation_url,
}
html_message = render_to_string(template_name, context)
plain_message = strip_tags(html_message)
subject = "تأیید اشتراک خبرنامه"
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[subscription.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"Newsletter confirmation sent to {subscription.email}")
return True
except Exception as e:
logger.error(f"Failed to send newsletter confirmation: {str(e)}")
return False
def send_event_reminder(event, user):
"""Send event reminder email"""
try:
template_name = f'emails/event_reminder.html'
event_url = f"{settings.FRONTEND_ROOT}events/{event.slug}"
context = {
'event': event,
'user': user,
'event_url': event_url,
}
html_message = render_to_string(template_name, context)
plain_message = strip_tags(html_message)
subject = f"یادآوری رویداد: {event.title}"
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"Event reminder sent to {user.email} for event {event.title}")
return True
except Exception as e:
logger.error(f"Failed to send event reminder: {str(e)}")
return False
def get_announcement_recipients(announcement):
"""Get list of email addresses based on announcement target audience"""
User = get_user_model()
recipients = []
if announcement.target_audience == 'all':
# All users with email
recipients = list(User.objects.filter(email__isnull=False).values_list('email', flat=True))
elif announcement.target_audience == 'members':
# Only members (users with is_member=True)
recipients = list(User.objects.filter(is_member=True, email__isnull=False).values_list('email', flat=True))
elif announcement.target_audience == 'committee':
# Only committee members
recipients = list(User.objects.filter(is_committee=True, email__isnull=False).values_list('email', flat=True))
elif announcement.target_audience == 'subscribers':
# Only newsletter subscribers
recipients = list(NewsletterSubscription.objects.filter(
is_active=True,
confirmed_at__isnull=False
).values_list('email', flat=True))
return recipients

418
apps/events/admin.py Normal file
View File

@@ -0,0 +1,418 @@
from django.contrib import admin, messages
from django.template.response import TemplateResponse
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.template.loader import render_to_string
from django.conf import settings
from django.shortcuts import redirect
from django.urls import reverse_lazy
from import_export.admin import ImportExportModelAdmin
from core.templatetags.jalali import jdate
from unfold.decorators import action as unfold_action
from core.admin import SoftDeleteListFilter, BaseModelAdmin
from apps.events.models import Event, Registration, EventEmailLog
from apps.events.resources import EventResource, RegistrationResource
from apps.events.tasks import (
queue_skyroom_credentials,
send_skyroom_credentials_individual_task,
send_event_reminder_task,
queue_event_announcement,
queue_invites_to_non_registered_users,
)
from apps.events.admin_forms import AnnouncementForm
from apps.events.tasks import _send_html_email
@admin.register(Event)
class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = EventResource
list_display = (
'title', 'event_type', 'start_time_display', 'end_time_display', 'status',
'price_display', 'capacity_display', 'attendees_display', 'is_registration_open_display'
)
list_filter = (
'event_type', 'status', 'is_deleted',
'start_time', 'end_time', 'registration_start_date', 'registration_end_date',
SoftDeleteListFilter
)
search_fields = ('title', 'description', 'address')
prepopulated_fields = {'slug': ('title',)}
date_hierarchy = 'start_time'
filter_horizontal = ('gallery_images',)
fieldsets = (
('Event Details', {
'fields': ('title', 'slug', 'description', 'featured_image')
}),
('Timing & Type', {
'fields': ('start_time', 'end_time', 'event_type', 'status')
}),
('Location & Online', {
'fields': ('address', 'location', 'online_link'),
'description': 'For On-Site or Hybrid events, provide address and select on map. For Online events, provide a link.'
}),
('Registration & Pricing', {
'fields': ('capacity', 'price', 'registration_start_date', 'registration_end_date', 'registration_success_markdown'),
'description': 'Leave capacity blank for unlimited. Leave price blank for free events.'
}),
('Gallery', {
'fields': ('gallery_images',),
'description': 'Add images related to this event from the Gallery app.'
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('deleted_at',)
actions = BaseModelAdmin.actions + [
'make_published',
'make_draft',
'make_cancelled',
'make_completed',
'restore_events',
]
actions_row = [
'action_send_announcement',
'action_send_reminder_now',
'action_send_skyroom_credentials',
'action_invite_other_users',
]
@admin.display(description="Price")
def price_display(self, obj):
return obj.price if obj.price is not None else "رایگان"
@admin.display(description="Start")
def start_time_display(self, obj):
return jdate(obj.start_time)
@admin.display(description="End")
def end_time_display(self, obj):
return jdate(obj.end_time)
@admin.display(description="Capacity")
def capacity_display(self, obj):
return obj.capacity if obj.capacity is not None else "نامحدود"
@admin.display(description="Attendees")
def attendees_display(self, obj):
return obj.current_attendees_count
@admin.display(description="Open", boolean=True)
def is_registration_open_display(self, obj):
return obj.is_registration_open
@admin.action(description="Mark selected events as published")
def make_published(self, request, queryset):
queryset.update(status=Event.StatusChoices.PUBLISHED)
self.message_user(request, f"Published {queryset.count()} events.")
@admin.action(description="Mark selected events as draft")
def make_draft(self, request, queryset):
queryset.update(status=Event.StatusChoices.DRAFT)
self.message_user(request, f"Marked {queryset.count()} events as draft.")
@admin.action(description="Mark selected events as cancelled")
def make_cancelled(self, request, queryset):
queryset.update(status=Event.StatusChoices.CANCELLED)
self.message_user(request, f"Cancelled {queryset.count()} events.")
@admin.action(description="Mark selected events as completed")
def make_completed(self, request, queryset):
queryset.update(status=Event.StatusChoices.COMPLETED)
self.message_user(request, f"Marked {queryset.count()} events as completed.")
@admin.action(description="Restore selected events")
def restore_events(self, request, queryset):
for event in queryset:
event.restore()
self.message_user(request, f"Restored {queryset.count()} events.")
@unfold_action(description="Send Skyroom Credentials")
def action_send_skyroom_credentials(self, request, object_id: int):
event = Event.objects.get(pk=object_id)
queue_skyroom_credentials.delay(event.pk)
self.message_user(request, f"ارسال مشخصات اسکای‌روم برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
return redirect(reverse_lazy("admin:events_event_changelist"))
@unfold_action(description="Send new Reminder")
def action_send_reminder_now(self, request, object_id: int):
event = Event.objects.get(pk=object_id)
send_event_reminder_task.delay(event.pk)
self.message_user(request, f"یادآوری برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
return redirect(reverse_lazy("admin:events_event_changelist"))
@unfold_action(description="send new Announcement")
def action_send_announcement(self, request, object_id: int):
"""
این اکشن یک فرم می‌گیرد (عنوان/متن/وضعیت‌ها) و با تمپلیت Unfold نشان داده می‌شود.
"""
form = AnnouncementForm(request.POST or None)
event = Event.objects.get(pk=object_id)
if request.method == "POST" and form.is_valid():
subject = form.cleaned_data["subject"]
body_html = form.cleaned_data["body_html"]
statuses = form.cleaned_data["statuses"] or None
queue_event_announcement.delay(event.pk, subject, body_html, statuses=statuses)
self.message_user(request, f"اطلاعیه برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
return redirect(reverse_lazy("admin:events_event_changelist"))
context = {
**self.admin_site.each_context(request),
"title": "ارسال اطلاعیه گروهی",
"opts": self.model._meta,
"form": form,
"action_name": "action_send_announcement",
"action_checkbox_name": ACTION_CHECKBOX_NAME,
}
return TemplateResponse(request, "forms/admin_announcement.html", context)
@unfold_action(description="Invite other users")
def action_invite_other_users(self, request, object_id: int):
event = Event.objects.get(pk=object_id)
queue_invites_to_non_registered_users.delay(event.pk)
self.message_user(request, f"دعوت برای شرکت در رویداد '{event.title}' صف شد.", messages.SUCCESS)
return redirect(reverse_lazy("admin:events_event_changelist"))
@admin.register(Registration)
class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = RegistrationResource
list_display = (
'user',
'event',
'status',
'registered_at',
'ticket_id',
'discount_code',
'discount_amount',
'final_price',
)
list_filter = (
'status',
'event',
'is_deleted',
'registered_at',
SoftDeleteListFilter
)
search_fields = ('user__username', 'user__email', 'user__first_name', 'user__last_name', 'event__title', 'ticket_id')
readonly_fields = (
'ticket_id',
'registered_at',
'confirmation_email_sent_at',
'cancellation_email_sent_at',
'discount_code',
'discount_amount',
'final_price',
'deleted_at',
)
fieldsets = (
(
'Registration Details',
{
'fields': (
'user',
'event',
'status',
'registered_at',
'ticket_id',
'confirmation_email_sent_at',
'cancellation_email_sent_at',
)
},
),
(
'Pricing & Discount',
{
'fields': ('discount_code', 'discount_amount', 'final_price'),
'classes': ('collapse',),
},
),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)
actions = BaseModelAdmin.actions + [
'confirm_registrations',
'cancel_registrations',
'mark_attended',
'restore_registrations',
]
actions_row = [
'action_email_selected',
'action_send_skyroom_credentials',
]
@admin.action(description="Confirm selected registrations")
def confirm_registrations(self, request, queryset):
queryset.update(status=Registration.StatusChoices.CONFIRMED)
self.message_user(request, f"Confirmed {queryset.count()} registrations.")
@admin.action(description="Cancel selected registrations")
def cancel_registrations(self, request, queryset):
queryset.update(status=Registration.StatusChoices.CANCELLED)
self.message_user(request, f"Cancelled {queryset.count()} registrations.")
@admin.action(description="Mark selected registrations as attended")
def mark_attended(self, request, queryset):
queryset.update(status=Registration.StatusChoices.ATTENDED)
self.message_user(request, f"Marked {queryset.count()} registrations as attended.")
@admin.action(description="Restore selected registrations")
def restore_registrations(self, request, queryset):
for registration in queryset:
registration.restore()
self.message_user(request, f"Restored {queryset.count()} registrations.")
@unfold_action(description="send email to registrated user")
def action_email_selected(self, request, object_id: int):
"""
همان فرم اطلاعیه را می‌گیرد و به افراد انتخاب‌شده ایمیل می‌زند.
برای نمایش فرم، از تمپلیت Unfold استفاده می‌کنیم.
"""
form = AnnouncementForm(request.POST or None)
registration = Registration.objects.get(id=object_id)
if request.method == "POST" and form.is_valid():
subject = form.cleaned_data["subject"]
body_html = form.cleaned_data["body_html"]
user = registration.user
ctx = {
"user": user,
"event": registration.event,
"body_html": body_html,
"event_url": f"{settings.FRONTEND_ROOT}events/{registration.event.slug}",
}
html = render_to_string("emails/event_announcement.html", ctx)
_send_html_email(subject, html, user.email)
self.message_user(request, f"ارسال ایمیل انجام شد.", messages.SUCCESS)
return redirect(reverse_lazy("admin:events_registration_changelist"))
context = {
**self.admin_site.each_context(request),
"title": "ارسال ایمیل به ثبت‌نام‌های انتخاب‌شده",
"form": AnnouncementForm(),
"opts": self.model._meta,
"action_name": "action_email_selected",
"action_checkbox_name": ACTION_CHECKBOX_NAME,
}
return TemplateResponse(request, "forms/admin_announcement.html", context)
@unfold_action(description="Send Skyroom Credentials")
def action_send_skyroom_credentials(self, request, object_id: int):
send_skyroom_credentials_individual_task.delay(object_id)
self.message_user(request, f"ارسال مشخصات اسکای‌روم به کاربر مربوطه صف شد.", messages.SUCCESS)
return redirect(reverse_lazy("admin:events_registration_changelist"))
from apps.events.tasks import send_invite_to_user
@admin.register(EventEmailLog)
class EventEmailLogAdmin(BaseModelAdmin, ImportExportModelAdmin):
list_display = (
"id",
"event",
"user",
"user_email",
"kind",
"status",
"sent_at",
"created_at",
)
list_filter = (
"kind",
"status",
"event",
("sent_at", admin.EmptyFieldListFilter),
("error", admin.EmptyFieldListFilter),
SoftDeleteListFilter,
)
search_fields = (
"user__email",
"user__username",
"user__first_name",
"user__last_name",
"event__title",
)
autocomplete_fields = ("event", "user")
date_hierarchy = "created_at"
ordering = ("-created_at",)
list_per_page = 50
list_select_related = ("event", "user")
# چون این مدل برای ایدمپوتنسی حیاتی است، ویرایش دستی را محدود می‌کنیم
readonly_fields = (
"event",
"user",
"kind",
"status",
"error",
"sent_at",
"created_at",
"updated_at",
)
fields = readonly_fields
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return True
actions = BaseModelAdmin.actions + [
'resend_selected_emails'
]
@admin.display(description="Email", ordering="user__email")
def user_email(self, obj):
return obj.user.email or ""
@admin.action(description="ارسال مجدد ایمیل برای رکوردهای انتخاب‌شده")
def resend_selected_emails(self, request, queryset):
"""
رکوردهای SENT را اسکیپ می‌کند، بقیه را به وضعیت pending برمی‌گرداند
و تسک ارسال تکی را در صف می‌گذارد (ایدِمپوتنت).
"""
queued = 0
skipped = 0
for log in queryset.select_related("event", "user"):
if log.status == EventEmailLog.STATUS_SENT:
skipped += 1
continue
# برگرداندن به pending و پاک کردن خطا
if log.status != EventEmailLog.STATUS_PENDING or log.error:
log.status = EventEmailLog.STATUS_PENDING
log.error = ""
log.save(update_fields=["status", "error", "updated_at"])
# صف کردن تسک اتمی
send_invite_to_user.delay(log.event_id, log.user_id)
queued += 1
if queued:
self.message_user(
request,
"%(n)d مورد در صف ارسال قرار گرفت." % {"n": queued},
level=messages.SUCCESS,
)
if skipped:
self.message_user(
request,
"%(n)d مورد قبلاً ارسال شده بود و نادیده گرفته شد." % {"n": skipped},
level=messages.WARNING,
)

View File

@@ -0,0 +1,25 @@
from django import forms
from unfold.widgets import UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget
from apps.events.models import Registration
class AnnouncementForm(forms.Form):
subject = forms.CharField(
label="Subject",
max_length=200,
widget=UnfoldAdminTextInputWidget,
)
body_html = forms.CharField(
label="Text (HTML or plain-text)",
widget=UnfoldAdminTextareaWidget,
help_text="you can enter either HTML or plain-text."
)
statuses = forms.MultipleChoiceField(
label="Statuses to sent",
required=False,
choices=Registration.StatusChoices.choices,
initial=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED],
widget=forms.CheckboxSelectMultiple,
)

View File

247
apps/events/api/schemas.py Normal file
View File

@@ -0,0 +1,247 @@
"""Event and gallery API schemas."""
from uuid import UUID
from ninja import ModelSchema, Schema
from pydantic import field_validator
from typing import Literal, Optional, List
from datetime import datetime
from apps.blog.api.schemas import AuthorSchema
from apps.events.models import Event, Registration
from apps.gallery.models import Gallery
from apps.payments.models import Payment
class EventGallerySchema(ModelSchema):
"""Schema representing gallery items associated with an event."""
uploaded_by: AuthorSchema
file_size_mb: float
markdown_url: str
absolute_image_url: Optional[str] = None
class Config:
model = Gallery
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
'width', 'height', 'is_public', 'created_at']
@staticmethod
def resolve_absolute_image_url(obj, context):
request = context['request']
if obj.image and hasattr(obj.image, 'url'):
return request.build_absolute_uri(obj.image.url)
return None
class EventSchema(ModelSchema):
"""Schema providing full event details for API responses."""
gallery_images: List[EventGallerySchema]
description_html: str
registration_count: int
absolute_featured_image_url: Optional[str] = None
class Config:
model = Event
model_fields = [
'id', 'title', 'slug', 'description', 'featured_image', 'event_type',
'address', 'location', 'online_link', 'start_time', 'end_time',
'registration_start_date', 'registration_end_date', 'registration_success_markdown',
'capacity', 'price', 'status', 'created_at', 'updated_at'
]
@staticmethod
def resolve_absolute_featured_image_url(obj, context):
request = context['request']
if obj.featured_image and hasattr(obj.featured_image, 'url'):
return request.build_absolute_uri(obj.featured_image.url)
return None
@staticmethod
def resolve_registration_count(obj):
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
@staticmethod
def resolve_description_html(obj):
return obj.description_html
class EventListSchema(Schema):
"""Condensed event representation for list endpoints."""
id: int
title: str
slug: str
featured_image: Optional[str] = None
absolute_featured_image_url: Optional[str] = None
event_type: str
start_time: datetime
end_time: datetime
registration_start_date: Optional[datetime] = None
registration_end_date: Optional[datetime] = None
capacity: Optional[int] = None
price: Optional[float] = None
status: str
registration_count: int
created_at: datetime
@staticmethod
def resolve_absolute_featured_image_url(obj, context):
request = context['request']
if obj.featured_image and hasattr(obj.featured_image, 'url'):
return request.build_absolute_uri(obj.featured_image.url)
return None
@staticmethod
def resolve_registration_count(obj):
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
class EventCreateSchema(Schema):
"""Payload for creating events via the API."""
title: str
description: str
event_type: str
address: Optional[str] = None
location: Optional[str] = None
online_link: Optional[str] = None
start_time: datetime
end_time: datetime
registration_start_date: Optional[datetime] = None
registration_end_date: Optional[datetime] = None
capacity: Optional[int] = None
price: Optional[float] = None
status: str = "draft"
gallery_image_ids: Optional[List[int]] = []
class EventUpdateSchema(Schema):
"""Payload for updating events via the API."""
title: Optional[str] = None
description: Optional[str] = None
event_type: Optional[str] = None
address: Optional[str] = None
location: Optional[str] = None
online_link: Optional[str] = None
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
registration_start_date: Optional[datetime] = None
registration_end_date: Optional[datetime] = None
capacity: Optional[int] = None
price: Optional[float] = None
status: Optional[str] = None
gallery_image_ids: Optional[List[int]] = None
class RegistrationSchema(ModelSchema):
"""Schema describing a registration entry with event context."""
user: AuthorSchema
event: EventListSchema
discount_code: str | None = None
class Config:
model = Registration
model_fields = [
'id',
'status',
'registered_at',
'ticket_id',
'discount_amount',
'final_price',
'created_at',
'updated_at',
]
@staticmethod
def resolve_discount_code(obj):
return obj.discount_code.code if obj.discount_code else None
class AdminUserSchema(Schema):
id: int
username: str
first_name: str
last_name: str
email: str
class PaymentAdminSchema(Schema):
id: int
authority: Optional[str]
ref_id: Optional[str]
status: int
status_label: str
base_amount: int
discount_amount: int
amount: int
verified_at: Optional[datetime]
created_at: datetime
discount_code: Optional[str]
user: AdminUserSchema
@field_validator("discount_code", mode="before")
def normalize_discount_code(cls, value):
if value is None:
return None
if hasattr(value, "code"):
return value.code
return str(value)
class RegistrationAdminSchema(Schema):
id: int
ticket_id: UUID
status: str
status_label: str
registered_at: datetime
final_price: Optional[int]
discount_amount: Optional[int]
user: AdminUserSchema
payments: List[PaymentAdminSchema]
class EventAdminDetailSchema(EventSchema):
registrations: List[RegistrationAdminSchema] = []
@staticmethod
def resolve_registrations(obj):
return obj.registrations.select_related("user").prefetch_related(
"payments__discount_code"
).order_by("-registered_at")
class PaginatedRegistrationSchema(Schema):
count: int
next: Optional[str] = None
previous: Optional[str] = None
results: List[RegistrationAdminSchema]
class RegistrationStatusUpdateSchema(Schema):
status: str
class RegisterationDetailSchema(Schema):
"""Detailed registration information with associated event metadata."""
event_image: Optional[str]
event_title: str
event_type: str
ticket_id: UUID
status: str
registered_at: datetime
success_markdown: Optional[str]
class EventBriefSchema(Schema):
"""Minimal event representation used for nested responses."""
id: int
title: str
slug: str
start_date: datetime
end_date: Optional[datetime] = None
location: Optional[str] = None
price: int
absolute_image_url: Optional[str] = None
class MyEventRegistrationOut(Schema):
"""Registration information as returned to authenticated users."""
id: int
created_at: datetime
status: Literal["pending", "confirmed", "cancelled", "attended"]
event: EventBriefSchema
class RegistrationStatusOut(Schema):
is_registered: bool
class RegistrationCreateSchema(Schema):
discount_code: Optional[str] = None

370
apps/events/api/views.py Normal file
View File

@@ -0,0 +1,370 @@
from django.shortcuts import get_object_or_404
from django.db.models import Q, Case, When, IntegerField
from django.utils.text import slugify
from django.utils import timezone
from ninja import Router, Query
from ninja.errors import HttpError
from typing import List, Optional
from uuid import UUID
from apps.events.api.schemas import (
EventAdminDetailSchema,
EventBriefSchema,
EventCreateSchema,
EventListSchema,
EventSchema,
EventUpdateSchema,
MyEventRegistrationOut,
PaginatedRegistrationSchema,
RegisterationDetailSchema,
RegistrationCreateSchema,
RegistrationSchema,
RegistrationStatusOut,
RegistrationStatusUpdateSchema,
)
from core.authentication import jwt_auth
from apps.events.models import Event, Registration
from apps.payments.models import DiscountCode
from core.api.schemas import ErrorSchema, MessageSchema
events_router = Router()
# Event endpoints
@events_router.get("/", response=List[EventListSchema])
def list_events(
request,
# status: Optional[str] = None,
status: Optional[List[str]] = Query(None),
event_type: Optional[str] = None,
search: Optional[str] = None,
limit: int = 20,
offset: int = 0
):
"""List events with filtering and pagination"""
queryset = Event.objects.filter(is_deleted=False).prefetch_related('gallery_images')
if status:
if "," in status:
parts = [s.strip() for s in status.split(",") if s.strip()]
queryset = queryset.filter(status__in=parts)
else:
queryset = queryset.filter(status__in=status)
if event_type:
queryset = queryset.filter(event_type=event_type)
if search:
queryset = queryset.filter(
Q(title__icontains=search) | Q(description__icontains=search)
)
queryset = queryset.annotate(
published_first=Case(
When(status='published', then=0),
default=1,
output_field=IntegerField()
)
).order_by('published_first', '-start_time', '-id')
events = queryset[offset:offset + limit]
return events
@events_router.get("/{int:event_id}", response=EventSchema)
def get_event(request, event_id: int):
"""Get event details by ID"""
event = get_object_or_404(
Event.objects.prefetch_related('gallery_images'),
id=event_id,
is_deleted=False
)
return event
@events_router.get("/slug/{str:slug}", response=EventSchema)
def get_event_by_slug(request, slug: str):
"""Get event details by slug"""
event = get_object_or_404(
Event.objects.prefetch_related('gallery_images'),
slug=slug,
is_deleted=False
)
return event
@events_router.post("/", response=EventSchema)
def create_event(request, payload: EventCreateSchema):
"""Create a new event"""
gallery_image_ids = payload.dict().pop('gallery_image_ids', [])
event = Event.objects.create(**payload.dict(exclude={'gallery_image_ids'}))
if gallery_image_ids:
event.gallery_images.set(gallery_image_ids)
return event
@events_router.put("/{int:event_id}", response=EventSchema)
def update_event(request, event_id: int, payload: EventUpdateSchema):
"""Update an existing event"""
event = get_object_or_404(Event, id=event_id, is_deleted=False)
update_data = payload.dict(exclude_unset=True)
gallery_image_ids = update_data.pop('gallery_image_ids', None)
for attr, value in update_data.items():
setattr(event, attr, value)
if 'title' in update_data:
event.slug = slugify(event.title)
event.save()
if gallery_image_ids is not None:
event.gallery_images.set(gallery_image_ids)
return event
@events_router.delete("/{int:event_id}", response=MessageSchema)
def delete_event(request, event_id: int):
"""Soft delete an event"""
event = get_object_or_404(Event, id=event_id, is_deleted=False)
event.delete()
return {"message": "Event deleted successfully"}
# Registration endpoints
@events_router.get("/{int:event_id}/registrations", response=List[RegistrationSchema])
def list_event_registrations(request, event_id: int, limit: int = 20, offset: int = 0):
"""List registrations for a specific event"""
event = get_object_or_404(Event, id=event_id, is_deleted=False)
queryset = event.registrations.filter(is_deleted=False).select_related('user')
registrations = queryset[offset:offset + limit]
return registrations
@events_router.get("/{int:event_id}/admin-registrations", response={200: PaginatedRegistrationSchema, 403: ErrorSchema}, auth=jwt_auth)
def list_event_registrations_admin(
request,
event_id: int,
status: Optional[List[str]] = Query(None),
university: Optional[str] = Query(None),
major: Optional[str] = Query(None),
search: Optional[str] = Query(None),
limit: int = Query(20, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""List registrations with filters for admin dashboard"""
user = request.auth
if not (user.is_staff or user.is_superuser):
return 403, {"error": "اجازه دسترسی ندارید."}
event = get_object_or_404(Event, id=event_id, is_deleted=False)
qs = (
event.registrations.filter(is_deleted=False)
.select_related("user")
.prefetch_related("payments__discount_code")
.order_by("-registered_at")
)
status_values = status or request.GET.getlist('status')
if status_values:
qs = qs.filter(status__in=status_values)
if university:
qs = qs.filter(
Q(user__university__code__icontains=university)
| Q(user__university__name__icontains=university)
)
if major:
qs = qs.filter(
Q(user__major__code__icontains=major)
| Q(user__major__name__icontains=major)
)
if search:
qs = qs.filter(
Q(user__username__icontains=search)
| Q(user__email__icontains=search)
| Q(user__first_name__icontains=search)
| Q(user__last_name__icontains=search)
)
total = qs.count()
results = qs[offset : offset + limit]
return PaginatedRegistrationSchema(count=total, next=None, previous=None, results=list(results))
@events_router.post(
"/{int:event_id}/register",
response=RegistrationSchema,
auth=jwt_auth,
)
def register_for_event(
request,
event_id: int,
payload: RegistrationCreateSchema | None = None,
):
"""Register current user for an event"""
event = get_object_or_404(Event, id=event_id, is_deleted=False)
user = request.auth
if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists():
raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.")
if event.registration_end_date and event.registration_end_date < timezone.now():
raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است")
if event.registration_start_date and event.registration_start_date > timezone.now():
raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است")
if not event.has_available_slots:
raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است")
# Create or get existing registration
discount_code = None
if payload and payload.discount_code:
discount_code = payload.discount_code
elif request.GET.get("discount_code"):
discount_code = request.GET.get("discount_code")
registration, created = Registration.objects.get_or_create(
event=event,
user=user,
status=Registration.StatusChoices.PENDING,
defaults={"final_price": event.price},
)
if registration.status == Registration.StatusChoices.CONFIRMED:
return HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید")
if registration.status == Registration.StatusChoices.CANCELLED:
registration = Registration.objects.create(
event=event,
user=user,
status=Registration.StatusChoices.PENDING,
final_price=event.price,
)
elif not created and registration.final_price is None:
registration.final_price = event.price
registration.save(update_fields=["final_price"])
applied_code = None
discount_amount = 0
final_price = event.price
fields_to_update = []
if discount_code:
applied_code = DiscountCode.objects.filter(
code=discount_code,
applicable_events=event,
is_active=True,
).first()
if not applied_code:
raise HttpError(400, "UcO_ O<>OrU?UOU? U.O1O<31>O\"O<EFBFBD> U+UOO3O<33>")
final_price, discount_amount = applied_code.calculate_discount(event, user)
registration.discount_code = applied_code
registration.discount_amount = discount_amount
fields_to_update.extend(["discount_code", "discount_amount"])
if registration.final_price != final_price:
registration.final_price = final_price
fields_to_update.append("final_price")
if not event.price or final_price == 0:
registration.status = Registration.StatusChoices.CONFIRMED
fields_to_update.append("status")
if fields_to_update:
registration.save(update_fields=list(set(fields_to_update)))
return registration
@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth)
def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema):
"""Update registration status"""
user = request.auth
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
registration.status = payload.dict(exclude_unset=True).get('status')
registration.full_clean()
registration.save()
return registration
@events_router.delete("/registrations/{int:registration_id}", response=MessageSchema, auth=jwt_auth)
def cancel_registration(request, registration_id: int):
"""Cancel a registration"""
user = request.auth
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
registration.delete()
return {"message": "ثبت‌نام شما لغو شد :("}
@events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth)
def verify_my_registration(request, ticket_id: UUID):
try:
reg = Registration.objects.select_related("event").get(ticket_id=ticket_id, user=request.auth)
return {
"event_image": request.build_absolute_uri(reg.event.featured_image.url) if reg.event.featured_image else None,
"event_title": reg.event.title,
"event_type": reg.event.get_event_type_display(),
"ticket_id": reg.ticket_id,
"status": reg.status,
"registered_at": reg.registered_at,
"success_markdown": reg.event.registration_success_markdown,
}
except Registration.DoesNotExist:
raise HttpError(404, "registration not found")
@events_router.get("/my-registrations", response=List[MyEventRegistrationOut], auth=jwt_auth)
def my_registrations(request):
qs = (
Registration.objects
.filter(user=request.auth)
.select_related("event")
.order_by("-created_at")
)
out: List[MyEventRegistrationOut] = []
for r in qs:
out.append(
MyEventRegistrationOut(
id=r.id,
created_at=r.created_at,
status=r.status,
event=EventBriefSchema(
id=r.event.id,
title=r.event.title,
slug=r.event.slug,
start_date=r.event.start_time,
end_date=r.event.end_time,
location=r.event.location,
price=r.event.price,
absolute_image_url=request.build_absolute_uri(r.event.featured_image.url) if r.event.featured_image else None,
),
)
)
return out
@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth)
def is_registered(request, event_id: int):
exists = Registration.objects.filter(
user=request.auth,
event_id=event_id,
status=Registration.StatusChoices.CONFIRMED
).exists()
return {"is_registered": exists}
@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth)
def event_admin_detail(request, event_id: int):
user = request.auth
if not (user.is_staff or user.is_superuser):
return 403, {"error": "اجازه دسترسی ندارید."}
event = get_object_or_404(
Event.objects.prefetch_related(
'gallery_images',
'registrations__user',
'registrations__payments__discount_code'
),
id=event_id,
is_deleted=False,
)
return event

6
apps/events/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EventsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.events"

View 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"
}
}
]

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2026-05-19 14:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0012_alter_eventemaillog_kind'),
]
operations = [
migrations.AlterField(
model_name='eventemaillog',
name='kind',
field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'Skyroom credentials'), ('send_event_announcement', 'Event announcement'), ('send_event_announcement2', 'Event announcement 2'), ('send_event_announcement3', 'Event announcement 3'), ('send_event_reminder', 'Event reminder')], max_length=64),
),
]

View File

269
apps/events/models.py Normal file
View File

@@ -0,0 +1,269 @@
from django.db import models
from django.db import models
from django.conf import settings
from django.utils import timezone
from django.utils.text import slugify
import hashlib
import uuid
import markdown
from location_field.models.plain import PlainLocationField as LocationField
from core.models import BaseModel
class Event(BaseModel):
class TypeChoices(models.TextChoices):
ONLINE = 'online', 'آنلاین'
ON_SITE = 'on_site', 'حضوری'
HYBRID = 'hybrid', 'آنلاین/حضوری'
class StatusChoices(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
CANCELLED = 'cancelled', 'Cancelled'
COMPLETED = 'completed', 'Completed'
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True, blank=True)
description = models.TextField(help_text="Event description in Markdown format")
start_time = models.DateTimeField()
end_time = models.DateTimeField()
address = models.CharField(max_length=255, blank=True, null=True, help_text="Physical address or venue name")
location = LocationField(based_fields=['address'], zoom=15, blank=True, null=True,
help_text="Select location on map")
event_type = models.CharField(max_length=10, choices=TypeChoices.choices, default=TypeChoices.ON_SITE)
online_link = models.URLField(max_length=500, blank=True, null=True,
help_text="Link for online events (e.g., Zoom, Google Meet)")
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
capacity = models.PositiveIntegerField(null=True, blank=True,
help_text="Maximum number of attendees (leave blank for unlimited)")
price = models.IntegerField(default=0, help_text="Price of the event. Leave blank for free events.")
registration_start_date = models.DateTimeField(null=True, blank=True)
registration_end_date = models.DateTimeField(null=True, blank=True)
featured_image = models.ImageField(upload_to='events/featured/', null=True, blank=True)
gallery_images = models.ManyToManyField('gallery.Gallery', blank=True, related_name='event_galleries',
help_text="Images taken during or related to the event.")
registration_success_markdown = models.TextField(
blank=True, null=True,
help_text="Optional markdown shown to users after a successful registration."
)
class Meta:
ordering = ['-start_time']
indexes = [
models.Index(fields=['status', 'start_time']),
models.Index(fields=['event_type']),
]
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
@property
def description_html(self):
"""Convert markdown description to HTML"""
return markdown.markdown(
self.description,
extensions=[
'markdown.extensions.extra',
'markdown.extensions.toc',
]
)
@property
def is_registration_open(self):
now = timezone.now()
return (self.registration_start_date is None or now >= self.registration_start_date) and \
(self.registration_end_date is None or now <= self.registration_end_date)
@property
def current_attendees_count(self):
"""Count confirmed attendees"""
return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count()
@property
def has_available_slots(self):
"""Check whether registration slots are available, treating None as unlimited capacity."""
if self.capacity is None:
return True
return self.current_attendees_count < self.capacity
class Registration(BaseModel):
class StatusChoices(models.TextChoices):
PENDING = 'pending', 'Pending'
CONFIRMED = 'confirmed', 'Confirmed'
CANCELLED = 'cancelled', 'Cancelled'
ATTENDED = 'attended', 'Attended'
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
registered_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=10, choices=StatusChoices.choices,
default=StatusChoices.PENDING)
ticket_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
confirmation_email_sent_at = models.DateTimeField(null=True, blank=True)
cancellation_email_sent_at = models.DateTimeField(null=True, blank=True)
discount_code = models.ForeignKey(
"payments.DiscountCode",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="registrations",
)
discount_amount = models.PositiveIntegerField(default=0)
final_price = models.PositiveIntegerField(null=True, blank=True)
class Meta:
ordering = ['-registered_at']
indexes = [
models.Index(fields=['event', 'status']),
models.Index(fields=['user']),
]
def __str__(self):
return f"{self.user.username} registered for {self.event.title}"
@property
def status_label(self):
"""Human-readable label for the current registration status."""
return self.get_status_display()
def save(self, *args, **kwargs):
# detect create vs update
is_create = self._state.adding
old_status = None
if not is_create and self.pk:
old_status = (
self.__class__.objects.only("status").get(pk=self.pk).status
)
# save first (so we have a pk + final values)
super().save(*args, **kwargs)
# 1) on create -> send confirmation if pending/confirmed (and not sent before)
if is_create and self.status == self.StatusChoices.CONFIRMED and not self.confirmation_email_sent_at:
# lazy import to avoid circular import
from apps.events.tasks import send_registration_confirmation_email
send_registration_confirmation_email.delay(str(self.pk))
self.confirmation_email_sent_at = timezone.now()
super().save(update_fields=["confirmation_email_sent_at"])
# 2) status changed -> cancelled
if (not is_create) and (old_status != self.StatusChoices.CANCELLED) and (self.status == self.StatusChoices.CANCELLED) and (not self.cancellation_email_sent_at):
from apps.events.tasks import send_registration_cancellation_email
send_registration_cancellation_email.delay(str(self.pk))
self.cancellation_email_sent_at = timezone.now()
super().save(update_fields=["cancellation_email_sent_at"])
# 3) status changed -> confirmed (if not sent before)
if (not is_create) and (old_status != self.StatusChoices.CONFIRMED) and (self.status == self.StatusChoices.CONFIRMED) and (not self.confirmation_email_sent_at):
from apps.events.tasks import send_registration_confirmation_email
send_registration_confirmation_email.delay(str(self.pk))
self.confirmation_email_sent_at = timezone.now()
super().save(update_fields=["confirmation_email_sent_at"])
class EventEmailLog(BaseModel):
class KindChoices(models.TextChoices):
INVITE_NON_REGISTERED = "invite_non_registered", "Invite non-registered users"
SKYROOM_CREDENTIALS = "send_skyroom_credentials", "Skyroom credentials"
EVENT_ANNOUNCEMENT = "send_event_announcement", "Event announcement"
EVENT_ANNOUNCEMENT2 = "send_event_announcement2", "Event announcement 2"
EVENT_ANNOUNCEMENT3 = "send_event_announcement3", "Event announcement 3"
EVENT_REMINDER = "send_event_reminder", "Event reminder"
class StatusChoices(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
FAILED = "failed", "Failed"
KIND_INVITE_NON_REGISTERED = KindChoices.INVITE_NON_REGISTERED
KIND_SKYROOM_CREDENTIALS = KindChoices.SKYROOM_CREDENTIALS
KIND_EVENT_ANNOUNCEMENT = KindChoices.EVENT_ANNOUNCEMENT
KIND_EVENT_ANNOUNCEMENT2 = KindChoices.EVENT_ANNOUNCEMENT2
KIND_EVENT_ANNOUNCEMENT3 = KindChoices.EVENT_ANNOUNCEMENT3
KIND_EVENT_REMINDER = KindChoices.EVENT_REMINDER
KIND_CHOICES = KindChoices.choices
STATUS_PENDING = StatusChoices.PENDING
STATUS_SENT = StatusChoices.SENT
STATUS_FAILED = StatusChoices.FAILED
STATUS_CHOICES = StatusChoices.choices
event = models.ForeignKey('events.Event', on_delete=models.CASCADE, related_name='email_logs')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='email_logs')
kind = models.CharField(max_length=64, choices=KIND_CHOICES)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
error = models.TextField(blank=True, null=True)
sent_at = models.DateTimeField(blank=True, null=True)
context_hash = models.CharField(max_length=64, blank=True, null=True)
class Meta:
unique_together = ("event", "user", "kind", "context_hash")
indexes = [
models.Index(fields=["event", "kind", "status"]),
models.Index(fields=["user", "kind", "status"]),
]
def __str__(self):
return f"{self.event.id} - {self.user.id} - {self.kind} - {self.status}"
@staticmethod
def _hash_context(context):
if context is None:
return None
if not isinstance(context, str):
context = str(context)
return hashlib.sha256(context.encode("utf-8")).hexdigest()
@classmethod
def claim(cls, *, event_id, user_id, kind, context=None):
context_hash = cls._hash_context(context)
log, created = cls.objects.get_or_create(
event_id=event_id,
user_id=user_id,
kind=kind,
context_hash=context_hash,
defaults={"status": cls.STATUS_PENDING},
)
if not created and log.status in (cls.STATUS_PENDING, cls.STATUS_SENT):
return log, True
if not created:
log._commit_status(cls.STATUS_PENDING, error="")
return log, False
def _commit_status(self, status, *, error="", sent_at=None):
self.status = status
self.error = error
update_fields = ["status", "error"]
if status == self.STATUS_SENT:
self.sent_at = sent_at or timezone.now()
update_fields.append("sent_at")
elif self.sent_at is not None:
self.sent_at = None
update_fields.append("sent_at")
if hasattr(self, "updated_at"):
update_fields.append("updated_at")
self.save(update_fields=update_fields)
def mark_sent(self):
self._commit_status(self.STATUS_SENT)
def mark_failed(self, error):
self._commit_status(self.STATUS_FAILED, error=error)

86
apps/events/resources.py Normal file
View File

@@ -0,0 +1,86 @@
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
from apps.events.models import Event, Registration
from apps.users.models import User
from apps.gallery.models import Gallery
from apps.payments.models import DiscountCode
class EventResource(resources.ModelResource):
gallery_images = fields.Field(
column_name='gallery_images',
attribute='gallery_images',
widget=ManyToManyWidget(Gallery, field='title', separator='|')
)
class Meta:
model = Event
fields = (
'id', 'title', 'slug', 'description', 'start_time', 'end_time',
'event_type', 'address', 'location', 'online_link', 'status',
'capacity', 'price', 'registration_start_date', 'registration_end_date',
'featured_image', 'gallery_images', 'created_at', 'updated_at',
'is_deleted', 'deleted_at'
)
export_order = fields
class RegistrationResource(resources.ModelResource):
"""Export registrations with user attributes and shortened ticket identifiers."""
event = fields.Field(
column_name='event',
attribute='event',
widget=ForeignKeyWidget(Event, 'title')
)
user_username = fields.Field(
column_name='user_username',
attribute='user',
widget=ForeignKeyWidget(User, 'username')
)
user_email = fields.Field(
column_name='user_email',
attribute='user',
widget=ForeignKeyWidget(User, 'email')
)
user_first_name = fields.Field(
column_name='user_first_name',
attribute='user',
widget=ForeignKeyWidget(User, 'first_name')
)
user_last_name = fields.Field(
column_name='user_last_name',
attribute='user',
widget=ForeignKeyWidget(User, 'last_name')
)
discount_code = fields.Field(
column_name='discount_code',
attribute='discount_code',
widget=ForeignKeyWidget(DiscountCode, 'code')
)
class Meta:
model = Registration
fields = (
'id',
'event',
'user_username',
'user_email',
'user_first_name',
'user_last_name',
'registered_at',
'status',
'ticket_id',
'discount_code',
'discount_amount',
'final_price',
'created_at',
'updated_at',
'is_deleted',
'deleted_at',
)
export_order = fields
def dehydrate_ticket_id(self, obj):
"""Limit ticket identifiers to eight characters in exports."""
val = getattr(obj, 'ticket_id', '')
return str(val)[:8] if val else ''

584
apps/events/tasks.py Normal file
View File

@@ -0,0 +1,584 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from django.utils import timezone
from celery import shared_task, group
from celery.exceptions import SoftTimeLimitExceeded
import markdown
import logging
from apps.users.models import User
from apps.events.models import Event, Registration, EventEmailLog
from core.templatetags.jalali import fa_digits, jdate
logger = logging.getLogger(__name__)
ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS = 30
ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS = 45
@shared_task(bind=True, max_retries=3)
def send_registration_confirmation_email(self, registration_pk: str):
"""Send a registration confirmation email, loading the model lazily to avoid circular imports."""
try:
from .models import Registration
reg = (
Registration.objects
.select_related("event", "user")
.get(pk=registration_pk)
)
user_email = getattr(reg.user, "email", None)
if not user_email:
return
success_md = reg.event.registration_success_markdown or ""
success_html = markdown.markdown(
success_md,
extensions=["extra", "sane_lists", "toc"]
) if success_md else ""
context = {
"user": reg.user,
"event": reg.event,
"registration": reg,
"success_html": success_html,
}
subject = f"تأیید ثبت‌نام شما در {reg.event.title}"
html_body = render_to_string("emails/event_registration_confirmation.html", context)
plain_body = strip_tags(html_body)
message = EmailMultiAlternatives(
subject=subject,
body=plain_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user_email],
)
message.attach_alternative(html_body, "text/html")
message.send(fail_silently=False)
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
except Exception as exc:
logger.error(f"Failed to send event registration email: {exc}")
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3)
def send_registration_cancellation_email(self, registration_pk: str):
try:
from .models import Registration
reg = (
Registration.objects
.select_related("event", "user")
.get(pk=registration_pk)
)
user_email = getattr(reg.user, "email", None)
if not user_email:
return
context = {
"user": reg.user,
"event": reg.event,
"registration": reg,
}
subject = f"لغو ثبت‌نام شما در {reg.event.title}"
html_body = render_to_string("emails/event_registration_cancellation.html", context)
plain_body = strip_tags(html_body)
message = EmailMultiAlternatives(
subject=subject,
body=plain_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user_email],
)
message.attach_alternative(html_body, "text/html")
message.send(fail_silently=False)
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
except Exception as exc:
logger.error(f"Failed to send event registration email: {exc}")
raise self.retry(exc=exc, countdown=60)
def _event_recipients(event, statuses=None, only_verified=True):
qs = Registration.objects.filter(event=event, is_deleted=False)
if statuses:
qs = qs.filter(status__in=statuses)
if only_verified:
qs = qs.filter(user__is_email_verified=True)
qs = qs.exclude(user__email__isnull=True).exclude(user__email="")
return qs.select_related("user")
def _send_html_email(subject, html_body, to_email):
text_body = strip_tags(html_body)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[to_email],
)
msg.attach_alternative(html_body, "text/html")
msg.send()
def _build_email_context(*parts):
values = [str(part) for part in parts if part not in (None, "")]
return "|".join(values) if values else None
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3}, soft_time_limit=60)
def send_skyroom_credentials_individual_task(self, reg_id: int):
"""
ارسال نام‌کاربری/رمز برای اسکای‌روم
- username = user.email
- password = registration.ticket_id[:8]
- url = event.online_link (اگر لینک در فیلد online_link ذخیره شده باشد)
"""
r = Registration.objects.get(pk=reg_id)
event = r.event
user = r.user
sky_user = user.email.strip().split('@')[0]
sky_pass = str(r.ticket_id)[:8]
skyroom_url = event.online_link
try:
ctx = {
"user": user,
"event": event,
"skyroom_url": skyroom_url,
"sky_username": sky_user,
"sky_password": sky_pass,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"اطلاعات دسترسی اسکای‌روم - {event.title}"
html = render_to_string("emails/skyroom_credentials.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
)
msg.attach_alternative(html, "text/html")
msg.send()
logger.info(f'Skyroom Credentials for Event "{event.title}" sent to {user.email}')
except Exception as exc:
logger.error(f"Failed to send skyroom credentials email: {exc}")
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True)
def send_event_reminder_task(self, event_id: int):
"""
یادآوری رویداد (ارسال الان؛ برای ارسال خودکار یک روز قبل، یک beat job بسازید)
"""
event = Event.objects.get(pk=event_id)
regs = (
_event_recipients(event, statuses=["confirmed", "attended"])
.select_related("user", "event")
.distinct()
)
reg_ids = list(regs.values_list("id", flat=True))
job = group(send_event_reminder_to_user.s(event_id, rid) for rid in reg_ids)
res = job.apply_async()
logger.info(
'Queued %s event reminder emails for event "%s" (group_id=%s)',
len(reg_ids),
event.title,
res.id,
)
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
def send_event_reminder_to_user(self, event_id: int, registration_id: int):
"""
Send reminder email to a single registration; safe to retry without duplicating emails.
"""
user = None
log = None
try:
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
to_email = (user.email or "").strip()
if not to_email:
return {"skipped": True, "status": "no_email"}
context_key = _build_email_context(
"event_reminder",
event.slug or event.id,
event.start_time,
)
log, skip = EventEmailLog.claim(
event_id=event_id,
user_id=user.id,
kind=EventEmailLog.KIND_EVENT_REMINDER,
context=context_key,
)
if skip:
return {"skipped": True, "status": log.status}
ctx = {
"user": user,
"event": event,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"یادآوری رویداد: {event.title}"
html = render_to_string("emails/event_reminder.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[to_email],
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Event reminder for "%s" sent to %s', event.title, to_email)
return f"Email sent to {to_email}"
except SoftTimeLimitExceeded:
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning(
"Soft time limit exceeded (event_id=%s, registration_id=%s)",
event_id,
registration_id,
)
raise
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error(
"Failed to send event reminder email: %s", exc, exc_info=True
)
raise
@shared_task(bind=True)
def queue_event_announcement(self, event_id: int, subject: str, body_html: str, statuses=None):
"""
تسک مادر: ثبت‌نام‌های هدف را پیدا می‌کند و برای هر Registration یک تسک کوچک می‌سازد.
"""
event = Event.objects.get(pk=event_id)
# محدوده مخاطبان: اگر statuses داده نشد، همان پیش‌فرض قبلی شما
statuses = statuses or ["confirmed", "attended", "pending"]
regs = (
_event_recipients(event, statuses=statuses)
.select_related("user", "event")
.exclude(user__email__isnull=True)
.exclude(user__email="")
.distinct()
)
reg_ids = list(regs.values_list("id", flat=True))
# ساخت group از تسک‌های کوچک؛ هر کدام فقط یک ایمیل ارسال می‌کند
job = group(
send_event_announcement_to_user.s(event_id, rid, subject, body_html)
for rid in reg_ids
)
# اگر نتیجه‌ها لازم نیست: CELERY_TASK_IGNORE_RESULT = True
res = job.apply_async()
logger.info(
'Queued %s event-announcement emails for event "%s" (group_id=%s)',
len(reg_ids), event.title, res.id
)
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
def send_event_announcement_to_user(self, event_id: int, registration_id: int, subject: str, body_html: str):
"""
تسک کوچک و اتمی: ارسال ایمیل اعلان رویداد برای یک Registration.
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
"""
user = None
log = None
try:
# از Registration می‌گیریم تا یک کوئری کمتر به Event بزنیم
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
context_key = _build_email_context(
"event_announcement3",
event.slug or event.id,
subject,
body_html,
)
log, skip = EventEmailLog.claim(
event_id=event_id,
user_id=user.id,
kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT3,
context=context_key,
)
if skip:
return {"skipped": True, "status": log.status}
# کانتکست رندر ایمیل: body_html مستقیم داخل تمپلیت شما اینجکت می‌شود
ctx = {
"user": user,
"event": event,
"body_html": body_html,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
html = render_to_string("emails/event_announcement.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Event announcement for "%s" sent to %s', event.title, user.email)
return f"Email sent to {user.email}"
except SoftTimeLimitExceeded:
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning("Soft time limit exceeded (event_id=%s, registration_id=%s)", event_id, registration_id)
raise
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error("Failed to send event announcement email: %s", exc, exc_info=True)
raise
def _event_url(event):
root = getattr(settings, "FRONTEND_ROOT", "/")
slug_or_id = getattr(event, "slug", None) or event.id
return f"{root}events/{slug_or_id}"
@shared_task(bind=True)
def queue_invites_to_non_registered_users(self, event_id: int, only_verified=True, only_active=True):
"""
تسک مادر: فقط کاربرها را پیدا می‌کند و برای هر نفر یک تسک کوچک می‌سازد.
"""
event = Event.objects.get(pk=event_id)
qs = User.objects.all()
if only_verified:
qs = qs.filter(is_email_verified=True)
if only_active:
qs = qs.filter(is_active=True)
# کسانی که برای این ایونت ثبت‌نام نکرده‌اند
qs = qs.exclude(event_registrations__event_id=event_id) \
.exclude(email__isnull=True).exclude(email="") \
.distinct()
user_ids = list(qs.values_list("id", flat=True))
# گَروهِ تسک‌های کوچک
job = group(send_invite_to_user.s(event_id, uid) for uid in user_ids)
res = job.apply_async()
return {"event_id": event_id, "queued": len(user_ids), "group_id": res.id}
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, time_limit=60)
def send_invite_to_user(self, event_id: int, user_id: int):
"""
تسک کوچک و اتمی: برای هر کاربر حداکثر یک ایمیل می‌فرستد (با لاگ ایدمپوتنسی).
"""
event = Event.objects.get(pk=event_id)
user = User.objects.get(pk=user_id)
# ساخت محتوا
context = {
"user": user,
"event": event,
"event_url": _event_url(event),
"start_time": fa_digits(jdate(event.start_time))
}
# ایدمپوتنسی: اگر قبلاً این ایمیل رزرو/ارسال شده، Skip
subject = f"دعوت به شرکت در «{event.title}»"
text_body = render_to_string("emails/event_invite_non_registered.txt", context)
html_body = render_to_string("emails/event_invite_non_registered.html", context)
context_key = _build_email_context(
"invite_non_registered",
event.slug or event.id,
html_body,
)
log, skip = EventEmailLog.claim(
event_id=event_id,
user_id=user_id,
kind=EventEmailLog.KIND_INVITE_NON_REGISTERED,
context=context_key,
)
if skip:
return {"skipped": True, "status": log.status}
try:
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.email],
)
msg.attach_alternative(html_body, "text/html")
msg.send()
log.mark_sent()
return f"Email sent to {user.email}"
except Exception as exc:
log.mark_failed(str(exc))
raise
@shared_task(bind=True)
def queue_skyroom_credentials(self, event_id: int):
"""
تسک مادر: ثبت‌نام‌های تاییدشده را پیدا می‌کند و برای هر Registration یک تسک کوچک می‌سازد.
"""
event = Event.objects.get(pk=event_id)
# فقط CONFIRMED ها + ایمیل معتبر
regs = (
_event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED])
.select_related("user", "event")
.exclude(user__email__isnull=True)
.exclude(user__email="")
.distinct()
)
reg_ids = list(regs.values_list("id", flat=True))
# ساخت group از تسک‌های کوچک؛ هر کدوم فقط یک ایمیل ارسال می‌کنند
job = group(send_skyroom_credentials_to_user.s(event_id, rid) for rid in reg_ids)
# توصیه: اگر نتیجه‌ها را لازم ندارید، در تنظیمات CELERY_TASK_IGNORE_RESULT=True بگذارید
res = job.apply_async()
logger.info(
'Queued %s Skyroom-credential emails for event "%s" (group_id=%s)',
len(reg_ids), event.title, res.id
)
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int):
"""
تسک کوچک و اتمی: ارسال نام‌کاربری/رمز اسکای‌روم برای یک Registration.
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
"""
user = None
log = None
try:
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
# ساخت یوزرنیم/پسورد
sky_username = (user.email or "").strip().split("@")[0]
sky_password = str(r.ticket_id or "")[:8]
skyroom_url = event.online_link
context_key = _build_email_context(
"skyroom_credentials",
event.slug or event.id,
sky_username,
sky_password,
skyroom_url,
)
log, skip = EventEmailLog.claim(
event_id=event_id,
user_id=user.id,
kind=EventEmailLog.KIND_SKYROOM_CREDENTIALS,
context=context_key,
)
if skip:
return {"skipped": True, "status": log.status}
ctx = {
"user": user,
"event": event,
"skyroom_url": skyroom_url,
"sky_username": sky_username,
"sky_password": sky_password,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"اطلاعات دسترسی اسکای‌روم - {event.title}"
html = render_to_string("emails/skyroom_credentials.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Skyroom credentials for "%s" sent to %s', event.title, user.email)
return f"Email sent to {user.email}"
except SoftTimeLimitExceeded as exc:
# ثبت خطا و اجازه به Celery برای retry خودکار
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning(
"Soft time limit exceeded for event_id=%s, registration_id=%s", event_id, registration_id
)
raise
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error("Failed to send skyroom credentials email: %s", exc, exc_info=True)
raise

View File

View File

@@ -0,0 +1,540 @@
import io
import json
import tempfile
import uuid
from datetime import timedelta
from types import SimpleNamespace
from PIL import Image
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.utils import timezone
from core.authentication import create_jwt_token
from apps.events.api.schemas import (
EventSchema,
EventGallerySchema,
EventListSchema,
RegistrationSchema,
PaymentAdminSchema,
EventAdminDetailSchema,
)
from apps.events.api.views import list_events
from apps.events.models import Event, Registration
from apps.gallery.models import Gallery
from apps.payments.models import DiscountCode
from apps.users.models import Major, University, User
MEDIA_ROOT = tempfile.mkdtemp()
@override_settings(MEDIA_ROOT=MEDIA_ROOT)
class EventsAPIIntegrationTests(TestCase):
password = "TestPass123!"
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="event_user",
email="event.user@example.com",
password=cls.password,
)
cls.user.is_email_verified = True
cls.user.save(update_fields=["is_email_verified"])
cls.staff = User.objects.create_user(
username="event_staff",
email="event.staff@example.com",
password=cls.password,
is_staff=True,
)
cls.staff.is_email_verified = True
cls.staff.save(update_fields=["is_email_verified"])
cls.major, _ = Major.objects.get_or_create(code="CS", defaults={"name": "Computer Science"})
cls.university, _ = University.objects.get_or_create(code="UT", defaults={"name": "University of Tehran"})
cls.user.major = cls.major
cls.user.university = cls.university
cls.user.save(update_fields=["major", "university"])
cls.staff.major = cls.major
cls.staff.university = cls.university
cls.staff.save(update_fields=["major", "university"])
def setUp(self):
super().setUp()
self.token = create_jwt_token(self.user)
self.staff_token = create_jwt_token(self.staff)
self.event = self._create_event(
title="Integration Event",
description="Integration description.",
status=Event.StatusChoices.PUBLISHED,
price=0,
)
self.other_event = self._create_event(
title="Other Published",
description="Searchable",
status=Event.StatusChoices.PUBLISHED,
price=0,
)
def _auth_headers(self, token):
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
def _create_event(self, **overrides):
now = timezone.now()
defaults = {
"title": "Event Title",
"description": "Description",
"start_time": now,
"end_time": now + timedelta(hours=2),
"registration_start_date": now - timedelta(days=1),
"registration_end_date": now + timedelta(days=5),
"slug": f"event-{uuid.uuid4().hex[:6]}",
"location": "Campus",
"online_link": "https://meet.example.com",
"price": 0,
"capacity": 10,
"status": Event.StatusChoices.PUBLISHED,
}
defaults.update(overrides)
return Event.objects.create(**defaults)
def _create_gallery_image(self):
buffer = io.BytesIO()
Image.new("RGB", (10, 10), color="blue").save(buffer, format="PNG")
buffer.seek(0)
file = SimpleUploadedFile("gallery.png", buffer.read(), content_type="image/png")
return Gallery.objects.create(
title="Gallery image",
description="desc",
image=file,
uploaded_by=self.user,
)
def _create_paid_event(self):
return self._create_event(price=30000, capacity=5)
def _create_registration(self, event, user, status=Registration.StatusChoices.PENDING):
return Registration.objects.create(event=event, user=user, status=status, final_price=event.price)
# Basic event endpoints ------------------------------------------------
def test_list_events_filters_and_search(self):
# Act
response = self.client.get("/api/events/", {"status": "published", "search": "Searchable"})
data = response.json()
# Assert
self.assertEqual(response.status_code, 200)
self.assertTrue(any(item["id"] == self.other_event.id for item in data))
def test_get_event_by_id_and_slug(self):
response_id = self.client.get(f"/api/events/{self.event.id}")
response_slug = self.client.get(f"/api/events/slug/{self.event.slug}")
self.assertEqual(response_id.status_code, 200)
self.assertEqual(response_slug.status_code, 200)
self.assertEqual(response_id.json()["id"], self.event.id)
self.assertEqual(response_slug.json()["slug"], self.event.slug)
def test_create_update_and_delete_event(self):
payload = {
"title": "New Event",
"description": "Desc",
"start_time": (timezone.now() + timedelta(days=1)).isoformat(),
"end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(),
"event_type": Event.TypeChoices.ON_SITE,
"status": Event.StatusChoices.DRAFT,
"price": 5000,
}
created = self.client.post(
"/api/events/",
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(created.status_code, 200)
event_id = created.json()["id"]
updated = self.client.put(
f"/api/events/{event_id}",
data=json.dumps({"title": "Updated Event"}),
content_type="application/json",
)
self.assertEqual(updated.status_code, 200)
self.assertEqual(updated.json()["title"], "Updated Event")
deleted = self.client.delete(f"/api/events/{event_id}")
self.assertEqual(deleted.status_code, 200)
def test_admin_detail_and_registration_list_requires_staff(self):
staff_headers = self._auth_headers(self.staff_token)
user_headers = self._auth_headers(self.token)
_ = self._create_registration(self.event, self.user, status=Registration.StatusChoices.CONFIRMED)
# Non staff forbidden
list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **user_headers)
self.assertEqual(list_resp.status_code, 403)
# Staff allowed
list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **staff_headers)
detail_resp = self.client.get(f"/api/events/{self.event.id}/admin-detail", **staff_headers)
self.assertEqual(list_resp.status_code, 200)
self.assertEqual(detail_resp.status_code, 200)
def test_list_events_filters_by_event_type_and_search(self):
event = self._create_event(
title="Special Search",
description="Unique discovery",
event_type=Event.TypeChoices.ONLINE,
status=Event.StatusChoices.PUBLISHED,
)
response = self.client.get(
"/api/events/",
{
"event_type": Event.TypeChoices.ONLINE,
"search": "Unique discovery",
},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(any(item["id"] == event.id for item in response.json()))
def test_list_events_handles_comma_status_parameter(self):
event = self._create_event(
title="Comma Event",
status=Event.StatusChoices.PUBLISHED,
)
results = list_events(
None,
status=f"{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}",
event_type=None,
search=None,
limit=10,
offset=0,
)
self.assertIn(event, list(results))
def test_create_event_attaches_gallery_images(self):
gallery = self._create_gallery_image()
payload = {
"title": "Gallery Event",
"description": "Gallery desc",
"start_time": (timezone.now() + timedelta(days=1)).isoformat(),
"end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(),
"event_type": Event.TypeChoices.ON_SITE,
"status": Event.StatusChoices.DRAFT,
"price": 5000,
"gallery_image_ids": [gallery.id],
}
response = self.client.post(
"/api/events/",
data=json.dumps(payload),
content_type="application/json",
)
body = response.json()
self.assertEqual(response.status_code, 200)
self.assertTrue(body["gallery_images"])
updated = self.client.put(
f"/api/events/{body['id']}",
data=json.dumps(
{
"title": "Gallery Event Updated",
"gallery_image_ids": [gallery.id],
}
),
content_type="application/json",
)
self.assertEqual(updated.status_code, 200)
self.assertEqual(updated.json()["slug"], "gallery-event-updated")
self.assertTrue(updated.json()["gallery_images"])
def test_admin_registration_filters_include_university_major_and_search(self):
event = self.event
self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
headers = self._auth_headers(self.staff_token)
response = self.client.get(
f"/api/events/{event.id}/admin-registrations",
{
"university": self.user.university.code,
"major": self.user.major.code,
"search": self.user.username,
"status": [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.PENDING],
},
**headers,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 1)
def test_register_before_start_and_after_end_dates_fail(self):
future_event = self._create_event(registration_start_date=timezone.now() + timedelta(days=1))
future_response = self.client.post(
f"/api/events/{future_event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(future_response.status_code, 400)
closed_event = self._create_event(registration_end_date=timezone.now() - timedelta(hours=1))
closed_response = self.client.post(
f"/api/events/{closed_event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(closed_response.status_code, 400)
def test_register_recreates_after_cancelled_registration(self):
event = self._create_event(price=0)
Registration.objects.create(
event=event,
user=self.user,
status=Registration.StatusChoices.CANCELLED,
final_price=0,
)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED)
def test_register_updates_final_price_when_none(self):
event = self._create_paid_event()
registration = Registration.objects.create(
event=event,
user=self.user,
status=Registration.StatusChoices.PENDING,
final_price=None,
)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["final_price"], event.price)
def _create_discount_code(self, event):
code = DiscountCode.objects.create(
code=f"CODE-{uuid.uuid4().hex[:4]}",
value=50,
type=DiscountCode.Type.PERCENT,
is_active=True,
)
code.applicable_events.add(event)
return code
def test_register_for_event_with_free_price_confirms(self):
event = self._create_event(price=0)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED)
def test_register_for_event_with_discount_updates_final_price(self):
event = self._create_paid_event()
code = self._create_discount_code(event)
response = self.client.post(
f"/api/events/{event.id}/register",
data=json.dumps({"discount_code": code.code}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
result = response.json()
self.assertEqual(response.status_code, 200)
self.assertEqual(result["discount_code"], code.code)
self.assertEqual(result["discount_amount"], event.price // 2)
self.assertEqual(result["final_price"], event.price // 2)
def test_register_fails_when_capacity_full(self):
event = self._create_event(capacity=1)
other = self._create_event_user("other_user", "other@example.com")
Registration.objects.create(
event=event,
user=other,
status=Registration.StatusChoices.CONFIRMED,
final_price=0,
)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
def _create_event_user(self, username, email):
user = User.objects.create_user(username=username, email=email, password=self.password)
user.is_email_verified = True
user.save(update_fields=["is_email_verified"])
user.major = self.user.major
user.university = self.user.university
user.save(update_fields=["major", "university"])
return user
def test_register_rejects_duplicate_confirmed(self):
event = self._create_event(price=0)
Registration.objects.create(
event=event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=0,
)
response = self.client.post(
f"/api/events/{event.id}/register",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
def test_registration_status_update_and_cancel(self):
event = self._create_event(price=0)
registration = self._create_registration(event, self.user)
update = self.client.put(
f"/api/events/registrations/{registration.id}",
data=json.dumps({"status": Registration.StatusChoices.ATTENDED}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(update.status_code, 200)
self.assertEqual(update.json()["status"], Registration.StatusChoices.ATTENDED)
cancel = self.client.delete(
f"/api/events/registrations/{registration.id}",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(cancel.status_code, 200)
self.assertEqual(cancel.json()["message"], "ثبت‌نام شما لغو شد :(")
def test_verify_registration_and_my_registrations(self):
event = self._create_event(price=0)
registration = self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
verify = self.client.get(
f"/api/events/registerations/verify/{registration.ticket_id}",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(verify.status_code, 200)
self.assertEqual(verify.json()["ticket_id"], str(registration.ticket_id))
my_regs = self.client.get(
"/api/events/my-registrations",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(my_regs.status_code, 200)
self.assertGreater(len(my_regs.json()), 0)
status_resp = self.client.get(
f"/api/events/{event.id}/is-registered",
HTTP_AUTHORIZATION=f"Bearer {self.token}",
)
self.assertEqual(status_resp.status_code, 200)
self.assertTrue(status_resp.json()["is_registered"])
def test_list_event_registrations(self):
event = self.event
self._create_registration(event, self.user)
response = self.client.get(f"/api/events/{event.id}/registrations")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json())
def test_list_event_registrations_admin_filters(self):
event = self.event
self._create_registration(event, self.user, status=Registration.StatusChoices.PENDING)
headers = self._auth_headers(self.staff_token)
response = self.client.get(
f"/api/events/{event.id}/admin-registrations",
{"status": [Registration.StatusChoices.PENDING]},
**headers,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["count"], 1)
class EventSchemasIntegrationTests(TestCase):
password = "SchemaPass!123"
def setUp(self):
self.user = User.objects.create_user(
username="schema_user",
email="schema.user@example.com",
password=self.password,
)
self.user.is_email_verified = True
self.user.save(update_fields=["is_email_verified"])
self.event = Event.objects.create(
title="Schema Event",
description="**bold**",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
price=1000,
slug="schema-event",
)
Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=0,
)
Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.ATTENDED,
final_price=0,
)
def _mock_request(self):
return SimpleNamespace(build_absolute_uri=lambda path: f"https://test{path}")
def test_gallery_schema_returns_full_url(self):
obj = SimpleNamespace(image=SimpleNamespace(url="/media/gallery.png"))
result = EventGallerySchema.resolve_absolute_image_url(obj, {"request": self._mock_request()})
self.assertEqual(result, "https://test/media/gallery.png")
def test_event_schema_resolvers(self):
context = {"request": self._mock_request()}
event_obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations)
self.assertEqual(EventSchema.resolve_absolute_featured_image_url(event_obj, context), "https://test/media/feat.png")
self.assertEqual(EventSchema.resolve_registration_count(self.event), 2)
self.assertIn("<p>", EventSchema.resolve_description_html(self.event))
def test_event_list_schema_resolvers(self):
obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations)
context = {"request": self._mock_request()}
self.assertEqual(EventListSchema.resolve_absolute_featured_image_url(obj, context), "https://test/media/feat.png")
self.assertEqual(EventListSchema.resolve_registration_count(self.event), 2)
def test_registration_schema_resolves_discount_code(self):
discount = DiscountCode.objects.create(code="SCHEMA", type=DiscountCode.Type.FIXED, value=100, is_active=True)
discount.applicable_events.add(self.event)
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=900,
discount_code=discount,
)
self.assertEqual(RegistrationSchema.resolve_discount_code(registration), discount.code)
def test_payment_admin_schema_normalizes_discount_code(self):
self.assertIsNone(PaymentAdminSchema.normalize_discount_code(None))
self.assertEqual(PaymentAdminSchema.normalize_discount_code("123"), "123")
self.assertEqual(PaymentAdminSchema.normalize_discount_code(SimpleNamespace(code="ABC")), "ABC")
def test_event_admin_detail_resolves_registrations(self):
registrations = EventAdminDetailSchema.resolve_registrations(self.event)
self.assertTrue(list(registrations))
# TODO registration-related tests

View File

File diff suppressed because it is too large Load Diff

89
apps/gallery/admin.py Normal file
View File

@@ -0,0 +1,89 @@
from django.contrib import admin
from django.utils.html import format_html
from import_export.admin import ImportExportModelAdmin
from apps.gallery.models import Gallery
from apps.gallery.resources import GalleryResource
from core.admin import SoftDeleteListFilter, BaseModelAdmin
@admin.register(Gallery)
class GalleryAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = GalleryResource
list_display = ('title', 'image_preview', 'uploaded_by', 'file_size_display', 'dimensions', 'is_public', 'created_at')
list_filter = ('is_public', 'created_at', SoftDeleteListFilter)
search_fields = ('title', 'description', 'alt_text')
readonly_fields = ('uploaded_by', 'file_size', 'width', 'height', 'image_preview_large', 'markdown_url')
fieldsets = (
('Image Info', {
'fields': ('title', 'description', 'image', 'alt_text', 'is_public')
}),
('Uploader', {
'fields': ('uploaded_by',),
'classes': ('collapse',)
}),
('Metadata', {
'fields': ('file_size', 'width', 'height'),
'classes': ('collapse',)
}),
('Preview & Usage', {
'fields': ('image_preview_large', 'markdown_url'),
'classes': ('collapse',)
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)
actions = BaseModelAdmin.actions + ['make_public', 'make_private', 'restore_images']
def image_preview(self, obj):
if obj.image:
return format_html(
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
obj.image.url
)
return "No Image"
image_preview.short_description = "Preview"
def image_preview_large(self, obj):
if obj.image:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; object-fit: contain;" />',
obj.image.url
)
return "No Image"
image_preview_large.short_description = "Image Preview"
def file_size_display(self, obj):
return f"{obj.file_size_mb} MB" if obj.file_size else "Unknown"
file_size_display.short_description = "File Size"
def dimensions(self, obj):
if obj.width and obj.height:
return f"{obj.width} × {obj.height}"
return "Unknown"
dimensions.short_description = "Dimensions"
def make_public(self, request, queryset):
queryset.update(is_public=True)
self.message_user(request, f"Made {queryset.count()} images public.")
make_public.short_description = "Make selected images public"
def make_private(self, request, queryset):
queryset.update(is_public=False)
self.message_user(request, f"Made {queryset.count()} images private.")
make_private.short_description = "Make selected images private"
def restore_images(self, request, queryset):
for image in queryset:
image.restore()
self.message_user(request, f"Restored {queryset.count()} images.")
restore_images.short_description = "Restore selected images"
def save_model(self, request, obj, form, change):
if not obj.uploaded_by_id:
obj.uploaded_by = request.user
super().save_model(request, obj, form, change)

View File

View File

@@ -0,0 +1,27 @@
"""Schemas for gallery resources."""
from ninja import Schema, ModelSchema
from typing import Optional
from apps.blog.api.schemas import AuthorSchema
from apps.gallery.models import Gallery
class GallerySchema(ModelSchema):
"""Serialized representation of a gallery image."""
uploaded_by: AuthorSchema
file_size_mb: float
markdown_url: str
class Config:
model = Gallery
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
'width', 'height', 'is_public', 'created_at']
class GalleryCreateSchema(Schema):
"""Payload for creating a gallery entry."""
title: str
description: Optional[str] = None
alt_text: Optional[str] = None
is_public: bool = True

128
apps/gallery/api/views.py Normal file
View File

@@ -0,0 +1,128 @@
from django.shortcuts import get_object_or_404
from django.core.files.base import ContentFile
from ninja import Router, Query, File, UploadedFile
from typing import List
import uuid
from apps.gallery.api.schemas import GalleryCreateSchema, GallerySchema
from apps.gallery.models import Gallery
from apps.gallery.tasks import process_uploaded_image
from core.api.schemas import ErrorSchema, MessageSchema
from core.authentication import jwt_auth
gallery_router = Router()
@gallery_router.get("/images", response=List[GallerySchema])
def list_gallery_images(
request,
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=50),
public_only: bool = Query(True)
):
"""List gallery images"""
queryset = Gallery.objects.select_related('uploaded_by')
if public_only:
queryset = queryset.filter(is_public=True)
# Pagination
offset = (page - 1) * limit
images = queryset[offset:offset + limit]
return images
@gallery_router.get("/images/{image_id}", response=GallerySchema)
def get_gallery_image(request, image_id: int):
"""Get single gallery image"""
image = get_object_or_404(Gallery, id=image_id, is_public=True)
return image
@gallery_router.post("/images", response={201: GallerySchema, 400: ErrorSchema}, auth=jwt_auth)
def upload_image(request, file: UploadedFile = File(...), data: GalleryCreateSchema = None):
"""Upload image to gallery (committee members only)"""
user = request.auth
if not (user.is_superuser or user.is_staff):
return 400, {"error": "Only committee members can upload images"}
# Validate file type
if not file.content_type.startswith('image/'):
return 400, {"error": "File must be an image"}
# Validate file size (10MB max)
if file.size > 10 * 1024 * 1024:
return 400, {"error": "File size must be less than 10MB"}
try:
# Create gallery item
gallery_item = Gallery.objects.create(
title=data.title if data else file.name,
description=data.description if data else "",
uploaded_by=user,
alt_text=data.alt_text if data else "",
is_public=data.is_public if data else True
)
# Save image
filename = f"gallery/{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
gallery_item.image.save(filename, ContentFile(file.read()))
# Process image asynchronously
process_uploaded_image.delay(gallery_item.id)
return 201, gallery_item
except Exception as e:
return 400, {"error": "Failed to upload image", "details": str(e)}
@gallery_router.put("/images/{image_id}", response={200: GallerySchema, 400: ErrorSchema}, auth=jwt_auth)
def update_image(request, image_id: int, data: GalleryCreateSchema):
"""Update gallery image metadata"""
user = request.auth
image = get_object_or_404(Gallery, id=image_id)
if not (image.uploaded_by == user or user.is_superuser or user.is_staff):
return 400, {"error": "You can only edit your own images"}
try:
for field, value in data.dict(exclude_unset=True).items():
setattr(image, field, value)
image.save()
return 200, image
except Exception as e:
return 400, {"error": "Failed to update image", "details": str(e)}
@gallery_router.delete("/images/{image_id}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def delete_image(request, image_id: int):
"""Soft delete a gallery image owned by the requester or committee."""
user = request.auth
image = get_object_or_404(Gallery, id=image_id)
if not (image.uploaded_by == user or user.is_superuser or user.is_staff):
return 400, {"error": "You can only delete your own images"}
image.delete()
return 200, {"message": "Image deleted successfully"}
@gallery_router.get("/deleted/images", response=List[GallerySchema], auth=jwt_auth)
def list_deleted_gallery_images(request):
"""List all soft-deleted gallery images (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
return Gallery.deleted_objects.all().select_related('uploaded_by')
@gallery_router.post("/deleted/images/{image_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def restore_gallery_image(request, image_id: int):
"""Restore a soft-deleted gallery image (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"}
try:
image = Gallery.deleted_objects.get(id=image_id)
image.restore()
return 200, {"message": f"Gallery image '{image.title}' restored successfully."}
except Gallery.DoesNotExist:
return 400, {"error": "Gallery image not found or not soft-deleted."}

6
apps/gallery/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class GalleryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.gallery"

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

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

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

View File

82
apps/gallery/models.py Normal file
View File

@@ -0,0 +1,82 @@
from django.db import models
from django.conf import settings
from PIL import Image
from core.models import BaseModel
MAX_IMAGE_FILE_SIZE_BYTES = 2 * 1024 * 1024
class Gallery(BaseModel):
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
image = models.ImageField(upload_to='gallery/')
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='gallery_images')
alt_text = models.CharField(max_length=200, blank=True)
file_size = models.PositiveIntegerField(null=True, blank=True)
width = models.PositiveIntegerField(null=True, blank=True)
height = models.PositiveIntegerField(null=True, blank=True)
is_public = models.BooleanField(default=True)
class Meta:
ordering = ['-created_at']
verbose_name_plural = "Gallery Images"
def __str__(self):
return self.title
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.image:
# Get file size
self.file_size = self.image.size
# Get image dimensions
with Image.open(self.image.path) as img:
self.width, self.height = img.size
# Compress image if it's too large
self.compress_image()
# Update fields without triggering save again
Gallery.objects.filter(pk=self.pk).update(
file_size=self.file_size,
width=self.width,
height=self.height
)
def compress_image(self):
"""Compress image if it's larger than 2MB or dimensions are too large"""
if not self.image:
return
with Image.open(self.image.path) as img:
# Convert to RGB if necessary
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Resize if too large
max_size = (1920, 1080)
if img.size[0] > max_size[0] or img.size[1] > max_size[1]:
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# Compress if file size is too large
quality = 85
if self.file_size and self.file_size > MAX_IMAGE_FILE_SIZE_BYTES:
quality = 70
img.save(self.image.path, "JPEG", quality=quality, optimize=True)
@property
def file_size_mb(self):
"""Return file size in MB"""
if self.file_size:
return round(self.file_size / (1024 * 1024), 2)
return 0
@property
def markdown_url(self):
"""Return URL for use in markdown"""
return f"![{self.alt_text or self.title}]({settings.BACKEND_ROOT}{self.image.url})"

17
apps/gallery/resources.py Normal file
View File

@@ -0,0 +1,17 @@
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget
from apps.gallery.models import Gallery
from apps.users.models import User
class GalleryResource(resources.ModelResource):
uploaded_by = fields.Field(
column_name='uploaded_by',
attribute='uploaded_by',
widget=ForeignKeyWidget(User, 'username')
)
class Meta:
model = Gallery
fields = ('id', 'title', 'description', 'image', 'uploaded_by',
'alt_text', 'file_size', 'width', 'height', 'is_public', 'created_at')

23
apps/gallery/tasks.py Normal file
View File

@@ -0,0 +1,23 @@
from celery import shared_task
from PIL import Image
import logging
logger = logging.getLogger(__name__)
@shared_task
def process_uploaded_image(gallery_id):
"""Process uploaded image: compress, resize, extract metadata"""
try:
from .models import Gallery
gallery_item = Gallery.objects.get(id=gallery_id)
if gallery_item.image:
# This will trigger the compression and metadata extraction
gallery_item.compress_image()
logger.info(f"Processed image: {gallery_item.title}")
return f"Processed image: {gallery_item.title}"
except Exception as exc:
logger.error(f"Failed to process image: {exc}")
raise exc

83
apps/payments/admin.py Normal file
View File

@@ -0,0 +1,83 @@
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from core.admin import SoftDeleteListFilter, BaseModelAdmin
from apps.payments.resources import DiscountResource, PaymentResource
from apps.payments.models import Payment, DiscountCode
@admin.register(DiscountCode)
class DiscountCodeAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = DiscountResource
list_display = (
'code', 'type', 'value', 'is_active', 'starts_at', 'ends_at',
'usage_limit_total', 'usage_limit_per_user', 'min_amount', 'is_deleted'
)
list_filter = (
'type', 'is_active', 'starts_at', 'ends_at', 'applicable_events',
SoftDeleteListFilter,
)
search_fields = ('code', )
readonly_fields = ('id', 'deleted_at', 'created_at', 'updated_at')
fieldsets = (
('Discount Code Details', {
'fields': ('code', 'type', 'value', 'applicable_events', 'is_active')
}),
('Limitations', {
'fields': ('starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user', 'min_amount')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('deleted_at', )
actions = BaseModelAdmin.actions + [
'deactivate_codes',
]
@admin.action(description="Deactivate selected discount codes")
def deactivate_codes(self, request, queryset):
queryset.update(is_active=False)
self.message_user(request, f"Deactivate {queryset.count()} discount codes.")
@admin.register(Payment)
class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = PaymentResource
list_display = (
'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount',
'status', 'created_at', 'verified_at', 'is_deleted'
)
list_filter = (
'status', 'event',
SoftDeleteListFilter,
)
search_fields = (
'user__email', 'authority', 'ref_id', 'discount_code__code'
)
readonly_fields = (
'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority',
'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at'
)
fieldsets = (
('Payment Details', {
'fields': ('user', 'event', 'status', 'created_at', 'updated_at')
}),
('Price Info', {
'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount')
}),
('Others', {
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)

View File

View File

@@ -0,0 +1,35 @@
from ninja import Schema
class CreatePaymentIn(Schema):
event_id: int
description: str
discount_code: str | None = None
mobile: str | None = None
email: str | None = None
class CreatePaymentOut(Schema):
start_pay_url: str | None = None
authority: str | None = None
base_amount: int
discount_amount: int
amount: int
class PaymentDetailOut(Schema):
ref_id: str | None = None
authority: str | None = None
base_amount: int
discount_amount: int
amount: int
status: str
verified_at: str | None = None
event: dict
class CouponVerifyIn(Schema):
event_id: int
code: str
class CouponVerifyOut(Schema):
discount_amount: int
final_price: int

240
apps/payments/api/views.py Normal file
View File

@@ -0,0 +1,240 @@
from django.conf import settings
from django.shortcuts import redirect, get_object_or_404
from django.utils import timezone
from ninja import Router
from ninja.errors import HttpError
import requests
from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event, Registration
from core.authentication import jwt_auth
from apps.payments.api.schemas import CouponVerifyIn, CouponVerifyOut, CreatePaymentIn, CreatePaymentOut, PaymentDetailOut
payments_router = Router(tags=["Payments"])
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
def create_payment(request, payload: CreatePaymentIn):
event = get_object_or_404(Event, pk=payload.event_id)
if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists():
raise HttpError(400, "You have already registered in this event")
registration = (
Registration.objects.filter(event=event, user=request.auth, is_deleted=False)
.order_by("-registered_at")
.first()
)
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
registration = Registration.objects.create(
event=event,
user=request.auth,
status=Registration.StatusChoices.PENDING,
final_price=event.price,
)
elif registration.final_price is None:
registration.final_price = event.price
registration.save(update_fields=["final_price"])
discount_code = None
discount_amount = 0
final_amount = event.price
if payload.discount_code:
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first()
if discount_code:
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
registration_updates = []
if discount_code and registration.discount_code_id != discount_code.id:
registration.discount_code = discount_code
registration_updates.append("discount_code")
if registration.discount_amount != discount_amount:
registration.discount_amount = discount_amount
registration_updates.append("discount_amount")
if registration.final_price != final_amount:
registration.final_price = final_amount
registration_updates.append("final_price")
if final_amount == 0:
if registration.status != Registration.StatusChoices.CONFIRMED:
registration.status = Registration.StatusChoices.CONFIRMED
registration_updates.append("status")
if registration_updates:
registration.save(update_fields=list(set(registration_updates)))
else:
registration.save(update_fields=["status"])
return {
"start_pay_url": None,
"authority": None,
"base_amount": event.price,
"discount_amount": discount_amount if discount_amount else 0,
"amount": 0,
}
if registration_updates:
registration.save(update_fields=list(set(registration_updates)))
pay = Payment.objects.create(
user=request.auth,
event=event,
base_amount=event.price,
discount_code=discount_code,
discount_amount=discount_amount,
amount=final_amount,
status=Payment.OrderStatusChoices.INIT,
registration=registration,
)
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
body = {
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
"amount": final_amount,
"callback_url": callback_url,
"description": payload.description,
"metadata": {
k: v for k, v in {
"mobile": payload.mobile,
"email": payload.email,
"event_id": event.id,
"user_id": request.auth.id,
"payment_id": pay.id,
"discount_code": discount_code.code if discount_code else None,
}.items() if v
}
}
try:
response = requests.post(
settings.ZARINPAL_REQUEST_URL,
json=body,
headers={"accept":"application/json","content-type":"application/json"},
timeout=15
)
jd = response.json()
except Exception as e:
pay.delete()
raise HttpError(502, f"Gateway request failed: {e}")
code = (jd.get("data") or {}).get("code")
if code != 100:
pay.delete()
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}")
authority = jd["data"]["authority"]
pay.authority = authority
pay.status = Payment.OrderStatusChoices.PENDING
pay.save(update_fields=["authority","status"])
return {
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
"authority": authority,
"base_amount": event.price,
"discount_amount": discount_amount if discount_amount else 0,
"amount": final_amount,
}
@payments_router.get("callback")
def callback(request, Authority: str | None = None, Status: str | None = None):
if not Authority:
raise HttpError(400, "Missing Authority")
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
if not pay:
raise HttpError(404, "Payment not found")
if Status != "OK":
pay.status = Payment.OrderStatusChoices.CANCELED
pay.save(update_fields=["status"])
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
verify_body = {
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
"amount": pay.amount,
"authority": Authority,
}
try:
vresp = requests.post(
settings.ZARINPAL_VERIFY_URL,
json=verify_body,
headers={"accept":"application/json","content-type":"application/json"},
timeout=15
)
vjd = vresp.json()
except Exception:
pay.status = Payment.OrderStatusChoices.FAILED
pay.save(update_fields=["status"])
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
vcode = (vjd.get("data") or {}).get("code")
if vcode in (100, 101):
data = vjd.get("data") or {}
pay.status = Payment.OrderStatusChoices.PAID
pay.ref_id = data.get("ref_id")
pay.card_pan = data.get("card_pan")
pay.card_hash = data.get("card_hash")
pay.verified_at = timezone.now()
pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"])
registration = pay.registration or Registration.objects.filter(
user=pay.user,
event=pay.event,
status=Registration.StatusChoices.PENDING,
).first()
if registration:
registration.status = Registration.StatusChoices.CONFIRMED
updates = ["status"]
if registration.final_price is None:
registration.final_price = pay.amount
updates.append("final_price")
registration.save(update_fields=updates)
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}")
pay.status = Payment.OrderStatusChoices.FAILED
pay.save(update_fields=["status"])
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
def payment_by_ref(request, ref_id: str):
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
ev = pay.event
return {
"ref_id": pay.ref_id,
"authority": pay.authority,
"base_amount": pay.base_amount,
"discount_amount": pay.discount_amount or 0,
"amount": pay.amount,
"status": pay.get_status_display(),
"verified_at": pay.verified_at.isoformat() if pay.verified_at else None,
"event": {
"id": ev.id,
"title": ev.title,
"slug": ev.slug,
"image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None,
"success_markdown": ev.registration_success_markdown,
},
}
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
def check_coupon(request, payload: CouponVerifyIn):
event = get_object_or_404(Event, id=payload.event_id)
code = payload.code
if not code:
raise HttpError(404, "لطفا کد تخفیف را وارد کنید")
try:
c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True)
final_price, disc = c.calculate_discount(event, request.auth)
return {
"discount_amount": disc,
"final_price": final_price,
}
except DiscountCode.DoesNotExist:
raise HttpError(404, "کد تخفیف معتبر نیست")

6
apps/payments/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.payments"

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

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

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

View File

122
apps/payments/models.py Normal file
View File

@@ -0,0 +1,122 @@
from django.db import models
from django.db.models import Q, Count
from django.core.exceptions import ValidationError
from django.conf import settings
from django.utils import timezone
from core.models import BaseModel
from apps.events.models import Event
from ninja.errors import HttpError
User = settings.AUTH_USER_MODEL
class DiscountCode(BaseModel):
class Type(models.TextChoices):
PERCENT = "percent", "Percent"
FIXED = "fixed", "Fixed (IRR)"
code = models.CharField(max_length=64, unique=True)
type = models.CharField(max_length=10, choices=Type.choices, default=Type.PERCENT)
value = models.PositiveIntegerField()
max_discount = models.PositiveIntegerField(null=True, blank=True)
is_active = models.BooleanField(default=True)
starts_at = models.DateTimeField(null=True, blank=True)
ends_at = models.DateTimeField(null=True, blank=True)
usage_limit_total = models.PositiveIntegerField(null=True, blank=True)
usage_limit_per_user = models.PositiveIntegerField(null=True, blank=True)
min_amount = models.PositiveIntegerField(null=True, blank=True)
applicable_events = models.ManyToManyField(Event, blank=True, related_name="discount_codes")
def __str__(self):
return f"{self.code} ({self.get_type_display()} {self.value})"
def calculate_discount(self, event: Event, user: User):
if not event.price:
return (0, 0)
if not self.is_active:
raise HttpError(400, "کد تخفیف نامعتبر یا غیرفعال است.")
n = timezone.now()
if self.starts_at and n < self.starts_at:
raise HttpError(400, "کد تخفیف هنوز فعال نشده است.")
if self.ends_at and n > self.ends_at:
raise HttpError(400, "کد تخفیف منقضی شده است.")
if self.applicable_events.exists() and not self.applicable_events.filter(pk=event.pk).exists():
raise HttpError(400, "کد تخفیف برای این رویداد قابل استفاده نیست.")
if self.min_amount and event.price < self.min_amount:
raise HttpError(400, "مبلغ سفارش کمتر از حداقل لازم برای این کد است.")
used_qs = Payment.objects.filter(discount_code=self, status__in=[Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.PENDING])
if self.usage_limit_total is not None and used_qs.count() >= self.usage_limit_total:
raise HttpError(400, "حداکثر تعداد استفاده از این کد تخفیف تکمیل شده است.")
used_by_user = used_qs.filter(user=user).count()
if self.usage_limit_per_user is not None and used_by_user >= self.usage_limit_per_user:
raise HttpError(400, "شما حداکثر تعداد مجاز استفاده از این کد تخفیف را مصرف کرده‌اید.")
if self.type == DiscountCode.Type.FIXED:
disc = min(self.value, event.price)
else:
disc = (event.price * self.value) // 100
if self.max_discount:
disc = min(disc, self.max_discount)
final_amount = max(event.price - disc, 0)
if 0 < final_amount < 10_000:
raise HttpError(400, "با این تخفیف مبلغ قابل پرداخت به کمتر از ۱۰٬۰۰۰ ریال می‌رسد.")
return (final_amount, disc)
class Payment(BaseModel):
class OrderStatusChoices(models.IntegerChoices):
INIT = 0, "Initiated"
PENDING = 1, "Pending"
PAID = 2, "Paid"
FAILED = 3, "Failed"
CANCELED = 4, "Canceled"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
base_amount = models.PositiveIntegerField(editable=False)
discount_code = models.ForeignKey(DiscountCode, on_delete=models.PROTECT, null=True, blank=True, editable=False, related_name="payments")
discount_amount = models.PositiveIntegerField(default=0, editable=False)
amount = models.PositiveIntegerField(editable=False)
registration = models.ForeignKey(
"events.Registration",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="payments",
editable=False,
)
authority = models.CharField(max_length=64, unique=True, null=True, blank=True, editable=False)
status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False)
ref_id = models.CharField(max_length=64, null=True, blank=True, editable=False)
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
def clean(self):
if self.discount_amount and self.amount + self.discount_amount != self.base_amount:
raise ValidationError({"amount": "amount + discount_amount must equal base_amount"})
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
@property
def status_label(self):
"""Human-readable label for the payment status."""
return self.get_status_display()
def __str__(self):
return f"{self.user.email}:{self.event} - {self.get_status_display()}"

View File

@@ -0,0 +1,44 @@
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event
from apps.users.models import User
class DiscountResource(resources.ModelResource):
event = fields.Field(
column_name='applicable_events',
attribute='applicable_events',
widget=ManyToManyWidget(Event, field='title', separator='||')
)
class Meta:
model = Event
fields = (
'id', 'code', 'type', 'value', 'max_discount', 'is_active',
'starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user',
'min_amount', 'applicable_events', 'created_at', 'updated_at',
'is_deleted', 'deleted_at'
)
export_order = fields
class PaymentResource(resources.ModelResource):
event = fields.Field(
column_name='event',
attribute='event',
widget=ForeignKeyWidget(Event, 'title')
)
user = fields.Field(
column_name='user',
attribute='user',
widget=ForeignKeyWidget(User, 'username')
)
class Meta:
model = Payment
fields = (
'id', 'event', 'user', 'base_amount', 'discount_code', 'discount_amount', 'amount',
'authority', 'status', 'red_id', 'card_pan', 'card_hash', 'verified_at', 'created_at',
'updated_at', 'is_deleted', 'deleted_at'
)
export_order = fields

View File

View File

@@ -0,0 +1,282 @@
import json
from datetime import timedelta
from unittest import mock
from django.test import TestCase, override_settings
from django.utils import timezone
from core.authentication import create_jwt_token
from apps.events.models import Event, Registration
from apps.payments.models import Payment, DiscountCode
from apps.users.models import User
@override_settings(
ZARINPAL_MERCHANT_ID="MID",
ZARINPAL_REQUEST_URL="https://zarinpal/request",
ZARINPAL_STARTPAY="https://zarinpal/start/",
ZARINPAL_VERIFY_URL="https://zarinpal/verify",
ZARINPAL_CALLBACK_URL="https://frontend/callback",
)
class PaymentsAPIIntegrationTests(TestCase):
password = "PaymentPass!123"
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="pay_user",
email="pay.user@example.com",
password=cls.password,
)
cls.user.is_email_verified = True
cls.user.save(update_fields=["is_email_verified"])
def setUp(self):
super().setUp()
self.event = Event.objects.create(
title="Pay Event",
description="Payment event",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=2),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
slug="pay-event",
price=50000,
capacity=10,
status=Event.StatusChoices.PUBLISHED,
)
self.token = create_jwt_token(self.user)
def _headers(self):
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
def _create_paid_event(self):
return Event.objects.create(
title="Paid Event",
description="Paid",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=2),
slug=f"paid-{timezone.now().timestamp()}",
price=20000,
capacity=5,
status=Event.StatusChoices.PUBLISHED,
)
def _create_discount_code(self, event):
code = DiscountCode.objects.create(
code="DISC50",
value=50,
is_active=True,
)
code.applicable_events.add(event)
return code
def test_create_payment_for_free_event(self):
free = Event.objects.create(
title="Free",
description="Zero",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
slug="free-event",
price=0,
capacity=10,
status=Event.StatusChoices.PUBLISHED,
)
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": free.id,
"description": "Free registration",
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["amount"], 0)
self.assertIsNone(data["start_pay_url"])
@mock.patch("apps.payments.api.views.requests.post")
def test_create_payment_with_discount(self, mock_post):
mock_response = mock.Mock()
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
mock_post.return_value = mock_response
code = self._create_discount_code(self.event)
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": self.event.id,
"description": "Pay with discount",
"discount_code": code.code,
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["discount_amount"], self.event.price // 2)
self.assertEqual(payload["amount"], self.event.price // 2)
self.assertIn("start_pay_url", payload)
payment = Payment.objects.get(user=self.user, event=self.event)
self.assertEqual(payment.discount_code, code)
@mock.patch("apps.payments.api.views.requests.post")
def test_callback_success_marks_paid(self, mock_post):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH123",
)
mock_resp = mock.Mock()
mock_resp.json.return_value = {"data": {"code": 100, "ref_id": "REF", "card_pan": "123", "card_hash": "ABC"}}
mock_post.return_value = mock_resp
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH123", "Status": "OK"},
)
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
self.assertTrue("status=success" in response.url)
@mock.patch("apps.payments.api.views.requests.post")
def test_callback_failure_redirects_failed(self, mock_post):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH456",
)
mock_resp = mock.Mock()
mock_resp.json.return_value = {"data": {"code": 101, "ref_id": "REF"}}
mock_post.return_value = mock_resp
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH456", "Status": "OK"},
)
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
self.assertTrue("status=success" in response.url)
def test_callback_missing_authority_returns_error(self):
response = self.client.get("/api/payments/callback", {"Status": "OK"})
self.assertEqual(response.status_code, 400)
def test_callback_not_ok_cancels(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH789",
)
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH789", "Status": "NOK"},
)
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED)
self.assertIn("status=failed", response.url)
@mock.patch("apps.payments.api.views.requests.post", side_effect=RuntimeError("down"))
def test_create_payment_gateway_failure(self, mock_post):
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": self.event.id,
"description": "Gateway fail",
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 502)
self.assertFalse(Payment.objects.filter(user=self.user).exists())
def test_create_payment_when_already_paid(self):
Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
)
response = self.client.post(
"/api/payments/create",
data=json.dumps({"event_id": self.event.id, "description": "Duplicate"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 400)
@mock.patch("apps.payments.api.views.requests.post")
def test_registration_final_price_none_updates(self, mock_post):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.PENDING,
final_price=None,
)
mock_response = mock.Mock()
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
mock_post.return_value = mock_response
response = self.client.post(
"/api/payments/create",
data=json.dumps({"event_id": self.event.id, "description": "Update"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
registration.refresh_from_db()
if registration.final_price is None:
self.fail("final_price should be populated")
def test_coupon_check_success_and_errors(self):
code = DiscountCode.objects.create(code="PAYCO", value=20, is_active=True, type=DiscountCode.Type.PERCENT)
code.applicable_events.add(self.event)
# missing code
missing = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(missing.status_code, 422)
# invalid code
invalid = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id, "code": "INVALID"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(invalid.status_code, 404)
success = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id, "code": code.code}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(success.status_code, 200)
self.assertIn("final_price", success.json())

View File

View File

@@ -0,0 +1,194 @@
import uuid
from datetime import timedelta
from types import SimpleNamespace
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.utils import timezone
from django.contrib.admin import AdminSite
from apps.payments.admin import DiscountCodeAdmin
from apps.payments.models import DiscountCode, Payment
from apps.payments.resources import DiscountResource, PaymentResource
from apps.events.models import Event
from apps.users.models import User
from ninja.errors import HttpError
class PaymentTestMixin:
@staticmethod
def _create_user(**overrides):
data = {
"username": f"user_{uuid.uuid4().hex[:6]}",
"email": f"user_{uuid.uuid4().hex[:6]}@example.com",
"password": "Test!1234",
}
data.update(overrides)
return User.objects.create_user(**data)
@staticmethod
def _create_event(**overrides):
now = timezone.now()
defaults = {
"title": "Sample",
"description": "Desc",
"start_time": now,
"end_time": now + timedelta(hours=2),
"registration_start_date": now - timedelta(days=1),
"registration_end_date": now + timedelta(days=5),
"slug": f"event-{uuid.uuid4().hex[:6]}",
"price": 100000,
"capacity": 10,
"status": Event.StatusChoices.PUBLISHED,
}
defaults.update(overrides)
return Event.objects.create(**defaults)
@staticmethod
def _discount_code(**overrides):
defaults = {
"code": f"CODE{uuid.uuid4().hex[:4]}",
"value": 50,
"is_active": True,
"type": DiscountCode.Type.PERCENT,
}
defaults.update(overrides)
return DiscountCode.objects.create(**defaults)
class DiscountCodeModelTests(TestCase, PaymentTestMixin):
def setUp(self):
self.event = self._create_event()
self.user = self._create_user(is_email_verified=True)
def test_zero_price_returns_zero_discount(self):
event = self._create_event(price=0)
code = self._discount_code()
code.applicable_events.add(event)
self.assertEqual(code.calculate_discount(event, self.user), (0, 0))
def test_inactive_raises_error(self):
code = self._discount_code(is_active=False)
code.applicable_events.add(self.event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_start_date_validation(self):
code = self._discount_code(starts_at=timezone.now() + timedelta(days=1))
code.applicable_events.add(self.event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_end_date_validation(self):
code = self._discount_code(ends_at=timezone.now() - timedelta(days=1))
code.applicable_events.add(self.event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_applicable_events_enforcement(self):
code = self._discount_code()
other_event = self._create_event()
code.applicable_events.add(other_event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_min_amount_guard(self):
code = self._discount_code(min_amount=200000)
code.applicable_events.add(self.event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_usage_limit_total(self):
code = self._discount_code(usage_limit_total=1)
code.applicable_events.add(self.event)
Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
discount_amount=0,
status=Payment.OrderStatusChoices.PAID,
discount_code=code,
)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_usage_limit_per_user(self):
code = self._discount_code(usage_limit_per_user=1)
code.applicable_events.add(self.event)
Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
discount_amount=0,
status=Payment.OrderStatusChoices.PENDING,
discount_code=code,
)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_final_price_below_min_post_discount(self):
event = self._create_event(price=15000)
code = self._discount_code(value=80)
code.applicable_events.add(event)
with self.assertRaises(HttpError):
code.calculate_discount(event, self.user)
def test_fixed_discount_type(self):
code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000)
code.applicable_events.add(self.event)
final, disc = code.calculate_discount(self.event, self.user)
self.assertEqual(disc, 5000)
self.assertEqual(final, self.event.price - 5000)
class PaymentModelAndResourceTests(TestCase, PaymentTestMixin):
def setUp(self):
self.event = self._create_event()
self.user = self._create_user(is_email_verified=True)
def test_payment_clean_validates_amount(self):
payment = Payment(
user=self.user,
event=self.event,
base_amount=1000,
amount=500,
discount_amount=400,
status=Payment.OrderStatusChoices.INIT,
)
with self.assertRaises(ValidationError):
payment.full_clean()
def test_payment_resource_defers_user_event(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=1000,
amount=1000,
discount_amount=0,
status=Payment.OrderStatusChoices.INIT,
)
resource = PaymentResource()
user_cell = resource.fields["user"].widget.clean(self.user.username, None)
self.assertEqual(user_cell, self.user)
event_cell = resource.fields["event"].widget.clean(self.event.title, None)
self.assertEqual(event_cell, self.event)
def test_discount_resource_expands_events(self):
resource = DiscountResource()
widget = resource.fields["event"].widget
self.assertEqual(widget.separator, "||")
class DiscountCodeAdminTests(TestCase, PaymentTestMixin):
def setUp(self):
self.admin = DiscountCodeAdmin(DiscountCode, AdminSite())
def test_deactivate_codes_action(self):
code = self._discount_code()
queryset = DiscountCode.objects.filter(pk=code.pk)
request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None))
self.admin.deactivate_codes(request, queryset)
code.refresh_from_db()
self.assertFalse(code.is_active)

122
apps/users/admin.py Normal file
View File

@@ -0,0 +1,122 @@
from django import forms
from django.utils import timezone
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from import_export.admin import ImportExportModelAdmin
from simplemde.widgets import SimpleMDEEditor
from apps.users.models import User, University, Major
from apps.users.resources import UserResource
from apps.users.tasks import send_verification_email
from core.admin import SoftDeleteListFilter, BaseModelAdmin
class UserAdminForm(forms.ModelForm):
bio = forms.CharField(widget=SimpleMDEEditor(), required=False)
student_id = forms.CharField(required=False)
class Meta:
model = User
fields = '__all__'
@admin.register(User)
class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin):
form = UserAdminForm
resource_class = UserResource
list_display = ('email', 'username', 'university', 'is_email_verified', 'date_joined')
list_filter = ('is_email_verified', 'is_staff', 'year_of_study', SoftDeleteListFilter)
search_fields = ('email', 'username', 'student_id', 'first_name', 'last_name')
ordering = ('-date_joined',)
fieldsets = (
('Auth Credentials', {'fields': ('username', 'email', 'password')}),
('Personal info', {
'fields': ('first_name', 'last_name', 'student_id', 'university', 'year_of_study', 'major', 'bio', 'profile_picture')
}),
('Permissions', {
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions',),
}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
('Email Verification', {
'fields': ('is_email_verified', 'email_verification_token', 'email_verification_sent_at')
}),
('Password Reset', {
'fields': ('password_reset_token', 'password_reset_token_expires_at'),
'classes': ('collapse',)
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)
add_fieldsets = (
(
'Step 1',
{
'classes': ('wide',),
'fields': ('email', 'student_id', 'password1', 'password2', 'usable_password'),
},
),
)
readonly_fields = ('email_verification_token', 'email_verification_sent_at', 'deleted_at',
'password_reset_token', 'password_reset_token_expires_at')
actions = BaseModelAdmin.actions + [
'verify_emails',
'resend_verification_email',
]
@admin.action(description='Verify selected user emails')
def verify_emails(self, request, queryset):
queryset.update(is_email_verified=True)
self.message_user(request, f'Verified {queryset.count()} user emails.')
@admin.action(description="Resend verification email")
def resend_verification_email(self, request, queryset):
qs = queryset.filter(is_email_verified=False).exclude(email__isnull=True).exclude(email="")
total = queryset.count()
to_send = qs.count()
skipped = total - to_send
sent = failed = 0
for user in qs:
try:
user.regenerate_verification_token()
user.email_verification_sent_at = timezone.now()
user.save(update_fields=["email_verification_sent_at"])
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}"
send_verification_email.delay(user.id, verification_url)
sent += 1
except Exception as exc:
failed += 1
if sent:
self.message_user(request, f"ایمیل تأیید برای {sent} کاربر ارسال شد.", level=messages.SUCCESS)
if skipped:
self.message_user(
request,
f"{skipped} کاربر کنار گذاشته شدند (یا قبلاً تأیید شده‌اند یا ایمیل ندارند).",
level=messages.WARNING,
)
if failed:
self.message_user(request, f"ارسال برای {failed} کاربر با خطا مواجه شد.", level=messages.ERROR)
@admin.register(University)
class UniversityAdmin(BaseModelAdmin):
list_display = ('name', 'code', 'is_active', 'created_at')
list_filter = ('is_active', SoftDeleteListFilter)
search_fields = ('name', 'code')
@admin.register(Major)
class MajorAdmin(BaseModelAdmin):
list_display = ('name', 'code', 'is_active', 'created_at')
list_filter = ('is_active', SoftDeleteListFilter)
search_fields = ('name', 'code')

View File

15
apps/users/api/meta.py Normal file
View File

@@ -0,0 +1,15 @@
from ninja import Router
from apps.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]

129
apps/users/api/schemas.py Normal file
View File

@@ -0,0 +1,129 @@
"""Authentication-related API schemas."""
from ninja import Schema, ModelSchema
from typing import Optional
from apps.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

403
apps/users/api/views.py Normal file
View File

@@ -0,0 +1,403 @@
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 apps.users.models import User, Major, University
from apps.users.tasks import send_verification_email, send_password_reset_email
from apps.users.api.schemas import (
PasswordResetConfirmSchema,
PasswordResetRequestSchema,
TokenRefreshIn,
TokenSchema,
UserListSchema,
UserLoginSchema,
UserProfileSchema,
UserRegistrationSchema,
UserUpdateSchema,
UsernameCheckSchema,
)
from core.api.schemas import ErrorSchema, MessageSchema
from core.authentication import create_jwt_token, create_refresh_token, jwt_auth
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}

9
apps/users/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.users"
def ready(self):
import apps.users.signals

View File

@@ -0,0 +1,48 @@
[
{"model":"users.user","fields":{"username":"u1403020111029","email":"pending-1403020111029@noemail.local","first_name":"پوریا","last_name":"شامخی","student_id":"1403020111029","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1400020111002","email":"pending-1400020111002@noemail.local","first_name":"سمانه","last_name":"جباری","student_id":"1400020111002","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u990201110035","email":"pending-990201110035@noemail.local","first_name":"سید علی","last_name":"حجتی مقدم","student_id":"990201110035","year_of_study":1399,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u990201200032","email":"pending-990201200032@noemail.local","first_name":"مهدی","last_name":"خدیوی سرشت","student_id":"990201200032","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020111026","email":"pending-1403020111026@noemail.local","first_name":"امیر سجاد","last_name":"حیدری","student_id":"1403020111026","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020111037","email":"pending-1403020111037@noemail.local","first_name":"امیرکیان","last_name":"رادپور","student_id":"1403020111037","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120011","email":"pending-1401020120011@noemail.local","first_name":"شیما","last_name":"گندم‌کار","student_id":"1401020120011","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120024","email":"pending-1401020120024@noemail.local","first_name":"رضا","last_name":"سالمی‌درگاهی","student_id":"1401020120024","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120102","email":"pending-1401020120102@noemail.local","first_name":"امیرمحمد","last_name":"نیک‌کار","student_id":"1401020120102","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120028","email":"pending-1401020120028@noemail.local","first_name":"امیرمحمد","last_name":"کیان‌فر","student_id":"1401020120028","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120035","email":"pending-1401020120035@noemail.local","first_name":"رژان","last_name":"پناهی‌پور","student_id":"1401020120035","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1400020111032","email":"pending-1400020111032@noemail.local","first_name":"مریم","last_name":"صفری","student_id":"1400020111032","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1400020111014","email":"pending-1400020111014@noemail.local","first_name":"علیرضا","last_name":"رحیمی","student_id":"1400020111014","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u992818200","email":"pending-992818200@noemail.local","first_name":"مریم","last_name":"مسلمی دوران محله","student_id":"992818200","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1400020111022","email":"pending-1400020111022@noemail.local","first_name":"امیرمحمد","last_name":"خیراندیش","student_id":"1400020111022","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1400020111029","email":"pending-1400020111029@noemail.local","first_name":"امیرحسین","last_name":"حسن‌پور","student_id":"1400020111029","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u990201201007","email":"pending-990201201007@noemail.local","first_name":"امیررضا","last_name":"اخلاقی","student_id":"990201201007","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020111006","email":"pending-1403020111006@noemail.local","first_name":"سینا","last_name":"زمان‌پور","student_id":"1403020111006","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020130021","email":"pending-1403020130021@noemail.local","first_name":"سبحان","last_name":"آسوده جلالی","student_id":"1403020130021","year_of_study":1403,"major":null,"university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403012268121","email":"pending-1403012268121@noemail.local","first_name":"فربد","last_name":"خلیلی خوشه مهر","student_id":"1403012268121","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u03111129302057","email":"pending-03111129302057@noemail.local","first_name":"محمد مهدی","last_name":"جباری","student_id":"03111129302057","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020121009","email":"pending-1403020121009@noemail.local","first_name":"امیرحسین","last_name":"امین‌پور","student_id":"1403020121009","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020121013","email":"pending-1403020121013@noemail.local","first_name":"عرشیا","last_name":"عرشی","student_id":"1403020121013","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020121023","email":"pending-1403020121023@noemail.local","first_name":"طاها","last_name":"محیط مافی","student_id":"1403020121023","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"uidx28","email":"pending-idx28@noemail.local","first_name":"مهدی","last_name":"منصورپور","student_id":null,"year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020121007","email":"pending-1403020121007@noemail.local","first_name":"سید محمدرضا","last_name":"حسین‌نیان","student_id":"1403020121007","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020121001","email":"pending-1403020121001@noemail.local","first_name":"محمود","last_name":"یاسری","student_id":"1403020121001","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120039","email":"pending-1401020120039@noemail.local","first_name":"ارشاد","last_name":"ایزدی","student_id":"1401020120039","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120002","email":"pending-1401020120002@noemail.local","first_name":"دلناز","last_name":"محمودی","student_id":"1401020120002","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020121018","email":"pending-1403020121018@noemail.local","first_name":"اروین","last_name":"نعمتی","student_id":"1403020121018","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120149","email":"pending-1401020120149@noemail.local","first_name":"مائده","last_name":"حسرت قرانی","student_id":"1401020120149","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120036","email":"pending-1401020120036@noemail.local","first_name":"شهریار","last_name":"اقاجانی","student_id":"1401020120036","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020121027","email":"pending-1403020121027@noemail.local","first_name":"عمید","last_name":"عباسی","student_id":"1403020121027","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u990201200016","email":"pending-990201200016@noemail.local","first_name":"مهدی","last_name":"دیداری","student_id":"990201200016","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120041","email":"pending-1401020120041@noemail.local","first_name":"حمید","last_name":"عباسی","student_id":"1401020120041","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020130022","email":"pending-1403020130022@noemail.local","first_name":"امیرمحمد","last_name":"نجفی","student_id":"1403020130022","year_of_study":1403,"major":null,"university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020111049","email":"pending-1401020111049@noemail.local","first_name":"علی","last_name":"رهگذر","student_id":"1401020111049","year_of_study":1401,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120103","email":"pending-1401020120103@noemail.local","first_name":"یاسان","last_name":"حاج‌قلی‌زاده","student_id":"1401020120103","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"uidx45","email":"pending-idx45@noemail.local","first_name":"امیر","last_name":"دوستی ماسوله","student_id":null,"year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120031","email":"pending-1401020120031@noemail.local","first_name":"امیررضا","last_name":"علیپور","student_id":"1401020120031","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u990201200036","email":"pending-990201200036@noemail.local","first_name":"مونا","last_name":"یحیی‌زاده واقفی","student_id":"990201200036","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120005","email":"pending-1401020120005@noemail.local","first_name":"بهار","last_name":"محمدی","student_id":"1401020120005","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1401020120026","email":"pending-1401020120026@noemail.local","first_name":"مطهره","last_name":"حق‌شناس","student_id":"1401020120026","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u1403020121020","email":"pending-1403020121020@noemail.local","first_name":"محمد","last_name":"خلیلی‌مقدم ملامحله","student_id":"1403020121020","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"u990201200027","email":"pending-990201200027@noemail.local","first_name":"مهراب","last_name":"گودرزی","student_id":"990201200027","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}},
{"model":"users.user","fields":{"username":"uidx52","email":"pending-idx52@noemail.local","first_name":"امیرمحمد","last_name":"چرختاب مقدم","student_id":null,"year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}
]

Some files were not shown because too many files have changed in this diff Show More