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

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