'
+ '🧹 Warning: this will clear the current document (segments, mapped risks/controls, key findings, and recommendations) and regenerate everything as if the document was newly created.'
+ '
'
+ ' '
+ ''
+ )
+ regen_document_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')
@@ -111,27 +147,79 @@ class DocumentAdmin(admin.ModelAdmin):
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
+ logger.exception("Failed to apply posted organization_risks")
+
+ def _clear_segments(self, obj, startswith=None, exact=None):
+ try:
+ if startswith:
+ for s in startswith:
+ obj.segments.filter(content__startswith=s).delete()
+ if exact:
+ obj.segments.filter(content__in=exact).delete()
+ except Exception:
+ logger.exception("Failed to clear segments for document %s", getattr(obj, 'pk', None))
+
+ def _risk_content(self, risks):
+ return "\n\n".join([
+ f"Risk: {risk.risk_id} - {risk.risk_name} \n"
+ f"Category: {risk.category}\n"
+ f"Primary Impact: {risk.primary_impact} \n"
+ f"Secondary Impact: {risk.secondary_impact}\n"
+ f"Tertiary Impact: {risk.tretiary_impact} \n"
+ f"Detection Difficulty: {risk.detection_difficulty} \n"
+ f"Recovery Complexity: {risk.recovery_complexity} \n"
+ f"Business Impact Severity: {risk.businnes_impact_severity}\n"
+ for risk in risks
+ ])
+
+ def _add_identified_risks(self, obj, risks):
+ if not risks:
+ return
+ self._clear_segments(obj, startswith=["Identified Risks"], exact=["Top 10 Risks Identified"])
+ obj.add_segment('h1', "Top 10 Risks Identified")
+ obj.add_segment('body', f"Identified Risks: \n\n{self._risk_content(risks)}")
+
+ def _clear_document_mappings(self, obj, clear_org_risks=True):
+ try:
+ obj.segments.all().delete()
+ obj.documentriskcontrol_set.all().delete()
+ obj.key_findings = ''
+ obj.recomendations = ''
+ obj.status = Document.STATUS_WAITING
+ obj.save(update_fields=['key_findings', 'recomendations', 'status', 'modified_at'])
+ if clear_org_risks and getattr(obj, 'organization', None):
+ obj.organization.risks.clear()
+ except Exception:
+ logger.exception("Failed to clear document mappings for document %s", getattr(obj, 'pk', None))
+
+ def _regen_pipeline(self, obj):
+ ok = True
+ if not self._regen_top_risks(obj):
+ ok = False
+ else:
+ try:
+ self._regen_controls(obj)
+ except Exception:
+ logger.exception("_regen_controls failed")
+ ok = False
+ if not self._regen_key_findings(obj):
+ ok = False
+ if not self._regen_recommendations(obj):
+ ok = False
+ return ok
+
+ def _regen_top_risks(self, obj):
+ top_risk_ids = get_top_risk(obj.organization)
+ top_risks = Risk.objects.filter(risk_id__in=top_risk_ids)
+ obj.organization.risks.set(top_risks)
+ self._add_identified_risks(obj, top_risks)
+ return True
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()
+ self._clear_segments(obj, startswith=["Identified Risks", "Mitigation Controls"], exact=["Top 10 Risks Identified", "Regenerated Controls"])
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}")
+ self._add_identified_risks(obj, top_risks)
controls_content = "Mitigation Controls:\n\n"
for risk in top_risks:
controls_content += f"Risk: {risk.risk_id} - {risk.risk_name}\n"
@@ -170,27 +258,55 @@ class DocumentAdmin(admin.ModelAdmin):
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")):
+ if request.method == 'POST' and any(k in request.POST for k in ("_regen_controls", "_regen_key_findings", "_regen_recommendations", "_regen_top_risks", "_regen_document")):
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:
+ if "_regen_top_risks" in request.POST:
+ if not obj.organization_id:
+ self.message_user(request, "Please select an organization first.", level=messages.WARNING)
+ else:
+ if self._regen_top_risks(obj):
+ self.message_user(request, "Top risks regenerated and risk segment updated.")
+ else:
+ self.message_user(request, "Top risks could not be generated.", level=messages.WARNING)
+ elif "_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')
+ self.message_user(request, "Key Findings could not be generated.", level=messages.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')
+ self.message_user(request, "Recommendations could not be generated.", level=messages.WARNING)
+ elif "_regen_document" in request.POST:
+ if not obj.organization_id:
+ self.message_user(request, "Please select an organization first.", level=messages.WARNING)
+ else:
+ try:
+ with transaction.atomic():
+ self._clear_document_mappings(obj, clear_org_risks=True)
+ regen_ok = self._regen_pipeline(obj)
+ try:
+ self.log_change(request, obj, "Full document regeneration triggered")
+ except Exception:
+ logger.exception("Failed to log admin change for full regen")
+ if regen_ok:
+ self.message_user(request, "Document fully regenerated.")
+ else:
+ self.message_user(request, "Document regeneration finished with warnings or missing outputs.", level=messages.WARNING)
+ except Exception as e:
+ logger.exception("Full regeneration failed")
+ self.message_user(request, f"Full regeneration failed: {e}", level=messages.ERROR)
except Exception as e:
- self.message_user(request, f"Action failed: {e}", level='error')
+ logger.exception("changeform_view action failed")
+ self.message_user(request, f"Action failed: {e}", level=messages.ERROR)
return redirect(reverse('admin:core_document_change', args=[obj.pk]))
return super().changeform_view(request, object_id, form_url, extra_context)
@@ -209,32 +325,17 @@ class DocumentAdmin(admin.ModelAdmin):
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')
+ logger.exception("Failed to send document email")
+ self.message_user(request, f"Failed to send document: {e}", level=messages.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()
+ self._clear_segments(obj, startswith=["Identified Risks", "Mitigation Controls"], exact=["Top 10 Risks Identified", "Regenerated Controls"])
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}")
+ self._add_identified_risks(obj, top_risks)
from collections import defaultdict
controls_by_risk = defaultdict(list)
@@ -260,7 +361,7 @@ class DocumentAdmin(admin.ModelAdmin):
try:
self._refresh_segments_from_current_mappings(obj)
except Exception:
- pass
+ logger.exception("Failed to refresh segments from current mappings for document %s", getattr(obj, 'pk', None))
class DocumentTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'created_at', 'updated_at', 'preview_button']
diff --git a/backend/core/migrations/0025_alter_document_status.py b/backend/core/migrations/0025_alter_document_status.py
new file mode 100644
index 0000000..fcabf88
--- /dev/null
+++ b/backend/core/migrations/0025_alter_document_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.3 on 2025-08-26 14:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0024_document_status'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='document',
+ name='status',
+ field=models.CharField(choices=[('waiting', 'Waiting'), ('done', 'Done'), ('incomplete', 'Incomplete')], default='waiting', max_length=16),
+ ),
+ ]
diff --git a/backend/core/models.py b/backend/core/models.py
index 373569f..e80b937 100644
--- a/backend/core/models.py
+++ b/backend/core/models.py
@@ -109,9 +109,11 @@ class Document(models.Model):
STATUS_WAITING = 'waiting'
STATUS_DONE = 'done'
+ STATUS_INCOMPLETE = 'incomplete'
STATUS_CHOICES = (
(STATUS_WAITING, 'Waiting'),
(STATUS_DONE, 'Done'),
+ (STATUS_INCOMPLETE, 'Incomplete'),
)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_WAITING)
diff --git a/backend/core/templates/admin/core/document/change_form.html b/backend/core/templates/admin/core/document/change_form.html
index 3b32ff4..c41fcdd 100644
--- a/backend/core/templates/admin/core/document/change_form.html
+++ b/backend/core/templates/admin/core/document/change_form.html
@@ -22,7 +22,23 @@
/* 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_document_action label,
+ .form-row.field-regen_note_action label,
.form-row.field-regen_recommendations_action label { display: none; }
+ .form-row.field-regen_top_risks_action label { display: none; }
+ .form-row.field-regen_controls_action { display: none !important; }
+
+ .ai-callout {
+ margin: 8px 0 0;
+ padding: 10px 12px;
+ border-left: 4px solid #2f80ed;
+ border-radius: 4px;
+ background: #e8f4ff;
+ color: #0c3b66;
+ box-shadow: 0 1px 0 rgba(0,0,0,0.04), 0 0 0 1px rgba(13, 110, 253, 0.06) inset;
+ font-size: 13px;
+ line-height: 1.35;
+ }
{% endblock %}
@@ -31,6 +47,19 @@
Use the Regenerate buttons inside each section to update content. When ready, Save and Send.
{{ block.super }}
{% endblock %}
+{% block field_sets %}
+ {% for fieldset in adminform %}
+ {% include "admin/includes/fieldset.html" %}
+ {% if forloop.first %}
+ {% for inline_admin_formset in inline_admin_formsets %}
+ {% include inline_admin_formset.opts.template %}
+ {% endfor %}
+ {% endif %}
+ {% endfor %}
+{% endblock %}
+
+{% block inline_field_sets %}{% endblock %}
+
{% block submit_buttons_bottom %}
{{ block.super }}
@@ -48,7 +77,36 @@
}
});
}
+
+ function addRegenControlsInsideInline() {
+ var inlineGroup = document.getElementById('documentriskcontrol_set-group');
+ if (!inlineGroup) {
+ document.querySelectorAll('.inline-group').forEach(function(g){
+ if (inlineGroup) return;
+ var h2 = g.querySelector('h2');
+ if (h2 && /document risk controls/i.test(h2.textContent)) {
+ inlineGroup = g;
+ }
+ });
+ }
+ if (!inlineGroup) return;
+
+ if (inlineGroup.querySelector('button[name="_regen_controls"]')) return;
+
+ var h2 = inlineGroup.querySelector('h2') || inlineGroup.firstElementChild;
+ (h2 || inlineGroup).insertAdjacentHTML('afterend',
+ '
' +
+ '
' +
+ '💡 Recommended: after regenerating controls, also update Key Findings and Recommendations.' +
+ '