{{ signatory.name }}
+{{ signatory.title }}
+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/{{ credential_name }}
+{{ signatory.name }}
+{{ signatory.title }}
+