Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BB-9241] Add learner pathways cert generation #69

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 23 additions & 34 deletions openedx_certificates/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,17 @@

from django import forms
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.utils.html import format_html
from django_object_actions import DjangoObjectActions, action
from django_reverse_admin import ReverseModelAdmin
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey

from .models import (
ExternalCertificate,
ExternalCertificateAsset,
ExternalCertificateCourseConfiguration,
ExternalCertificateConfiguration,
ExternalCertificateType,
)
from .tasks import generate_certificates_for_course_task
from .tasks import generate_certificates_task

if TYPE_CHECKING: # pragma: no cover
from collections.abc import Generator
Expand Down Expand Up @@ -117,10 +114,10 @@ class ExternalCertificateAssetAdmin(admin.ModelAdmin): # noqa: D101
prepopulated_fields = {"asset_slug": ("description",)} # noqa: RUF012


class ExternalCertificateCourseConfigurationForm(forms.ModelForm, DocstringOptionsMixin): # noqa: D101
class ExternalCertificateConfigurationForm(forms.ModelForm, DocstringOptionsMixin): # noqa: D101
class Meta: # noqa: D106
model = ExternalCertificateCourseConfiguration
fields = ('course_id', 'certificate_type', 'custom_options')
model = ExternalCertificateConfiguration
fields = ('resource_id', 'resource_type', 'certificate_type', 'custom_options')

def __init__(self, *args, **kwargs):
"""Initializes the choices for the retrieval and generation function selection fields."""
Expand All @@ -137,42 +134,32 @@ def __init__(self, *args, **kwargs):

self.fields['custom_options'].help_text += options

def clean_course_id(self) -> CourseKey:
"""Validate the course_id field."""
course_id = self.cleaned_data.get('course_id')
try:
CourseKey.from_string(course_id)
except InvalidKeyError as exc:
msg = "Invalid course ID format. The correct format is 'course-v1:{Organization}+{Course}+{Run}'."
raise ValidationError(msg) from exc
return course_id


@admin.register(ExternalCertificateCourseConfiguration)
class ExternalCertificateCourseConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
@admin.register(ExternalCertificateConfiguration)
class ExternalCertificateConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
"""
Admin page for the course-specific certificate configuration for each certificate type.

It manages the associations between configuration and its corresponding periodic task.
The reverse inline provides a way to manage the periodic task from the configuration page.
"""

form = ExternalCertificateCourseConfigurationForm
form = ExternalCertificateConfigurationForm
inline_type = 'stacked'
inline_reverse = [ # noqa: RUF012
(
'periodic_task',
{'fields': ['enabled', 'interval', 'crontab', 'clocked', 'start_time', 'expires', 'one_off']},
),
]
list_display = ('course_id', 'certificate_type', 'enabled', 'interval')
search_fields = ('course_id', 'certificate_type__name')
list_filter = ('course_id', 'certificate_type')
list_display = ('resource_id', 'resource_type', 'certificate_type', 'enabled', 'interval')
search_fields = ('resource_id', 'resource_type', 'certificate_type__name')
list_filter = ('resource_id', 'resource_type', 'certificate_type')

def get_inline_instances(
self,
request: HttpRequest,
obj: ExternalCertificateCourseConfiguration = None,
obj: ExternalCertificateConfiguration = None,
) -> list[admin.ModelAdmin]:
"""
Hide inlines on the "Add" view in Django admin, and show them on the "Change" view.
Expand All @@ -186,33 +173,33 @@ def get_inline_instances(
"""
return super().get_inline_instances(request, obj) if '/add/' not in request.path else []

def enabled(self, obj: ExternalCertificateCourseConfiguration) -> bool:
def enabled(self, obj: ExternalCertificateConfiguration) -> bool:
"""Return the 'enabled' status of the periodic task."""
return obj.periodic_task.enabled

enabled.boolean = True

# noinspection PyMethodMayBeStatic
def interval(self, obj: ExternalCertificateCourseConfiguration) -> IntervalSchedule:
def interval(self, obj: ExternalCertificateConfiguration) -> IntervalSchedule:
"""Return the interval of the certificate generation task."""
return obj.periodic_task.interval

def get_readonly_fields(self, _request: HttpRequest, obj: ExternalCertificateCourseConfiguration = None) -> tuple:
"""Make the course_id field read-only."""
def get_readonly_fields(self, _request: HttpRequest, obj: ExternalCertificateConfiguration = None) -> tuple:
"""Make the resource_id field read-only."""
if obj: # editing an existing object
return *self.readonly_fields, 'course_id', 'certificate_type'
return *self.readonly_fields, 'resource_id', 'resource_type', 'certificate_type'
return self.readonly_fields

@action(label="Generate certificates")
def generate_certificates(self, _request: HttpRequest, obj: ExternalCertificateCourseConfiguration):
def generate_certificates(self, _request: HttpRequest, obj: ExternalCertificateConfiguration):
"""
Custom action to generate certificates for the current ExternalCertificateCourse instance.

Args:
_request: The request object.
obj: The ExternalCertificateCourse instance.
"""
generate_certificates_for_course_task.delay(obj.id)
generate_certificates_task.delay(obj.id)

change_actions = ('generate_certificates',)

Expand All @@ -222,7 +209,8 @@ class ExternalCertificateAdmin(admin.ModelAdmin): # noqa: D101
list_display = (
'user_id',
'user_full_name',
'course_id',
'resource_id',
'resource_type',
'certificate_type',
'status',
'url',
Expand All @@ -234,7 +222,8 @@ class ExternalCertificateAdmin(admin.ModelAdmin): # noqa: D101
'created',
'modified',
'user_full_name',
'course_id',
'resource_id',
'resource_type',
'certificate_type',
'status',
'url',
Expand Down
78 changes: 52 additions & 26 deletions openedx_certificates/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@
We will move this module to an external repository (a plugin).
"""

from __future__ import annotations
from __future__ import annotations # noqa: I001

import io
import logging
import secrets
from typing import TYPE_CHECKING, Any

from django.apps import apps
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import FileSystemStorage, default_storage

from pypdf import PdfReader, PdfWriter
from pypdf.constants import UserAccessPermissions
from reportlab.pdfbase import pdfmetrics
Expand All @@ -26,13 +28,11 @@
from openedx_certificates.compat import get_course_name, get_default_storage_url, get_localized_certificate_date
from openedx_certificates.models import ExternalCertificateAsset

log = logging.getLogger(__name__)

if TYPE_CHECKING: # pragma: no cover
from django.contrib.auth.models import User # noqa: I001
from uuid import UUID

from django.contrib.auth.models import User
from opaque_keys.edx.keys import CourseKey
log = logging.getLogger(__name__)


def _get_user_name(user: User) -> str:
Expand All @@ -58,14 +58,20 @@
return font or 'Helvetica'


def _write_text_on_template(template: any, font: str, username: str, course_name: str, options: dict[str, Any]) -> any:
def _write_text_on_template(
template: any,
font: str,
username: str,
resource_name: str,
options: dict[str, Any],
) -> any:
"""
Prepare a new canvas and write the user and course name onto it.
Prepare a new canvas and write the user and resource name onto it.

:param template: Pdf template.
:param font: Font name.
:param username: The name of the user to generate the certificate for.
:param course_name: The name of the course the learner completed.
:param resource_name: The name of the resource the learner completed (e.g. course, learning_path).
:param options: A dictionary documented in the `generate_pdf_certificate` function.
:returns: A canvas with written data.
"""
Expand Down Expand Up @@ -99,16 +105,16 @@

# Write the course name.
pdf_canvas.setFont(font, 28)
course_name_color = options.get('course_name_color', '#000')
pdf_canvas.setFillColorRGB(*hex_to_rgb(course_name_color))
resource_name_color = options.get('resource_name_color', '#000')
pdf_canvas.setFillColorRGB(*hex_to_rgb(resource_name_color))

course_name_y = options.get('course_name_y', 220)
course_name_line_height = 28 * 1.1
resource_name_y = options.get('resource_name_y', 220)
resource_name_line_height = 28 * 1.1

# Split the course name into lines and write each of them in the center of the template.
for line_number, line in enumerate(course_name.split('\n')):
for line_number, line in enumerate(resource_name.split('\n')):
line_x = (template_width - pdf_canvas.stringWidth(line)) / 2
line_y = course_name_y - (line_number * course_name_line_height)
line_y = resource_name_y - (line_number * resource_name_line_height)
pdf_canvas.drawString(line_x, line_y, line)

# Write the issue date.
Expand Down Expand Up @@ -162,39 +168,59 @@
return url


def generate_pdf_certificate(course_id: CourseKey, user: User, certificate_uuid: UUID, options: dict[str, Any]) -> str:
def generate_pdf_certificate(
resource_id: str,
resource_type: str,
user: User,
certificate_uuid: UUID,
options: dict[str, Any],
) -> str:
"""
Generate a PDF certificate.

:param course_id: The ID of the course the learner completed.
:param user: The user to generate the certificate for.
:param resource_id: The ID of the course or learning path the learner completed.
:param resource_type: The type of the resource ('course' or 'learning_path').
:param certificate_uuid: The UUID of the certificate to generate.
:param options: The custom options for the certificate.
:returns: The URL of the saved certificate.

Options:
- template: The path to the PDF template file.
- template_two_lines: The path to the PDF template file for two-line course names.
A two-line course name is specified by using a semicolon as a separator.
- template_two_lines: The path to the PDF template file for two-line resource names.
A two-line resource name is specified by using a semicolon as a separator.
- font: The name of the font to use.
- name_y: The Y coordinate of the name on the certificate (vertical position on the template).
- name_color: The color of the name on the certificate (hexadecimal color code).
- course_name: Specify the course name to use instead of the course Display Name retrieved from Open edX.
- course_name_y: The Y coordinate of the course name on the certificate (vertical position on the template).
- course_name_color: The color of the course name on the certificate (hexadecimal color code).
- resource_name: Specify the resource name to use instead of the Display Name retrieved from Open edX.
- resource_name_y: The Y coordinate of the resource name.
- resource_name_color: The color of the resource name.
- issue_date_y: The Y coordinate of the issue date on the certificate (vertical position on the template).
- issue_date_color: The color of the issue date on the certificate (hexadecimal color code).
"""
log.info("Starting certificate generation for user %s", user.id)

username = _get_user_name(user)
course_name = options.get('course_name') or get_course_name(course_id)
resource_name = options.get('resource_name')

if resource_type == 'course':
resource_name = resource_name or get_course_name(resource_id)
elif resource_type == 'learning_path':
if not apps.is_installed('learning_paths'):
resource_name = ""

Check warning on line 209 in openedx_certificates/generators.py

View check run for this annotation

Codecov / codecov/patch

openedx_certificates/generators.py#L209

Added line #L209 was not covered by tests
try:
LearningPath = apps.get_model('learning_paths', 'LearningPath')
resource_name = LearningPath.objects.get(uuid=resource_id).display_name
except Exception: # noqa: BLE001
resource_name = ""

Check warning on line 214 in openedx_certificates/generators.py

View check run for this annotation

Codecov / codecov/patch

openedx_certificates/generators.py#L213-L214

Added lines #L213 - L214 were not covered by tests
else:
msg = f"Unsupported resource type: {resource_type}"
raise ValueError(msg)

Check warning on line 217 in openedx_certificates/generators.py

View check run for this annotation

Codecov / codecov/patch

openedx_certificates/generators.py#L216-L217

Added lines #L216 - L217 were not covered by tests

# Get template from the ExternalCertificateAsset.
# HACK: We support two-line strings by using a semicolon as a separator.
if ';' in course_name and (template_path := options.get('template_two_lines')):
if ';' in resource_name and (template_path := options.get('template_two_lines')):
template_file = ExternalCertificateAsset.get_asset_by_slug(template_path)
course_name = course_name.replace(';', '\n')
resource_name = resource_name.replace(';', '\n')
else:
template_file = ExternalCertificateAsset.get_asset_by_slug(options['template'])

Expand All @@ -207,7 +233,7 @@
certificate = PdfWriter()

# Create a new canvas, prepare the page and write the data
pdf_canvas = _write_text_on_template(template, font, username, course_name, options)
pdf_canvas = _write_text_on_template(template, font, username, resource_name, options)

overlay_pdf = PdfReader(io.BytesIO(pdf_canvas.getpdfdata()))
template.merge_page(overlay_pdf.pages[0])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.13 on 2025-01-22 15:29

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('openedx_certificates', '0001_initial'),
]

operations = [
migrations.RenameModel(
old_name='ExternalCertificateCourseConfiguration',
new_name='ExternalCertificateConfiguration',
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 4.2.13 on 2025-01-22 15:43

from django.db import migrations, models


def populate_resource_id(apps, schema_editor):
"""
Populate the resource_id field with the value from course_id for all existing rows.
"""
ExternalCertificateConfiguration = apps.get_model('openedx_certificates', 'ExternalCertificateConfiguration')
for certconfig in ExternalCertificateConfiguration.objects.all():
certconfig.resource_id = certconfig.course_id
certconfig.save()


class Migration(migrations.Migration):

dependencies = [
('openedx_certificates', '0002_rename_externalcertificatecourseconfiguration_externalcertificateconfiguration'),
]

operations = [
migrations.AlterUniqueTogether(
name='externalcertificateconfiguration',
unique_together=set(),
),
migrations.AddField(
model_name='externalcertificateconfiguration',
name='resource_id',
field=models.CharField(blank=True, help_text='The ID of the resource (e.g., course_id, learner_path_uuid).', max_length=255, null=True),
),
migrations.AddField(
model_name='externalcertificateconfiguration',
name='resource_type',
field=models.CharField(choices=[('course', 'Course'), ('learning_path', 'Learning Path')], default='course', help_text='The type of the resource.', max_length=50),
),
migrations.AlterUniqueTogether(
name='externalcertificateconfiguration',
unique_together={('resource_id', 'certificate_type')},
),
migrations.RunPython(populate_resource_id),
migrations.RemoveField(
model_name='externalcertificateconfiguration',
name='course_id',
),
migrations.AlterField(
model_name='externalcertificateconfiguration',
name='resource_id',
field=models.CharField(max_length=255, help_text='The ID of the resource (e.g., course, learning path).'),
),
]
Loading