init
This commit is contained in:
89
backend/gallery/admin.py
Normal file
89
backend/gallery/admin.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from gallery.models import Gallery
|
||||
from gallery.resources import GalleryResource
|
||||
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
|
||||
@admin.register(Gallery)
|
||||
class GalleryAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
resource_class = GalleryResource
|
||||
list_display = ('title', 'image_preview', 'uploaded_by', 'file_size_display', 'dimensions', 'is_public', 'created_at')
|
||||
list_filter = ('is_public', 'created_at', SoftDeleteListFilter)
|
||||
search_fields = ('title', 'description', 'alt_text')
|
||||
readonly_fields = ('uploaded_by', 'file_size', 'width', 'height', 'image_preview_large', 'markdown_url')
|
||||
|
||||
fieldsets = (
|
||||
('Image Info', {
|
||||
'fields': ('title', 'description', 'image', 'alt_text', 'is_public')
|
||||
}),
|
||||
('Uploader', {
|
||||
'fields': ('uploaded_by',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('file_size', 'width', 'height'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Preview & Usage', {
|
||||
'fields': ('image_preview_large', 'markdown_url'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Soft Delete', {
|
||||
'fields': ('is_deleted', 'deleted_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
actions = BaseModelAdmin.actions + ['make_public', 'make_private', 'restore_images']
|
||||
|
||||
def image_preview(self, obj):
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
|
||||
obj.image.url
|
||||
)
|
||||
return "No Image"
|
||||
image_preview.short_description = "Preview"
|
||||
|
||||
def image_preview_large(self, obj):
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-width: 300px; max-height: 300px; object-fit: contain;" />',
|
||||
obj.image.url
|
||||
)
|
||||
return "No Image"
|
||||
image_preview_large.short_description = "Image Preview"
|
||||
|
||||
def file_size_display(self, obj):
|
||||
return f"{obj.file_size_mb} MB" if obj.file_size else "Unknown"
|
||||
file_size_display.short_description = "File Size"
|
||||
|
||||
def dimensions(self, obj):
|
||||
if obj.width and obj.height:
|
||||
return f"{obj.width} × {obj.height}"
|
||||
return "Unknown"
|
||||
dimensions.short_description = "Dimensions"
|
||||
|
||||
def make_public(self, request, queryset):
|
||||
queryset.update(is_public=True)
|
||||
self.message_user(request, f"Made {queryset.count()} images public.")
|
||||
make_public.short_description = "Make selected images public"
|
||||
|
||||
def make_private(self, request, queryset):
|
||||
queryset.update(is_public=False)
|
||||
self.message_user(request, f"Made {queryset.count()} images private.")
|
||||
make_private.short_description = "Make selected images private"
|
||||
|
||||
def restore_images(self, request, queryset):
|
||||
for image in queryset:
|
||||
image.restore()
|
||||
self.message_user(request, f"Restored {queryset.count()} images.")
|
||||
restore_images.short_description = "Restore selected images"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not obj.uploaded_by_id:
|
||||
obj.uploaded_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
5
backend/gallery/apps.py
Normal file
5
backend/gallery/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class GalleryConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'gallery'
|
||||
218
backend/gallery/fixtures/gallery.json
Normal file
218
backend/gallery/fixtures/gallery.json
Normal file
@@ -0,0 +1,218 @@
|
||||
[
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "کارگاه یادگیری ماشین - تصویر ۱",
|
||||
"description": "شرکتکنندگان در حال یادگیری مفاهیم یادگیری ماشین",
|
||||
"image": "gallery/ml_workshop_1.jpg",
|
||||
"uploaded_by": 1,
|
||||
"alt_text": "دانشجویان در کارگاه یادگیری ماشین",
|
||||
"file_size": 2048000,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"created_at": "2024-01-20T14:15:00Z",
|
||||
"updated_at": "2024-01-20T14:15:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "مسابقه برنامهنویسی - لحظه اعلام نتایج",
|
||||
"description": "اعلام نتایج مسابقه برنامهنویسی و اهدای جوایز",
|
||||
"image": "gallery/programming_contest_results.jpg",
|
||||
"uploaded_by": 2,
|
||||
"alt_text": "اهدای جوایز مسابقه برنامهنویسی",
|
||||
"file_size": 1536000,
|
||||
"width": 1600,
|
||||
"height": 900,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"created_at": "2024-01-25T09:45:00Z",
|
||||
"updated_at": "2024-01-25T09:45:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "سمینار امنیت سایبری",
|
||||
"description": "دکتر رضایی در حال ارائه مطالب امنیت سایبری",
|
||||
"image": "gallery/cybersecurity_seminar.jpg",
|
||||
"uploaded_by": 5,
|
||||
"alt_text": "سخنرانی در سمینار امنیت سایبری",
|
||||
"file_size": 1792000,
|
||||
"width": 1800,
|
||||
"height": 1200,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"created_at": "2024-02-01T16:20:00Z",
|
||||
"updated_at": "2024-02-01T16:20:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "کارگاه React.js - کدنویسی عملی",
|
||||
"description": "شرکتکنندگان در حال کدنویسی با React.js",
|
||||
"image": "gallery/react_workshop_coding.jpg",
|
||||
"uploaded_by": 9,
|
||||
"alt_text": "کدنویسی در کارگاه React.js",
|
||||
"file_size": 2304000,
|
||||
"width": 2048,
|
||||
"height": 1152,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"created_at": "2024-02-05T11:30:00Z",
|
||||
"updated_at": "2024-02-05T11:30:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "بازدید از دیجیکالا - ورودی شرکت",
|
||||
"description": "دانشجویان در ورودی شرکت دیجیکالا",
|
||||
"image": "gallery/digikala_visit_entrance.jpg",
|
||||
"uploaded_by": 3,
|
||||
"alt_text": "بازدید از شرکت دیجیکالا",
|
||||
"file_size": 1920000,
|
||||
"width": 1920,
|
||||
"height": 1280,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"created_at": "2024-02-10T22:45:00Z",
|
||||
"updated_at": "2024-02-10T22:45:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "هکاتون هوش مصنوعی - شب اول",
|
||||
"description": "تیمها در حال کار شبانه روزی در هکاتون",
|
||||
"image": "gallery/ai_hackathon_night.jpg",
|
||||
"uploaded_by": 6,
|
||||
"alt_text": "کار شبانه در هکاتون هوش مصنوعی",
|
||||
"file_size": 1664000,
|
||||
"width": 1600,
|
||||
"height": 1067,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"created_at": "2024-02-15T13:10:00Z",
|
||||
"updated_at": "2024-02-15T13:10:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "سمینار کارآفرینی - پنل بحث",
|
||||
"description": "پنل بحث با کارآفرینان موفق فناوری",
|
||||
"image": "gallery/entrepreneurship_panel.jpg",
|
||||
"uploaded_by": 1,
|
||||
"alt_text": "پنل بحث کارآفرینی فناوری",
|
||||
"file_size": 2176000,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"created_at": "2024-02-20T15:25:00Z",
|
||||
"updated_at": "2024-02-20T15:25:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "کارگاه DevOps - آموزش Docker",
|
||||
"description": "آموزش عملی Docker و کانتینرها",
|
||||
"image": "gallery/devops_docker_training.jpg",
|
||||
"uploaded_by": 8,
|
||||
"alt_text": "آموزش Docker در کارگاه DevOps",
|
||||
"file_size": 1856000,
|
||||
"width": 1728,
|
||||
"height": 1152,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"created_at": "2024-02-25T12:40:00Z",
|
||||
"updated_at": "2024-02-25T12:40:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "مسابقه طراحی UI/UX - آثار شرکتکنندگان",
|
||||
"description": "نمایش آثار طراحی شده توسط شرکتکنندگان",
|
||||
"image": "gallery/uiux_contest_designs.jpg",
|
||||
"uploaded_by": 12,
|
||||
"alt_text": "آثار مسابقه طراحی UI/UX",
|
||||
"file_size": 2048000,
|
||||
"width": 2048,
|
||||
"height": 1365,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"created_at": "2024-03-01T17:55:00Z",
|
||||
"updated_at": "2024-03-01T17:55:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "نشست فارغالتحصیلان - عکس گروهی",
|
||||
"description": "عکس یادگاری با فارغالتحصیلان و دانشجویان فعلی",
|
||||
"image": "gallery/alumni_group_photo.jpg",
|
||||
"uploaded_by": 5,
|
||||
"alt_text": "عکس گروهی نشست فارغالتحصیلان",
|
||||
"file_size": 2560000,
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 11,
|
||||
"fields": {
|
||||
"created_at": "2024-03-05T08:20:00Z",
|
||||
"updated_at": "2024-03-05T08:20:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "آزمایشگاه کامپیوتر - محیط کار",
|
||||
"description": "نمایی از آزمایشگاه کامپیوتر دانشکده",
|
||||
"image": "gallery/computer_lab.jpg",
|
||||
"uploaded_by": 9,
|
||||
"alt_text": "آزمایشگاه کامپیوتر دانشکده",
|
||||
"file_size": 1792000,
|
||||
"width": 1792,
|
||||
"height": 1024,
|
||||
"is_public": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gallery.gallery",
|
||||
"pk": 12,
|
||||
"fields": {
|
||||
"created_at": "2024-03-10T14:35:00Z",
|
||||
"updated_at": "2024-03-10T14:35:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "کتابخانه دانشکده - بخش کتب فنی",
|
||||
"description": "بخش کتب فنی و مهندسی کامپیوتر کتابخانه",
|
||||
"image": "gallery/library_tech_books.jpg",
|
||||
"uploaded_by": 4,
|
||||
"alt_text": "کتب فنی کتابخانه دانشکده",
|
||||
"file_size": 1536000,
|
||||
"width": 1536,
|
||||
"height": 1024,
|
||||
"is_public": true
|
||||
}
|
||||
}
|
||||
]
|
||||
36
backend/gallery/migrations/0001_initial.py
Normal file
36
backend/gallery/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Gallery',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('image', models.ImageField(upload_to='gallery/')),
|
||||
('alt_text', models.CharField(blank=True, max_length=200)),
|
||||
('file_size', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('width', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('height', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('is_public', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Gallery Images',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
23
backend/gallery/migrations/0002_initial.py
Normal file
23
backend/gallery/migrations/0002_initial.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('gallery', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='gallery',
|
||||
name='uploaded_by',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gallery_images', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
0
backend/gallery/migrations/__init__.py
Normal file
0
backend/gallery/migrations/__init__.py
Normal file
82
backend/gallery/models.py
Normal file
82
backend/gallery/models.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from utils.models import BaseModel
|
||||
|
||||
|
||||
MAX_IMAGE_FILE_SIZE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
class Gallery(BaseModel):
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
image = models.ImageField(upload_to='gallery/')
|
||||
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='gallery_images')
|
||||
alt_text = models.CharField(max_length=200, blank=True)
|
||||
file_size = models.PositiveIntegerField(null=True, blank=True)
|
||||
width = models.PositiveIntegerField(null=True, blank=True)
|
||||
height = models.PositiveIntegerField(null=True, blank=True)
|
||||
is_public = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name_plural = "Gallery Images"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.image:
|
||||
# Get file size
|
||||
self.file_size = self.image.size
|
||||
|
||||
# Get image dimensions
|
||||
with Image.open(self.image.path) as img:
|
||||
self.width, self.height = img.size
|
||||
|
||||
# Compress image if it's too large
|
||||
self.compress_image()
|
||||
|
||||
# Update fields without triggering save again
|
||||
Gallery.objects.filter(pk=self.pk).update(
|
||||
file_size=self.file_size,
|
||||
width=self.width,
|
||||
height=self.height
|
||||
)
|
||||
|
||||
def compress_image(self):
|
||||
"""Compress image if it's larger than 2MB or dimensions are too large"""
|
||||
if not self.image:
|
||||
return
|
||||
|
||||
with Image.open(self.image.path) as img:
|
||||
# Convert to RGB if necessary
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Resize if too large
|
||||
max_size = (1920, 1080)
|
||||
if img.size[0] > max_size[0] or img.size[1] > max_size[1]:
|
||||
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Compress if file size is too large
|
||||
quality = 85
|
||||
if self.file_size and self.file_size > MAX_IMAGE_FILE_SIZE_BYTES:
|
||||
quality = 70
|
||||
|
||||
img.save(self.image.path, "JPEG", quality=quality, optimize=True)
|
||||
|
||||
@property
|
||||
def file_size_mb(self):
|
||||
"""Return file size in MB"""
|
||||
if self.file_size:
|
||||
return round(self.file_size / (1024 * 1024), 2)
|
||||
return 0
|
||||
|
||||
@property
|
||||
def markdown_url(self):
|
||||
"""Return URL for use in markdown"""
|
||||
return f""
|
||||
17
backend/gallery/resources.py
Normal file
17
backend/gallery/resources.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from import_export import resources, fields
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from gallery.models import Gallery
|
||||
from users.models import User
|
||||
|
||||
class GalleryResource(resources.ModelResource):
|
||||
uploaded_by = fields.Field(
|
||||
column_name='uploaded_by',
|
||||
attribute='uploaded_by',
|
||||
widget=ForeignKeyWidget(User, 'username')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Gallery
|
||||
fields = ('id', 'title', 'description', 'image', 'uploaded_by',
|
||||
'alt_text', 'file_size', 'width', 'height', 'is_public', 'created_at')
|
||||
23
backend/gallery/tasks.py
Normal file
23
backend/gallery/tasks.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from celery import shared_task
|
||||
from PIL import Image
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task
|
||||
def process_uploaded_image(gallery_id):
|
||||
"""Process uploaded image: compress, resize, extract metadata"""
|
||||
try:
|
||||
from .models import Gallery
|
||||
gallery_item = Gallery.objects.get(id=gallery_id)
|
||||
|
||||
if gallery_item.image:
|
||||
# This will trigger the compression and metadata extraction
|
||||
gallery_item.compress_image()
|
||||
|
||||
logger.info(f"Processed image: {gallery_item.title}")
|
||||
return f"Processed image: {gallery_item.title}"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to process image: {exc}")
|
||||
raise exc
|
||||
Reference in New Issue
Block a user