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

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)