Skip to content
Closed
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
70 changes: 70 additions & 0 deletions credentials/apps/credentials/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <strong>program_certificate</strong> for a specific program. "
"Optionally narrow to a specific <strong>organization</strong>. "
"Selection priority: exact cert+org &gt; exact cert. "
"Requires waffle switch <code>credentials.custom_program_certificate_templates</code> to be active."
),
},
),
(
"Template HTML",
{
"fields": ("template",),
"description": (
"Standalone Django template HTML rendered directly via <code>from_string()</code>. "
"All standard Django template tags and filters are available. "
"Use <code>{% load certificate_assets %}{% certificate_asset_url 'slug' %}</code> "
"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 <strong>ProgramCertificateTemplate</strong> with: "
"<code>{% load certificate_assets %}{% certificate_asset_url 'your-slug' %}</code>"
),
},
),
(
"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('<a href="{url}">{url}</a>', url=obj.asset.url)
return "—"

asset_url_preview.short_description = "Asset URL"


@admin.register(RevokeCertificatesConfig)
class RevokeCertificatesConfigAdmin(ConfigurationModelAdmin):
pass
Original file line number Diff line number Diff line change
@@ -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.')],
},
),
]
143 changes: 143 additions & 0 deletions credentials/apps/credentials/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/<filename>."""
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 %}
<img src="{% certificate_asset_url 'fbr-logo' %}">

.. 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.
Expand Down
50 changes: 50 additions & 0 deletions credentials/apps/credentials/templatetags/certificate_assets.py
Original file line number Diff line number Diff line change
@@ -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 #}
<img src="{% certificate_asset_url 'fbr-logo' %}" alt="FBR Logo">

{# CSS file #}
<link rel="stylesheet" href="{% certificate_asset_url 'custom-cert-styles' %}">

{# Font #}
<style>
@font-face {
font-family: 'CustomFont';
src: url("{% certificate_asset_url 'custom-font-regular' %}") format('woff2');
}
</style>
"""

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 ""
20 changes: 20 additions & 0 deletions credentials/apps/credentials/toggles.py
Original file line number Diff line number Diff line change
@@ -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__,
)
Loading
Loading