initial commit
This commit is contained in:
1
apps/certificates/__init__.py
Normal file
1
apps/certificates/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
""""""
|
||||
24
apps/certificates/admin.py
Normal file
24
apps/certificates/admin.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import CertificateTemplate, Skill, UserCertificate
|
||||
|
||||
|
||||
@admin.register(Skill)
|
||||
class SkillAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_at')
|
||||
search_fields = ('name',)
|
||||
|
||||
|
||||
@admin.register(CertificateTemplate)
|
||||
class CertificateTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ('event', 'created_at')
|
||||
search_fields = ('event__title',)
|
||||
filter_horizontal = ('skills',)
|
||||
|
||||
|
||||
@admin.register(UserCertificate)
|
||||
class UserCertificateAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'event', 'title', 'score', 'issued_at')
|
||||
list_filter = ('score', 'issued_at')
|
||||
search_fields = ('user__username', 'title', 'event__title')
|
||||
filter_horizontal = ('skills',)
|
||||
0
apps/certificates/api/__init__.py
Normal file
0
apps/certificates/api/__init__.py
Normal file
70
apps/certificates/api/schemas.py
Normal file
70
apps/certificates/api/schemas.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""API payloads for certificate operations."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class SkillSchema(Schema):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CertificateTemplateOut(Schema):
|
||||
id: int
|
||||
event_id: int
|
||||
event_title: str
|
||||
image_url: Optional[str]
|
||||
skill_ids: List[int]
|
||||
skills: List[SkillSchema]
|
||||
|
||||
|
||||
class CertificateGenerationItem(Schema):
|
||||
user_id: int
|
||||
score: int
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
skill_ids: Optional[List[int]] = None
|
||||
issued_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CertificateGenerationPayload(Schema):
|
||||
entries: List[CertificateGenerationItem]
|
||||
default_title: Optional[str] = None
|
||||
default_description: Optional[str] = None
|
||||
|
||||
|
||||
class UserCertificateOut(Schema):
|
||||
id: int
|
||||
user_id: int
|
||||
user_name: str
|
||||
event_id: int
|
||||
title: str
|
||||
certificate_id: str
|
||||
certificate_code: str
|
||||
score: int
|
||||
score_label: str
|
||||
image_url: Optional[str]
|
||||
|
||||
|
||||
class CertificateGenerationResponse(Schema):
|
||||
certificates: List[UserCertificateOut]
|
||||
|
||||
|
||||
class CertificateVerificationOut(Schema):
|
||||
certificate_id: str
|
||||
certificate_code: str
|
||||
user_id: int
|
||||
user_name: str
|
||||
event_id: int
|
||||
event_title: str
|
||||
title: str
|
||||
score: int
|
||||
score_label: str
|
||||
issued_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
image_url: Optional[str] = None
|
||||
skills: List[str]
|
||||
138
apps/certificates/api/views.py
Normal file
138
apps/certificates/api/views.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from 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()],
|
||||
)
|
||||
6
apps/certificates/apps.py
Normal file
6
apps/certificates/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CertificatesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.certificates"
|
||||
86
apps/certificates/migrations/0001_initial.py
Normal file
86
apps/certificates/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
1
apps/certificates/migrations/__init__.py
Normal file
1
apps/certificates/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
""""""
|
||||
316
apps/certificates/models.py
Normal file
316
apps/certificates/models.py
Normal file
@@ -0,0 +1,316 @@
|
||||
from io import BytesIO
|
||||
from typing import Optional, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from 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)
|
||||
Reference in New Issue
Block a user