diff --git a/credentials/apps/credentials/admin.py b/credentials/apps/credentials/admin.py index bc7cf39bb..347d268ea 100644 --- a/credentials/apps/credentials/admin.py +++ b/credentials/apps/credentials/admin.py @@ -4,8 +4,10 @@ from credentials.apps.credentials.forms import ProgramCertificateAdminForm, SignatoryModelForm from credentials.apps.credentials.models import ( + CertificateAsset, CourseCertificate, ProgramCertificate, + ProgramCertificateTemplate, ProgramCompletionEmailConfiguration, RevokeCertificatesConfig, Signatory, @@ -117,6 +119,74 @@ class ProgramCompletionEmailConfigurationAdmin(TimeStampedModelAdminMixin, admin search_fields = ("identifier",) +@admin.register(ProgramCertificateTemplate) +class ProgramCertificateTemplateAdmin(TimeStampedModelAdminMixin, admin.ModelAdmin): + list_display = ("__str__", "organization", "is_active", "modified") + list_filter = ("is_active",) + list_select_related = ("program_certificate", "organization") + search_fields = ("organization__key", "program_certificate__program_uuid") + autocomplete_fields = ("program_certificate", "organization") + fieldsets = ( + ( + None, + { + "fields": ("is_active", "program_certificate", "organization"), + "description": ( + "Scope: set program_certificate for a specific program. " + "Optionally narrow to a specific organization. " + "Selection priority: exact cert+org > exact cert. " + "Requires waffle switch credentials.custom_program_certificate_templates to be active." + ), + }, + ), + ( + "Template HTML", + { + "fields": ("template",), + "description": ( + "Standalone Django template HTML rendered directly via from_string(). " + "All standard Django template tags and filters are available. " + "Use {% load certificate_assets %}{% certificate_asset_url 'slug' %} " + "to reference uploaded assets." + ), + }, + ), + ) + + +@admin.register(CertificateAsset) +class CertificateAssetAdmin(TimeStampedModelAdminMixin, admin.ModelAdmin): + list_display = ("slug", "description", "asset", "modified") + search_fields = ("slug", "description") + readonly_fields = TimeStampedModelAdminMixin.readonly_fields + ("asset_url_preview",) + fieldsets = ( + ( + None, + { + "fields": ("slug", "description", "asset"), + "description": ( + "Upload an asset and assign it a unique slug. " + "Reference it in a ProgramCertificateTemplate with: " + "{% load certificate_assets %}{% certificate_asset_url 'your-slug' %}" + ), + }, + ), + ( + "Preview", + {"fields": ("asset_url_preview",)}, + ), + ) + + def asset_url_preview(self, obj): + from django.utils.html import format_html + + if obj.pk and obj.asset: + return format_html('{url}', url=obj.asset.url) + return "—" + + asset_url_preview.short_description = "Asset URL" + + @admin.register(RevokeCertificatesConfig) class RevokeCertificatesConfigAdmin(ConfigurationModelAdmin): pass diff --git a/credentials/apps/credentials/migrations/0034_certificateasset_programcertificatetemplate.py b/credentials/apps/credentials/migrations/0034_certificateasset_programcertificatetemplate.py new file mode 100644 index 000000000..4f4ab171c --- /dev/null +++ b/credentials/apps/credentials/migrations/0034_certificateasset_programcertificatetemplate.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.7 on 2026-03-31 05:25 + +import credentials.apps.credentials.models +import django.db.models.deletion +import django_extensions.db.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalog', '0017_pathway_status_never_empty'), + ('credentials', '0033_remove_download_url'), + ] + + operations = [ + migrations.CreateModel( + name='CertificateAsset', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('description', models.CharField(help_text="Human-readable description of this asset (e.g. 'FBR Pakistan org logo – PNG 200×200').", max_length=255)), + ('asset', models.FileField(help_text='Upload an image (PNG/SVG/JPG), CSS file, or font file (TTF/WOFF/WOFF2).', upload_to=credentials.apps.credentials.models.certificate_asset_path)), + ('slug', models.SlugField(help_text="Unique identifier used to reference this asset in templates via {% certificate_asset_url 'your-slug' %}.", max_length=100, unique=True)), + ], + options={ + 'verbose_name': 'Certificate Asset', + 'verbose_name_plural': 'Certificate Assets', + 'ordering': ['slug'], + }, + ), + migrations.CreateModel( + name='ProgramCertificateTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('template', models.TextField(help_text="Standalone Django template HTML rendered directly via from_string(). Use {% load certificate_assets %}{% certificate_asset_url 'slug' %} to reference uploaded assets.")), + ('is_active', models.BooleanField(default=True)), + ('organization', models.ForeignKey(blank=True, help_text='If set, applies only to programs from this organization.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='certificate_templates', to='catalog.organization')), + ('program_certificate', models.ForeignKey(blank=True, help_text='If set, applies only to this specific program certificate.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_templates', to='credentials.programcertificate')), + ], + options={ + 'verbose_name': 'Program Certificate Template', + 'verbose_name_plural': 'Program Certificate Templates', + 'ordering': ['-created'], + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('program_certificate', 'organization'), name='unique_active_template_per_program_org', violation_error_message='An active template for this program certificate and organization already exists.'), models.UniqueConstraint(condition=models.Q(('is_active', True), ('organization', None)), fields=('program_certificate',), name='unique_active_template_per_program_no_org', violation_error_message='An active template for this program certificate without an organization already exists.')], + }, + ), + ] diff --git a/credentials/apps/credentials/models.py b/credentials/apps/credentials/models.py index d482e1724..4e718120a 100644 --- a/credentials/apps/credentials/models.py +++ b/credentials/apps/credentials/models.py @@ -429,6 +429,149 @@ class UserCredentialDateOverride(TimeStampedModel): ) +class ProgramCertificateTemplate(TimeStampedModel): + """ + Stores custom Django template HTML for program certificates. + + Allows per-program and per-organization certificate template customization + via Django admin without touching files or rebuilding images. + Gated by the ``credentials.custom_program_certificate_templates`` waffle switch. + + Selection priority (most specific first): + 1. program_certificate + organization — exact program, specific org + 2. program_certificate only — exact program, any org + + The first active match wins. Falls back to file-based templates if no match. + + .. no_pii: + """ + + program_certificate = models.ForeignKey( + "ProgramCertificate", + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="custom_templates", + help_text="If set, applies only to this specific program certificate.", + ) + organization = models.ForeignKey( + "catalog.Organization", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="certificate_templates", + help_text="If set, applies only to programs from this organization.", + ) + template = models.TextField( + help_text=( + "Standalone Django template HTML rendered directly via from_string(). " + "Use {% load certificate_assets %}{% certificate_asset_url 'slug' %} " + "to reference uploaded assets." + ), + ) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["-created"] + verbose_name = "Program Certificate Template" + verbose_name_plural = "Program Certificate Templates" + constraints = [ + # Prevents two active templates targeting the same program+org pair. + # Covers the case where organization is specified (NOT NULL). + models.UniqueConstraint( + fields=["program_certificate", "organization"], + condition=models.Q(is_active=True), + name="unique_active_template_per_program_org", + violation_error_message=( + "An active template for this program certificate and organization already exists." + ), + ), + # Prevents two active templates targeting the same program with no org scoping. + # Needed as a separate constraint because NULL != NULL in SQL unique indexes. + models.UniqueConstraint( + fields=["program_certificate"], + condition=models.Q(is_active=True, organization=None), + name="unique_active_template_per_program_no_org", + violation_error_message=( + "An active template for this program certificate without an organization already exists." + ), + ), + ] + + def __str__(self): + parts = [] + if self.program_certificate_id: + parts.append(f"program={self.program_certificate.program_uuid}") + if self.organization_id: + parts.append(f"org={self.organization.key}") + return f"ProgramCertificateTemplate({', '.join(parts) or 'default'})" + + +def get_custom_program_certificate_template(program_certificate, org_keys): + """ + Return the most specific active ProgramCertificateTemplate, or None. + + Selection priority: + 1. exact program_certificate + org match + 2. exact program_certificate, any org + """ + if not program_certificate: + return None + qs = ProgramCertificateTemplate.objects.filter(is_active=True) + org_qs = qs.filter(organization__key__in=org_keys) if org_keys else qs.none() + + candidates = [ + org_qs.filter(program_certificate=program_certificate).first(), + qs.filter(program_certificate=program_certificate, organization=None).first(), + ] + return next((c for c in candidates if c is not None), None) + + +def certificate_asset_path(instance, filename): + """Returns upload path for certificate assets: certificate_assets//.""" + return f"certificate_assets/{instance.slug}/{filename}" + + +class CertificateAsset(TimeStampedModel): + """ + Stores uploadable assets (images, CSS, fonts) for use in DB-backed program + certificate templates. + + Reference an asset inside a ``ProgramCertificateTemplate`` using the + ``certificate_asset_url`` template tag:: + + {% load certificate_assets %} + + + .. no_pii: + """ + + description = models.CharField( + max_length=255, + help_text="Human-readable description of this asset (e.g. 'FBR Pakistan org logo – PNG 200×200').", + ) + asset = models.FileField( + upload_to=certificate_asset_path, + help_text="Upload an image (PNG/SVG/JPG), CSS file, or font file (TTF/WOFF/WOFF2).", + ) + slug = models.SlugField( + max_length=100, + unique=True, + help_text=( + "Unique identifier used to reference this asset in templates via " + "{% certificate_asset_url 'your-slug' %}." + ), + ) + + class Meta: + ordering = ["slug"] + verbose_name = "Certificate Asset" + verbose_name_plural = "Certificate Assets" + + def __str__(self): + return f"CertificateAsset({self.slug})" + + class RevokeCertificatesConfig(ConfigurationModel): """ Manages configuration for a run of the revoke_certificates management command. diff --git a/credentials/apps/credentials/templatetags/certificate_assets.py b/credentials/apps/credentials/templatetags/certificate_assets.py new file mode 100644 index 000000000..982369020 --- /dev/null +++ b/credentials/apps/credentials/templatetags/certificate_assets.py @@ -0,0 +1,50 @@ +""" +Template tag for referencing uploaded CertificateAsset files inside +DB-backed ProgramCertificateTemplate HTML. + +Usage:: + + {% load certificate_assets %} + + {# Renders the asset URL, empty string if slug not found #} + FBR Logo + + {# CSS file #} + + + {# Font #} + +""" + +import logging + +from django import template + +log = logging.getLogger(__name__) + +register = template.Library() + + +@register.simple_tag +def certificate_asset_url(slug): + """ + Return the URL of the CertificateAsset with the given slug. + + Returns an empty string and logs a warning if the slug does not exist, + so a missing asset degrades gracefully rather than crashing the certificate page. + """ + # Import inside the function to avoid import-time issues when this tag + # is loaded by from_string() before the app registry is fully ready. + from credentials.apps.credentials.models import CertificateAsset # noqa: PLC0415 + + try: + asset = CertificateAsset.objects.get(slug=slug) + return asset.asset.url + except CertificateAsset.DoesNotExist: + log.warning("certificate_asset_url: no CertificateAsset found with slug=%r", slug) + return "" diff --git a/credentials/apps/credentials/toggles.py b/credentials/apps/credentials/toggles.py new file mode 100644 index 000000000..002f8f037 --- /dev/null +++ b/credentials/apps/credentials/toggles.py @@ -0,0 +1,20 @@ +""" +Waffle switches for the credentials app. +""" +from edx_toggles.toggles import WaffleSwitch + + +# .. toggle_name: credentials.custom_program_certificate_templates +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: When enabled, the credentials service checks the +# ProgramCertificateTemplate model for a custom HTML template before +# falling back to file-based certificate templates. Allows per-program +# and per-organization certificate customization via Django admin +# without touching files or rebuilding images. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2026-03-26 +CUSTOM_PROGRAM_CERTIFICATE_TEMPLATES = WaffleSwitch( + "credentials.custom_program_certificate_templates", + module_name=__name__, +) diff --git a/credentials/apps/credentials/views.py b/credentials/apps/credentials/views.py index fbb32661f..487619da1 100644 --- a/credentials/apps/credentials/views.py +++ b/credentials/apps/credentials/views.py @@ -7,6 +7,7 @@ from django.contrib.admin.views.decorators import staff_member_required from django.http import Http404 from django.shortcuts import get_object_or_404 +from django.template import engines from django.template.defaultfilters import slugify from django.utils import timezone from django.utils.decorators import method_decorator @@ -17,7 +18,12 @@ from credentials.apps.catalog.data import OrganizationDetails, ProgramDetails from credentials.apps.core.views import ThemeViewMixin from credentials.apps.credentials.exceptions import MissingCertificateLogoError -from credentials.apps.credentials.models import ProgramCertificate, UserCredential +from credentials.apps.credentials.models import ( + ProgramCertificate, + UserCredential, + get_custom_program_certificate_template, +) +from credentials.apps.credentials.toggles import CUSTOM_PROGRAM_CERTIFICATE_TEMPLATES from credentials.apps.credentials.utils import get_credential_visible_date, to_language @@ -200,18 +206,36 @@ def get_context_data(self, **kwargs): return context - def get_credential_template(self): - template_names = [] + @cached_property + def db_certificate_template(self): + """Return the active DB-backed ProgramCertificateTemplate for this credential, or None.""" + if not CUSTOM_PROGRAM_CERTIFICATE_TEMPLATES.is_enabled(): + return None credential_type = self.user_credential.credential + org_keys = [org.key for org in credential_type.program_details.organizations] + return get_custom_program_certificate_template( + program_certificate=credential_type, + org_keys=org_keys, + ) + + def get_credential_template(self): + if self.db_certificate_template: + return engines["django"].from_string(self.db_certificate_template.template) + # Fallback: file-based template lookup (existing behaviour). # NOTE: In the future we will need to account for other types of credentials besides programs. - template_names += [ + credential_type = self.user_credential.credential + template_names = [ f"credentials/programs/{credential_type.program_uuid}/certificate.html", "credentials/programs/{type}/certificate.html".format(type=slugify(credential_type.program_details.type)), ] - return self.select_theme_template(template_names) + def get_template_names(self): + if self.db_certificate_template: + return ["credentials/certificate_only.html"] + return super().get_template_names() + def get_child_templates(self): return { "credential": self.get_credential_template(), diff --git a/credentials/templates/credentials/certificate_only.html b/credentials/templates/credentials/certificate_only.html new file mode 100644 index 000000000..ad4eef454 --- /dev/null +++ b/credentials/templates/credentials/certificate_only.html @@ -0,0 +1,35 @@ + +{% with render_language|default:"en" as page_language %} +{% load i18n %} + + + + + {{ page_title }} + + + +{% include child_templates.credential %} + + +{% endwith %} diff --git a/credentials/templates/credentials/programs/fbr-program/certificate.html b/credentials/templates/credentials/programs/fbr-program/certificate.html new file mode 100644 index 000000000..993cd018b --- /dev/null +++ b/credentials/templates/credentials/programs/fbr-program/certificate.html @@ -0,0 +1,276 @@ +{% load certificate_assets %} +{% load i18n %} + +{% certificate_asset_url 'irs-academy-logo-color' as left_logo_url %} +{% certificate_asset_url 'fbr-logo-color' as right_logo_url %} +{% certificate_asset_url 'irs-academy-logo-color' as watermark_url %} +{% certificate_asset_url 'font-algerian' as font_algerian_url %} +{% certificate_asset_url 'font-bookman-old-style' as font_bookman_url %} +{% certificate_asset_url 'font-monotype-corsiva' as font_corsiva_url %} +{% certificate_asset_url 'font-open-sans-400' as font_open_sans_400_url %} +{% certificate_asset_url 'font-open-sans-700' as font_open_sans_700_url %} + + + +
+ + + +
+ +
+ IRS Academy + FBR Pakistan +
+ +
+

{{ program_name }}

+
(21st April 2025 – {{ issue_date|date:"jS F Y" }})
+
+ +
{% trans "Certificate presented to" %}
+
+
+

{{ credential_name }}

+
+
+ +
+ {% trans "On successful completion of" %} {{ program_name }} {% trans "of" %} + {% trans "Inland Revenue Service (IRS) at Inland Revenue Service Academy," %} + {% trans "Lahore" %} +
+ +
+ {% for signatory in user_credential.credential.signatories.all %} +
+
+ {% if signatory.image %} + + {% endif %} +
+
+

{{ signatory.name }}

+

{{ signatory.title }}

+
+ {% endfor %} +
+ +
+
\ No newline at end of file diff --git a/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Algerian Regular.ttf b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Algerian Regular.ttf new file mode 100644 index 000000000..ebb574dcc Binary files /dev/null and b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Algerian Regular.ttf differ diff --git a/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Bookman Old Style Std Regular.otf b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Bookman Old Style Std Regular.otf new file mode 100644 index 000000000..42b1eb2d7 Binary files /dev/null and b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Bookman Old Style Std Regular.otf differ diff --git a/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Monotype-Corsiva-Regular.ttf b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Monotype-Corsiva-Regular.ttf new file mode 100644 index 000000000..c5c96668c Binary files /dev/null and b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/Monotype-Corsiva-Regular.ttf differ diff --git a/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/fbr-logo-color.png b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/fbr-logo-color.png new file mode 100644 index 000000000..4b8147c14 Binary files /dev/null and b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/fbr-logo-color.png differ diff --git a/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/irs-academy-logo-color.png b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/irs-academy-logo-color.png new file mode 100644 index 000000000..435ae2e66 Binary files /dev/null and b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/irs-academy-logo-color.png differ diff --git a/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/open-sans-latin-400-normal.ttf b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/open-sans-latin-400-normal.ttf new file mode 100644 index 000000000..903aa0d8d Binary files /dev/null and b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/open-sans-latin-400-normal.ttf differ diff --git a/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/open-sans-latin-700-normal.ttf b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/open-sans-latin-700-normal.ttf new file mode 100644 index 000000000..ae5454ece Binary files /dev/null and b/credentials/templates/credentials/programs/fbr-program/program-certificate-assets/open-sans-latin-700-normal.ttf differ diff --git a/credentials/templates/credentials/programs/fbr-program/program-certificate.png b/credentials/templates/credentials/programs/fbr-program/program-certificate.png new file mode 100644 index 000000000..549b7ef39 Binary files /dev/null and b/credentials/templates/credentials/programs/fbr-program/program-certificate.png differ