From 5f51cad7bec5b2e7c37a16200b0d9e52271c22f8 Mon Sep 17 00:00:00 2001 From: Naman Sharma Date: Mon, 9 Feb 2026 22:20:54 +0000 Subject: [PATCH] Added async certificate batch generation for test and training --- cron/__init__.py | 3 +- cron/migrations/0006_certificatebatch.py | 36 +++ cron/models.py | 27 +++ cron/tasks.py | 284 ++++++++++++++++++++++- events/urls.py | 5 +- events/urlsv2.py | 2 +- events/views.py | 24 +- events/viewsv2.py | 22 +- 8 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 cron/migrations/0006_certificatebatch.py diff --git a/cron/__init__.py b/cron/__init__.py index f9a61eb69..71d6ef67b 100644 --- a/cron/__init__.py +++ b/cron/__init__.py @@ -9,4 +9,5 @@ from rq import Retry, Queue DEFAULT_QUEUE = Queue('default_queue', connection=REDIS_CLIENT, failure_ttl='72h') -TOPPER_QUEUE = Queue('topper_queue', connection=REDIS_CLIENT, failure_ttl='72h') \ No newline at end of file +TOPPER_QUEUE = Queue('topper_queue', connection=REDIS_CLIENT, failure_ttl='72h') +CERTIFICATE_QUEUE = Queue('certificate_queue',connection=REDIS_CLIENT,failure_ttl='72h') diff --git a/cron/migrations/0006_certificatebatch.py b/cron/migrations/0006_certificatebatch.py new file mode 100644 index 000000000..9a5f4f7d6 --- /dev/null +++ b/cron/migrations/0006_certificatebatch.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2026-02-08 23:16 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0057_auto_20260127_1633'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cron', '0005_asynccronmail_ers_job_id'), + ] + + operations = [ + migrations.CreateModel( + name='CertificateBatch', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('batch_type', models.IntegerField(choices=[(1, 'TEST'), (2, 'TRAINING')])), + ('status', models.IntegerField(choices=[(0, 'QUEUED'), (1, 'RUNNING'), (2, 'DONE'), (3, 'FAILED')], default=0)), + ('rq_job_id', models.CharField(blank=True, max_length=128, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('output_path', models.TextField(blank=True, null=True)), + ('error', models.TextField(blank=True, null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('test', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='events.Test')), + ('training', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='events.TrainingRequest')), + ], + ), + ] diff --git a/cron/models.py b/cron/models.py index 02e5b3299..24c38d034 100644 --- a/cron/models.py +++ b/cron/models.py @@ -21,3 +21,30 @@ def __str__(self): return self.subject +class CertificateBatch(models.Model): + TYPE_CHOICES = ( + (1, "TEST"), + (2, "TRAINING") + ) + + STATUS_CHOICES = ( + (0, "QUEUED"), + (1, "RUNNING"), + (2, "DONE"), + (3, "FAILED"), + ) + + batch_type = models.IntegerField(choices=TYPE_CHOICES) + test = models.ForeignKey("events.Test", null=True, blank=True, on_delete=models.CASCADE) + training = models.ForeignKey("events.TrainingRequest", null=True, blank=True, on_delete=models.CASCADE) + + status = models.IntegerField(choices=STATUS_CHOICES, default=0) + rq_job_id = models.CharField(max_length=128, blank=True, null=True) + + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + output_path = models.TextField(null=True, blank=True) + error = models.TextField(null=True, blank=True) \ No newline at end of file diff --git a/cron/tasks.py b/cron/tasks.py index 8ccaeb22a..d26960770 100644 --- a/cron/tasks.py +++ b/cron/tasks.py @@ -10,8 +10,8 @@ os.environ["DJANGO_SETTINGS_MODULE"] = "spoken.settings" application = get_wsgi_application() -from .models import AsyncCronMail -from datetime import datetime +from .models import AsyncCronMail, CertificateBatch +from datetime import datetime, date from django.utils import timezone from django.conf import settings import uuid @@ -21,7 +21,7 @@ from smtplib import SMTPException, SMTPServerDisconnected from django.core.mail import BadHeaderError from rq.decorators import job -from cron import REDIS_CLIENT, DEFAULT_QUEUE, TOPPER_QUEUE +from cron import REDIS_CLIENT, DEFAULT_QUEUE, TOPPER_QUEUE, CERTIFICATE_QUEUE from rq import Retry import time from rq import get_current_job @@ -37,6 +37,52 @@ from events.helpers import get_fossmdlcourse from django.db import close_old_connections # from events.views import update_events_log, update_events_notification +import logging +import traceback +from io import BytesIO +from datetime import timedelta +from reportlab.pdfgen import canvas +from reportlab.platypus import Paragraph +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import cm +from PyPDF2 import PdfFileWriter, PdfFileReader +from events import certificates as certs +from events.certificates import ( + get_test_certificate, + get_training_certificate, + get_signature, + get_test_cert_text, + get_training_cert_text, +) +import random +import string + +logger = logging.getLogger(__name__) + + +def _id_generator(size=6, chars=string.ascii_uppercase + string.digits): + return ''.join(random.choice(chars) for _ in range(size)) + + +def _custom_strftime(format, t): + return t.strftime(format) + + +def _ensure_certificate_date(): + if isinstance(certs.EDUPYRAMIDS_CERTIFICATE_DATE, date): + return + if not certs.EDUPYRAMIDS_CERTIFICATE_DATE: + certs.EDUPYRAMIDS_CERTIFICATE_DATE = date.max + return + for fmt in ("%Y-%m-%d", "%d-%m-%Y"): + try: + certs.EDUPYRAMIDS_CERTIFICATE_DATE = datetime.strptime( + certs.EDUPYRAMIDS_CERTIFICATE_DATE, fmt + ).date() + return + except ValueError: + continue + certs.EDUPYRAMIDS_CERTIFICATE_DATE = date.max @@ -408,3 +454,235 @@ def async_test_post_save(test, user, message): job_timeout='24h' ) print(f"\033[92m Added async_test_post_save job successfully \033[0m") + + +def _merge_overlay_page(output, template_path, overlay_buffer): + # Merge overlay onto template and add to output + with open(template_path, "rb") as template_file: + template_bytes = template_file.read() + page = PdfFileReader(BytesIO(template_bytes)).getPage(0) + overlay = PdfFileReader(BytesIO(overlay_buffer.getvalue())).getPage(0) + if hasattr(page, "merge_page"): + page.merge_page(overlay) + else: + page.mergePage(overlay) + output.addPage(page) + + +def _build_test_overlay(ta, test, mdluser, mdlgrade): + _ensure_certificate_date() + img_temp = BytesIO() + img_doc = canvas.Canvas(img_temp) + + if ta.test.training.department.id != 169: + img_doc.setFont('Helvetica', 18, leading=None) + img_doc.drawCentredString(211, 115, _custom_strftime('%d %B %Y', test.tdate)) + + img_doc.setFillColorRGB(0, 0, 0) + img_doc.setFont('Helvetica', 10, leading=None) + img_doc.drawString(10, 6, ta.password) + + img_path = get_signature(ta.test.tdate) + img_doc.drawImage(img_path, 600, 95, 150, 76) + + credits = "

Credits: " + str(test.foss.credits) + "   Score: " + str('{:.2f}'.format(mdlgrade.grade)) + "%

" + text = get_test_cert_text(ta.test, mdluser, credits=credits) + centered = ParagraphStyle( + name='centered', + fontSize=15, + leading=24, + alignment=1, + spaceAfter=20 + ) + p = Paragraph(text, centered) + p.wrap(700, 200) + p.drawOn(img_doc, 3 * cm, 6.5 * cm) + + text = "Certificate for Completion of
" + test.foss.foss + " Training" + centered = ParagraphStyle( + name='centered', + fontSize=25, + leading=25, + alignment=1, + spaceAfter=15 + ) + p = Paragraph(text, centered) + p.wrap(500, 20) + p.drawOn(img_doc, 6.2 * cm, 17 * cm) + + img_doc.save() + return img_temp + + +def _build_training_overlay(ta, training_end): + _ensure_certificate_date() + img_temp = BytesIO() + img_doc = canvas.Canvas(img_temp) + + img_doc.setFont('Helvetica', 35, leading=None) + img_doc.drawCentredString(405, 480, "Certificate of Participation") + + if ta.training.department.id != 169: + img_doc.setFont('Helvetica', 18, leading=None) + img_doc.drawCentredString(211, 115, _custom_strftime('%d %B %Y', training_end)) + + img_doc.setFillColorRGB(211, 211, 211) + img_doc.setFont('Helvetica', 10, leading=None) + img_doc.drawString(10, 6, "") + + img_path = get_signature(ta.training.training_start_date) + img_doc.drawImage(img_path, 600, 100, 150, 76) + + text = get_training_cert_text(ta) + centered = ParagraphStyle( + name='centered', + fontSize=16, + leading=30, + alignment=0, + spaceAfter=20 + ) + p = Paragraph(text, centered) + p.wrap(630, 200) + p.drawOn(img_doc, 4.2 * cm, 7 * cm) + img_doc.save() + return img_temp + + +def _generate_test_certificates(batch): + if not batch.test_id: + raise ValueError("Test batch is missing test") + + test = Test.objects.select_related( + 'training', + 'training__department', + 'academic', + 'foss', + 'organiser__user', + 'invigilator__user' + ).get(pk=batch.test_id) + + test_attendances = TestAttendance.objects.select_related( + 'test', + 'test__training', + 'test__training__department', + 'test__academic', + 'test__foss', + 'test__organiser__user', + 'test__invigilator__user' + ).filter(test_id=batch.test_id) + + quiz_ids = {ta.mdlquiz_id for ta in test_attendances} + user_ids = {ta.mdluser_id for ta in test_attendances} + grades = MdlQuizGrades.objects.using('moodle').filter(quiz__in=quiz_ids, userid__in=user_ids) + grades_by_key = {(g.quiz, g.userid): g for g in grades} + users_by_id = MdlUser.objects.using('moodle').in_bulk(user_ids) + + output = PdfFileWriter() + + for ta in test_attendances: + mdlgrade = grades_by_key.get((ta.mdlquiz_id, ta.mdluser_id)) + mdluser = users_by_id.get(ta.mdluser_id) + if not mdlgrade or not mdluser: + continue + if ta.status < 1 or round(mdlgrade.grade, 1) < 40: + continue + + if ta.password: + certificate_pass = ta.password + else: + pad_len = max(0, 10 - len(str(ta.mdluser_id))) + certificate_pass = str(ta.mdluser_id) + _id_generator(pad_len) + ta.password = certificate_pass + + ta.count += 1 + ta.status = 4 + ta.save(update_fields=["password", "count", "status", "updated"]) + + overlay = _build_test_overlay(ta, test, mdluser, mdlgrade) + template_path = get_test_certificate(ta) + _merge_overlay_page(output, template_path, overlay) + + return output + + +def _generate_training_certificates(batch): + if not batch.training_id: + raise ValueError("Training batch is missing training") + + ta_list = TrainingAttend.objects.select_related( + 'student__user', + 'training', + 'training__department', + 'training__course__foss', + 'training__training_planner__academic', + 'training__training_planner__organiser__user' + ).filter(training_id=batch.training_id) + + output = PdfFileWriter() + + for ta in ta_list: + training_end = ta.training.sem_start_date + timedelta(days=60) + overlay = _build_training_overlay(ta, training_end) + template_path = get_training_certificate(ta) + _merge_overlay_page(output, template_path, overlay) + + return output + + +def _get_certificate_output_path(batch): + cert_dir = os.path.join(settings.MEDIA_ROOT, 'certificates') + os.makedirs(cert_dir, exist_ok=True) + type_label = 'test' if batch.batch_type == 1 else 'training' + filename = "certificate_batch_%s_%s.pdf" % (batch.id, type_label) + rel_path = os.path.join('certificates', filename) + abs_path = os.path.join(settings.MEDIA_ROOT, rel_path) + return rel_path, abs_path + + +def generate_certificate_batch(batch_id): + # Generate merged certificates + close_old_connections() + _ensure_certificate_date() + batch = CertificateBatch.objects.select_related('test', 'training').get(pk=batch_id) + logger.info("Starting certificate batch %s", batch.id) + + batch.status = 1 + batch.started_at = timezone.now() + batch.error = None + batch.save(update_fields=["status", "started_at", "error"]) + + try: + if batch.batch_type == 1: + output = _generate_test_certificates(batch) + elif batch.batch_type == 2: + output = _generate_training_certificates(batch) + else: + raise ValueError("Unsupported certificate batch type: %s" % batch.batch_type) + + rel_path, abs_path = _get_certificate_output_path(batch) + with open(abs_path, "wb") as output_file: + output.write(output_file) + + batch.output_path = rel_path + batch.status = 2 + batch.completed_at = timezone.now() + batch.save(update_fields=["output_path", "status", "completed_at"]) + logger.info("Completed certificate batch %s", batch.id) + except Exception: + batch.status = 3 + batch.error = traceback.format_exc() + batch.completed_at = timezone.now() + batch.save(update_fields=["status", "error", "completed_at"]) + logger.exception("Certificate batch %s failed", batch.id) + raise + + +def async_generate_certificate_batch(batch): + job = CERTIFICATE_QUEUE.enqueue( + generate_certificate_batch, + batch.pk, + job_timeout='72h' + ) + batch.rq_job_id = job.id + batch.status = 0 + batch.save(update_fields=["rq_job_id", "status"]) diff --git a/events/urls.py b/events/urls.py index 2f6f85f40..1060a34a2 100644 --- a/events/urls.py +++ b/events/urls.py @@ -2,6 +2,7 @@ from events.views import * from events.notification import nemail from .views import get_schools, get_batches +from youtube.views import add_youtube_video as youtube_add_video app_name = 'events' urlpatterns = [ @@ -80,7 +81,7 @@ #url(r'^test/subscribe/(\d+)/(\d+)/$', test_student_subscribe', name='test_student_subscribe'), url(r'^test/(\d+)/participant/$', test_participant, name='test_participant'), url(r'^test/participant/certificate/(\d+)/(\d+)/$', test_participant_ceritificate, name='test_participant_ceritificate'), - url(r'^test/participant/certificate/all/(\d+)/$', test_participant_ceritificate_all, name='test_participant_ceritificate_all'), + url(r'^test/participant/certificate/all/(\d+)/$', async_test_participant_certificate_all, name='test_participant_ceritificate_all'), url(r'^test/(\d+)/attendance/$', test_attendance, name='test_attendance'), url(r'^test/(?P\w+)/request/$', test_request, name='test_request'), url(r'^test/(?P\w+)/(?P\d+)/approvel/$', test_approvel, name='test_approvel'), @@ -94,6 +95,8 @@ url(r'^resource-center/$', resource_center, name="resource_center"), url(r'^resource-center/(?P[\w-]+)/$', resource_center, name="resource_center"), + url(r'^add-youtube-video/$', youtube_add_video, name="add_youtube_video"), + url(r'^academic-center/(?P\d+)/$', academic_center, name="academic_center"), url(r'^academic-center/(?P\d+)/(?P[\w-]+)/$', academic_center, name="academic_center"), diff --git a/events/urlsv2.py b/events/urlsv2.py index 94586e5d1..438527dde 100755 --- a/events/urlsv2.py +++ b/events/urlsv2.py @@ -346,7 +346,7 @@ ), url( r'^training-certificate/(?P\d+)/allcertificates/$', - AllTrainingCertificateView.as_view(), \ + async_training_certificate_all, \ name="alltraining_certificate" ), url( diff --git a/events/views.py b/events/views.py index 87f466027..71f0a7b9a 100644 --- a/events/views.py +++ b/events/views.py @@ -80,8 +80,10 @@ def get_batches(request): from cron.tasks import ( async_process_test_attendance, - async_test_post_save + async_test_post_save, + async_generate_certificate_batch ) +from cron.models import CertificateBatch from io import StringIO, BytesIO @@ -2558,6 +2560,26 @@ def test_participant_ceritificate_all(request, testid): return response +def async_test_participant_certificate_all(request, testid): + w = Test.objects.get(id=testid) + if not (w.organiser.user == request.user or w.invigilator.user == request.user): + raise PermissionDenied() + total_participants = TestAttendance.objects.filter(test_id=testid).count() + estimated_minutes = max(1, total_participants // 50) + batch = CertificateBatch.objects.create( + batch_type=1, + test_id=testid, + created_by=request.user + ) + async_generate_certificate_batch(batch) + messages.success( + request, + "Certificate generation has started. Estimated time: ~%s minutes." % estimated_minutes + ) + redirect_url = request.META.get("HTTP_REFERER") or request.path + return HttpResponseRedirect(redirect_url) + + @csrf_exempt def training_subscribe(request, events, eventid = None, mdluser_id = None): try: diff --git a/events/viewsv2.py b/events/viewsv2.py index 6d57854b1..fa9818924 100755 --- a/events/viewsv2.py +++ b/events/viewsv2.py @@ -72,7 +72,8 @@ # import helpers from events.views import is_organiser, is_invigilator, is_resource_person, is_administrator, is_accountexecutive from events.helpers import get_prev_semester_duration, get_updated_form -from cron.tasks import async_filter_student_grades, filter_student_grades +from cron.tasks import async_filter_student_grades, filter_student_grades, async_generate_certificate_batch +from cron.models import CertificateBatch from spoken.config import TOPPER_WORKER_STATUS from django.db import connection from donate.utils import send_transaction_email @@ -824,6 +825,25 @@ def get_context_data(self, **kwargs): return context +@group_required("Organiser") +def async_training_certificate_all(request, trid): + training = get_object_or_404(TrainingRequest, pk=trid) + total_participants = TrainingAttend.objects.filter(training_id=training.id).count() + estimated_minutes = max(1, total_participants // 50) + batch = CertificateBatch.objects.create( + batch_type=2, + training_id=training.id, + created_by=request.user + ) + async_generate_certificate_batch(batch) + messages.success( + request, + "Certificate generation has started. Estimated time: ~%s minutes." % estimated_minutes + ) + redirect_url = request.META.get("HTTP_REFERER") or request.path + return HttpResponseRedirect(redirect_url) + + """ Delete Student """