Merge branch '49-august-9-human-in-the-loop' into 'master'

Added expert review/edit document

Closes #49

See merge request kbr4/riskletpy!54
This commit was merged in pull request #103.
This commit is contained in:
2025-08-17 16:52:54 +00:00
14 changed files with 497 additions and 18 deletions

View File

@@ -1,10 +1,13 @@
from django.contrib import admin from django.contrib import admin
from .models import EmailConfirmation from .models import EmailConfirmation, ExpertAnalysisEmails
class EmailConfirmationAdmin(admin.ModelAdmin): class EmailConfirmationAdmin(admin.ModelAdmin):
list_display= ['email', 'uuid', 'created_at'] list_display= ['email', 'uuid', 'created_at']
class ExpertAnalysisEmailsAdmin(admin.ModelAdmin):
list_display = ['email', 'created_at']
admin.site.register(EmailConfirmation, EmailConfirmationAdmin) admin.site.register(EmailConfirmation, EmailConfirmationAdmin)
admin.site.register(ExpertAnalysisEmails, ExpertAnalysisEmailsAdmin)

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.1.3 on 2025-08-17 15:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_emailconfirmation_confirmed'),
]
operations = [
migrations.CreateModel(
name='ExpertAnalysisEmails',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@@ -11,3 +11,10 @@ class EmailConfirmation(models.Model):
def is_expired(self): def is_expired(self):
return now() > (self.created_at + timedelta(days=1)) return now() > (self.created_at + timedelta(days=1))
class ExpertAnalysisEmails(models.Model):
email = models.EmailField(unique=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.email

View File

@@ -43,6 +43,26 @@ def send_payment_email(email):
fail_silently=False, fail_silently=False,
) )
def send_documet_to_expert(email, document):
subject = "Document for Expert Analysis"
document_link = f"{site_domain}{reverse('core:document', args=[document.id])}"
edit_link = f"{site_domain}{reverse('admin:core_document_change', args=[document.id])}"
message = f"""
You have been assigned a document for expert analysis.
Document ID: {document.id}
Document Link: {document_link}
Edit Document: {edit_link}
"""
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[email],
fail_silently=False,
)
def send_document_email(email, document_link, document): def send_document_email(email, document_link, document):
image_io = generate_first_page_image(document) image_io = generate_first_page_image(document)

View File

@@ -1,23 +1,266 @@
from django.contrib import admin from django.contrib import admin
from .models import Document, DocumentSegment, Organization, Risk, Control, DocumentTemplate, DocumentRiskControl, DemoCode from .models import Document, DocumentSegment, Organization, Risk, Control, DocumentTemplate, DocumentRiskControl, DemoCode
from django.urls import reverse from django.urls import reverse, path
from django.utils.html import format_html from django.utils.html import format_html
from .utils import generate_demo_code from .utils import generate_demo_code, get_controls_for_risk, generate_key_findings, generate_recommendations
from django.urls import path from .tables import get_risk_table
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from .forms import GenerateCodesForm from .forms import GenerateCodesForm
from django.conf import settings
from backend.accounts.utils import send_document_email
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
class DocumentRiskControlInline(admin.TabularInline):
model = DocumentRiskControl
extra = 2
max_num = 10
can_delete = False
fields = ('risk', 'control', 'weight', 'likelihood')
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
try:
if request.method == 'POST' and 'organization_risks' in request.POST:
risk_ids = request.POST.getlist('organization_risks')
formset.form.base_fields['risk'].queryset = Risk.objects.filter(pk__in=risk_ids)
elif obj:
formset.form.base_fields['risk'].queryset = obj.organization.risks.all()
except Exception:
pass
return formset
class DocumentAdminForm(forms.ModelForm):
organization_risks = forms.ModelMultipleChoiceField(
queryset=Risk.objects.all(),
required=False,
widget=FilteredSelectMultiple(verbose_name="Risks", is_stacked=False),
help_text="Edit the AI-selected risks for this organization."
)
class Meta:
model = Document
fields = ['organization', 'status', 'key_findings', 'recomendations']
class Media:
css = { 'all': ('admin/css/widgets.css',) }
js = (
'admin/js/SelectBox.js',
'admin/js/SelectFilter2.js',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and getattr(self.instance, 'organization_id', None):
self.fields['organization_risks'].initial = self.instance.organization.risks.all()
class DocumentSegmentInline(admin.StackedInline):
model = DocumentSegment
extra = 1
ordering = ['order']
fields = ('segment_type', 'content', 'order')
class DocumentAdmin(admin.ModelAdmin): class DocumentAdmin(admin.ModelAdmin):
inlines = [DocumentSegmentInline] change_form_template = "admin/core/document/change_form.html"
list_display = ('organization', 'created_at', 'modified_at')
search_fields = ['organization__name'] form = DocumentAdminForm
readonly_fields = ('created_at', 'modified_at') inlines = [DocumentRiskControlInline]
list_display = ('organization', 'status', 'created_at', 'modified_at', 'review_link')
list_filter = ('status', 'created_at')
search_fields = ['organization__name', 'organization__email']
readonly_fields = (
'created_at', 'modified_at',
'regen_controls_action', 'regen_keyfindings_action', 'regen_recommendations_action',
)
fieldsets = (
('Organization & Risks', {
'fields': ('organization', 'organization_risks', 'regen_controls_action')
}),
('Key Findings', {
'fields': ('key_findings', 'regen_keyfindings_action')
}),
('Recommendations', {
'fields': ('recomendations', 'regen_recommendations_action')
}),
('Status', {
'fields': ('status',)
}),
('Timestamps', {
'fields': ('created_at', 'modified_at')
}),
)
def regen_controls_action(self, obj):
return format_html('<button type="submit" name="_regen_controls" class="button">Regenerate Controls using AI</button>')
regen_controls_action.short_description = ''
def regen_keyfindings_action(self, obj):
return format_html('<button type="submit" name="_regen_key_findings" class="button">Regenerate Key Findings using AI</button>')
regen_keyfindings_action.short_description = ''
def regen_recommendations_action(self, obj):
return format_html('<button type="submit" name="_regen_recommendations" class="button">Regenerate Recommendations using AI</button>')
regen_recommendations_action.short_description = ''
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
org_risks = form.cleaned_data.get('organization_risks')
if org_risks is not None and obj.organization_id:
obj.organization.risks.set(org_risks)
def _apply_post_org_risks(self, request, obj):
try:
if 'organization_risks' in request.POST and obj.organization_id:
risk_ids = [int(pk) for pk in request.POST.getlist('organization_risks') if pk]
obj.organization.risks.set(Risk.objects.filter(pk__in=risk_ids))
except Exception:
pass
def _regen_controls(self, obj):
obj.segments.filter(content__startswith="Identified Risks").delete()
obj.segments.filter(content__startswith="Mitigation Controls").delete()
obj.segments.filter(content__in=["Top 10 Risks Identified", "Regenerated Controls"]).delete()
obj.documentriskcontrol_set.all().delete()
top_risks = list(obj.organization.risks.all())
obj.add_segment('h1', "Top 10 Risks Identified")
risk_content = "\n\n".join([
f"Risk: {r.risk_id} - {r.risk_name} \n"
f"Category: {r.category}\n"
f"Primary Impact: {r.primary_impact} \n"
f"Secondary Impact: {r.secondary_impact}\n"
f"Tertiary Impact: {r.tretiary_impact} \n"
f"Detection Difficulty: {r.detection_difficulty} \n"
f"Recovery Complexity: {r.recovery_complexity} \n"
f"Business Impact Severity: {r.businnes_impact_severity}\n"
for r in top_risks
])
obj.add_segment('body', f"Identified Risks: \n\n{risk_content}")
controls_content = "Mitigation Controls:\n\n"
for risk in top_risks:
controls_content += f"Risk: {risk.risk_id} - {risk.risk_name}\n"
selected_controls = get_controls_for_risk(risk, organization=obj.organization)
for control_id, weight, likelihood in selected_controls:
control = Control.objects.filter(id=control_id).first()
if control:
DocumentRiskControl.objects.create(
document=obj,
risk=risk,
control=control,
weight=weight,
likelihood=likelihood
)
label = f"{control.subcategory} - {control.function or ''}".rstrip(" -")
controls_content += f" - Control: {label} (Impact Weight: {weight}/10) (Likelihood: {likelihood}/10)\n"
controls_content += "\n"
obj.add_segment('body', controls_content)
def _regen_key_findings(self, obj):
risks_top3 = get_risk_table(obj)[:3]
key_findings = generate_key_findings(obj, risks_top3)
if key_findings:
obj.key_findings = key_findings
obj.save(update_fields=['key_findings', 'modified_at'])
return True
return False
def _regen_recommendations(self, obj):
risks_top10 = get_risk_table(obj)[:10]
recommendations = generate_recommendations(risks_top10, obj.organization)
if recommendations:
obj.recomendations = recommendations
obj.save(update_fields=['recomendations', 'modified_at'])
return True
return False
def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
if request.method == 'POST' and any(k in request.POST for k in ("_regen_controls", "_regen_key_findings", "_regen_recommendations")):
obj = self.get_object(request, object_id)
if obj is None:
return super().changeform_view(request, object_id, form_url, extra_context)
try:
self._apply_post_org_risks(request, obj)
if "_regen_controls" in request.POST:
self._regen_controls(obj)
self.message_user(request, "Risks and controls regenerated successfully.")
elif "_regen_key_findings" in request.POST:
if self._regen_key_findings(obj):
self.message_user(request, "Key Findings regenerated.")
else:
self.message_user(request, "Key Findings could not be generated.", level='warning')
elif "_regen_recommendations" in request.POST:
if self._regen_recommendations(obj):
self.message_user(request, "Recommendations regenerated.")
else:
self.message_user(request, "Recommendations could not be generated.", level='warning')
except Exception as e:
self.message_user(request, f"Action failed: {e}", level='error')
return redirect(reverse('admin:core_document_change', args=[obj.pk]))
return super().changeform_view(request, object_id, form_url, extra_context)
def review_link(self, obj):
url = reverse('admin:core_document_change', args=[obj.pk])
label = 'Review / Edit'
return format_html('<a class="button" href="{}">{}</a>', url, label)
review_link.short_description = 'Action'
def response_change(self, request, obj):
if "_save_send" in request.POST:
try:
url = f"{settings.SITE_DOMAIN}/pdf/{obj.id}/"
send_document_email(obj.organization.email, url, obj)
obj.status = Document.STATUS_DONE
obj.save(update_fields=['status', 'modified_at'])
self.message_user(request, "Document sent and marked as done.")
except Exception as e:
self.message_user(request, f"Failed to send document: {e}", level='error')
return redirect(reverse('admin:core_document_change', args=[obj.pk]))
return super().response_change(request, obj)
def _refresh_segments_from_current_mappings(self, obj):
obj.segments.filter(content__startswith="Identified Risks").delete()
obj.segments.filter(content__startswith="Mitigation Controls").delete()
obj.segments.filter(content__in=["Top 10 Risks Identified", "Regenerated Controls"]).delete()
top_risks = list(obj.organization.risks.all())
if top_risks:
obj.add_segment('h1', "Top 10 Risks Identified")
risk_content = "\n\n".join([
f"Risk: {r.risk_id} - {r.risk_name} \n"
f"Category: {r.category}\n"
f"Primary Impact: {r.primary_impact} \n"
f"Secondary Impact: {r.secondary_impact}\n"
f"Tertiary Impact: {r.tretiary_impact} \n"
f"Detection Difficulty: {r.detection_difficulty} \n"
f"Recovery Complexity: {r.recovery_complexity} \n"
f"Business Impact Severity: {r.businnes_impact_severity}\n"
for r in top_risks
])
obj.add_segment('body', f"Identified Risks: \n\n{risk_content}")
from collections import defaultdict
controls_by_risk = defaultdict(list)
for drc in obj.documentriskcontrol_set.select_related('risk', 'control').all():
controls_by_risk[drc.risk_id].append(drc)
controls_content = "Mitigation Controls:\n\n"
for risk in top_risks:
controls_content += f"Risk: {risk.risk_id} - {risk.risk_name}\n"
rows = controls_by_risk.get(risk.pk, [])
rows.sort(key=lambda x: (x.control.subcategory or '', x.control.function or ''))
for drc in rows:
control = drc.control
if control:
label = f"{control.subcategory} - {control.function or ''}".rstrip(" -")
controls_content += f" - Control: {label} (Impact Weight: {drc.weight}/10) (Likelihood: {drc.likelihood}/10)\n"
controls_content += "\n"
obj.add_segment('body', controls_content)
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
obj = form.instance
try:
self._refresh_segments_from_current_mappings(obj)
except Exception:
pass
class DocumentTemplateAdmin(admin.ModelAdmin): class DocumentTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'created_at', 'updated_at', 'preview_button'] list_display = ['name', 'created_at', 'updated_at', 'preview_button']

View File

@@ -10,7 +10,7 @@ class OrganizationForm(forms.ModelForm):
'network_infrastructure', 'remote_workforce_percentage', 'third_party_vendor_access', 'network_infrastructure', 'remote_workforce_percentage', 'third_party_vendor_access',
'internal_software_development', 'geographic_scope', 'customer_base', 'customer_type', 'internal_software_development', 'geographic_scope', 'customer_base', 'customer_type',
'product_portfolio', 'supplier_base', 'it_infrastructure', 'intellectual_property', 'product_portfolio', 'supplier_base', 'it_infrastructure', 'intellectual_property',
'sensitive_data','sensitive_data_types', 'integration_level', 'ip_value', 'change_rate', 'threat_actors' 'sensitive_data','sensitive_data_types', 'integration_level', 'ip_value', 'change_rate', 'threat_actors', 'expert_analysis'
] ]
widgets = { widgets = {
'compliance_frameworks': forms.CheckboxSelectMultiple(), 'compliance_frameworks': forms.CheckboxSelectMultiple(),

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.3 on 2025-08-14 21:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_remove_control_subcategory_description'),
]
operations = [
migrations.AddField(
model_name='control',
name='subcategory_description',
field=models.TextField(blank=True, help_text='Subcategory Description', null=True),
),
migrations.AddField(
model_name='organization',
name='expert_analysis',
field=models.BooleanField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.3 on 2025-08-14 21:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0022_control_subcategory_description_and_more'),
]
operations = [
migrations.RemoveField(
model_name='control',
name='subcategory_description',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-08-14 21:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0023_remove_control_subcategory_description'),
]
operations = [
migrations.AddField(
model_name='document',
name='status',
field=models.CharField(choices=[('waiting', 'Waiting'), ('done', 'Done')], default='waiting', max_length=16),
),
]

View File

@@ -71,6 +71,7 @@ class Organization(models.Model):
threat_actors = models.JSONField(null=True, blank=True, help_text="Which types of threat actors are most relevant to your organization (e.g., cybercriminals, insiders, nation-states)?") threat_actors = models.JSONField(null=True, blank=True, help_text="Which types of threat actors are most relevant to your organization (e.g., cybercriminals, insiders, nation-states)?")
sensitive_data_types = models.JSONField(null=True, blank=True, help_text="What type of sensitive data does your organization handle?") sensitive_data_types = models.JSONField(null=True, blank=True, help_text="What type of sensitive data does your organization handle?")
risks = models.ManyToManyField('Risk', related_name='organizations', blank=True) risks = models.ManyToManyField('Risk', related_name='organizations', blank=True)
expert_analysis = models.BooleanField(null=True, blank=True)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -105,6 +106,15 @@ class Document(models.Model):
organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='documents') organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='documents')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True) modified_at = models.DateTimeField(auto_now=True)
STATUS_WAITING = 'waiting'
STATUS_DONE = 'done'
STATUS_CHOICES = (
(STATUS_WAITING, 'Waiting'),
(STATUS_DONE, 'Done'),
)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_WAITING)
key_findings = models.TextField(blank=True, null=True, help_text="Key findings") key_findings = models.TextField(blank=True, null=True, help_text="Key findings")
recomendations = models.TextField(blank=True, null=True, help_text="Recommendations") recomendations = models.TextField(blank=True, null=True, help_text="Recommendations")

View File

@@ -0,0 +1,56 @@
{% extends "admin/change_form.html" %}
{% load i18n admin_urls static admin_list %}
{% block extrastyle %}
{{ block.super }}
<style>
/* Keep page within viewport and allow horizontal scroll for wide tables */
.change-form { max-width: 100%; overflow-x: hidden; }
.inline-group { overflow-x: auto; }
.inline-group table { table-layout: fixed; width: 100%; }
.inline-group table th, .inline-group table td { white-space: nowrap; }
/* Make textareas fill width */
textarea { width: 100% !important; min-height: 120px; }
/* Adjust numeric inputs smaller */
input[type=number] { width: 90px; }
/* Make select boxes reasonable width */
.related-widget-wrapper select, select { min-width: 260px; }
.inline-action { margin: 8px 0 16px; }
/* Green Save & Send button */
.btn-save-send { background-color: #28a745 !important; border-color: #28a745 !important; color: #fff !important; }
.btn-save-send:hover, .btn-save-send:focus { background-color: #218838 !important; border-color: #1e7e34 !important; color: #fff !important; }
/* Hide labels (and colons) for regen action rows */
.form-row.field-regen_controls_action label,
.form-row.field-regen_keyfindings_action label,
.form-row.field-regen_recommendations_action label { display: none; }
</style>
{% endblock %}
{% block content %}
<h2 style="margin-top:0">Review and Edit Document</h2>
<p>Use the Regenerate buttons inside each section to update content. When ready, Save and Send.</p>
{{ block.super }}
{% endblock %}
{% block submit_buttons_bottom %}
{{ block.super }}
{% endblock %}
{% block footer %}
{{ block.super }}
<script>
(function() {
function addSaveSendButton() {
document.querySelectorAll('.submit-row').forEach(function(row){
var saveBtn = row.querySelector('input[name="_save"]');
if (saveBtn && !row.querySelector('button[name="_save_send"]')) {
saveBtn.insertAdjacentHTML('afterend', '\n<button type="submit" name="_save_send" class="button btn-save-send">Save and Send</button>');
}
});
}
addSaveSendButton();
document.addEventListener('formset:added', addSaveSendButton);
document.addEventListener('formset:removed', addSaveSendButton);
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<section class="py-16 bg-secondary sm:py-24 p-body-full">
<div class="max-w-lg w-full mx-auto text-center shadow-lg border border-success rounded-xl p-8 bg-white">
<h2 class="text-3xl font-extrabold mb-4 text-success">Thank you.</h2>
<p class="mb-8 text-gray-700 text-lg">
Your report is awaiting expert analysis. Well deliver the final document to {{ email }} once the review is complete.
</p>
<a href="{% url 'core:index' %}" class="bg-transparent border-2 border-accent text-accent hover:bg-accent hover:text-primary font-semibold py-3 px-8 rounded-lg text-lg transition-all duration-300 ease-in-out">
Go back to Homepage
</a>
</div>
</section>
{% endblock content %}
{% block bottom %}
<script src="/static/js/formHandling.js"></script>
{% endblock bottom %}

View File

@@ -1213,6 +1213,31 @@
</small> </small>
</div> </div>
<!-- Expert Analysis -->
<div class="mb-3 question basic-section" id="q25">
<label class="form-label mt-3">
Do u want to perform an expert analysis of your cybersecurity posture?
<br>
<small class="form-text text-muted">
This will help identify gaps and provide tailored recommendations.
</small>
</label>
<hr>
<div class="pb-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="expert_analysis" id="expert-analysis-yes" value="true">
<label class="form-check-label" for="expert-analysis-yes">
<i class="fa-solid fa-magnifying-glass"></i> Yes, I want an expert analysis
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="expert_analysis" id="expert-analysis-no" value="false">
<label class="form-check-label" for="expert-analysis-no">
<i class="fa-solid fa-circle-xmark"></i> No, I don't need an expert analysis
</label>
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-4"> <div class="d-flex justify-content-end mt-4">
<button type="button" class="btn btn-lg btn-outline-secondary me-3" id="back">Back</button> <button type="button" class="btn btn-lg btn-outline-secondary me-3" id="back">Back</button>

View File

@@ -6,8 +6,8 @@ import time
from django.shortcuts import render, redirect , get_object_or_404 from django.shortcuts import render, redirect , get_object_or_404
from .forms import OrganizationForm from .forms import OrganizationForm
from .models import Organization,Document, DocumentTemplate, DemoCode from .models import Organization,Document, DocumentTemplate, DemoCode, DocumentRiskControl, Risk, Control
from backend.accounts.utils import send_confirmation_email, send_document_email from backend.accounts.utils import send_confirmation_email, send_document_email, send_documet_to_expert
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from .utils import generate_pdf, generate_risk_graph, generate_residual_risk_graph from .utils import generate_pdf, generate_risk_graph, generate_residual_risk_graph
from .tables import risk_matrix_table ,get_risk_table, get_safeguard_summary_table from .tables import risk_matrix_table ,get_risk_table, get_safeguard_summary_table
@@ -20,6 +20,7 @@ from django.utils import timezone
from weasyprint import HTML from weasyprint import HTML
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from backend.accounts.models import ExpertAnalysisEmails
# @login_required # @login_required
@@ -134,7 +135,23 @@ def payment_page(request):
payment_code.save() payment_code.save()
document = Document.objects.get(organization = org) document = Document.objects.get(organization = org)
url = f"{site_domain}/pdf/{document.id}/" url = f"{site_domain}/pdf/{document.id}/"
if org.expert_analysis:
document.status = Document.STATUS_WAITING
document.save(update_fields=['status', 'modified_at'])
expert_emails_qs = ExpertAnalysisEmails.objects.values_list('email', flat=True).distinct()
expert_emails = [e for e in expert_emails_qs if e]
if expert_emails:
for email_addr in expert_emails:
try:
send_documet_to_expert(email_addr, document)
except Exception:
logger.exception("Failed to send expert analysis email to %s", email_addr)
else:
logger.info("No expert emails configured; skipping expert notifications.")
return render(request, 'payment_expert_analysis.html', {'email': email, 'document': document})
send_document_email(email, url, document) send_document_email(email, url, document)
document.status = Document.STATUS_DONE
document.save(update_fields=['status', 'modified_at'])
return redirect(url) return redirect(url)
except DemoCode.DoesNotExist: except DemoCode.DoesNotExist:
error = "❌ Invalid code" error = "❌ Invalid code"