diff --git a/backend/accounts/admin.py b/backend/accounts/admin.py index 3e0e828..f37522e 100644 --- a/backend/accounts/admin.py +++ b/backend/accounts/admin.py @@ -1,10 +1,13 @@ from django.contrib import admin -from .models import EmailConfirmation +from .models import EmailConfirmation, ExpertAnalysisEmails class EmailConfirmationAdmin(admin.ModelAdmin): list_display= ['email', 'uuid', 'created_at'] +class ExpertAnalysisEmailsAdmin(admin.ModelAdmin): + list_display = ['email', 'created_at'] admin.site.register(EmailConfirmation, EmailConfirmationAdmin) +admin.site.register(ExpertAnalysisEmails, ExpertAnalysisEmailsAdmin) diff --git a/backend/accounts/migrations/0003_expertanalysisemails.py b/backend/accounts/migrations/0003_expertanalysisemails.py new file mode 100644 index 0000000..f938571 --- /dev/null +++ b/backend/accounts/migrations/0003_expertanalysisemails.py @@ -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)), + ], + ), + ] diff --git a/backend/accounts/models.py b/backend/accounts/models.py index 16083b9..6587a69 100644 --- a/backend/accounts/models.py +++ b/backend/accounts/models.py @@ -10,4 +10,11 @@ class EmailConfirmation(models.Model): confirmed = models.BooleanField(default=False) def is_expired(self): - return now() > (self.created_at + timedelta(days=1)) \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/accounts/utils.py b/backend/accounts/utils.py index 849b37f..662bee7 100644 --- a/backend/accounts/utils.py +++ b/backend/accounts/utils.py @@ -43,6 +43,26 @@ def send_payment_email(email): 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): image_io = generate_first_page_image(document) diff --git a/backend/core/admin.py b/backend/core/admin.py index 173aba7..3efe85a 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -1,23 +1,266 @@ from django.contrib import admin 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 .utils import generate_demo_code -from django.urls import path +from .utils import generate_demo_code, get_controls_for_risk, generate_key_findings, generate_recommendations +from .tables import get_risk_table from django.shortcuts import render, redirect 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): - inlines = [DocumentSegmentInline] - list_display = ('organization', 'created_at', 'modified_at') - search_fields = ['organization__name'] - readonly_fields = ('created_at', 'modified_at') + change_form_template = "admin/core/document/change_form.html" + + form = DocumentAdminForm + 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('') + regen_controls_action.short_description = '' + + def regen_keyfindings_action(self, obj): + return format_html('') + regen_keyfindings_action.short_description = '' + + def regen_recommendations_action(self, obj): + return format_html('') + 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('{}', 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): list_display = ['name', 'created_at', 'updated_at', 'preview_button'] diff --git a/backend/core/forms.py b/backend/core/forms.py index 92bf720..977d66c 100644 --- a/backend/core/forms.py +++ b/backend/core/forms.py @@ -10,7 +10,7 @@ class OrganizationForm(forms.ModelForm): 'network_infrastructure', 'remote_workforce_percentage', 'third_party_vendor_access', 'internal_software_development', 'geographic_scope', 'customer_base', 'customer_type', '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 = { 'compliance_frameworks': forms.CheckboxSelectMultiple(), diff --git a/backend/core/migrations/0022_control_subcategory_description_and_more.py b/backend/core/migrations/0022_control_subcategory_description_and_more.py new file mode 100644 index 0000000..583c5c0 --- /dev/null +++ b/backend/core/migrations/0022_control_subcategory_description_and_more.py @@ -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), + ), + ] diff --git a/backend/core/migrations/0023_remove_control_subcategory_description.py b/backend/core/migrations/0023_remove_control_subcategory_description.py new file mode 100644 index 0000000..6729c84 --- /dev/null +++ b/backend/core/migrations/0023_remove_control_subcategory_description.py @@ -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', + ), + ] diff --git a/backend/core/migrations/0024_document_status.py b/backend/core/migrations/0024_document_status.py new file mode 100644 index 0000000..bca6272 --- /dev/null +++ b/backend/core/migrations/0024_document_status.py @@ -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), + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 0568b53..373569f 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -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)?") 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) + expert_analysis = models.BooleanField(null=True, blank=True) def __str__(self): return self.name @@ -105,6 +106,15 @@ class Document(models.Model): organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='documents') created_at = models.DateTimeField(auto_now_add=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") recomendations = models.TextField(blank=True, null=True, help_text="Recommendations") diff --git a/backend/core/templates/admin/core/document/change_form.html b/backend/core/templates/admin/core/document/change_form.html new file mode 100644 index 0000000..3b32ff4 --- /dev/null +++ b/backend/core/templates/admin/core/document/change_form.html @@ -0,0 +1,56 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static admin_list %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block content %} +

Review and Edit Document

+

Use the Regenerate buttons inside each section to update content. When ready, Save and Send.

+ {{ block.super }} +{% endblock %} + +{% block submit_buttons_bottom %} + {{ block.super }} +{% endblock %} + +{% block footer %} + {{ block.super }} + +{% endblock %} diff --git a/backend/core/templates/payment_expert_analysis.html b/backend/core/templates/payment_expert_analysis.html new file mode 100644 index 0000000..b2407c5 --- /dev/null +++ b/backend/core/templates/payment_expert_analysis.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Thank you.

+

+ Your report is awaiting expert analysis. We’ll deliver the final document to {{ email }} once the review is complete. +

+ + Go back to Homepage + +
+
+ +{% endblock content %} + +{% block bottom %} + +{% endblock bottom %} diff --git a/backend/core/templates/signup.html b/backend/core/templates/signup.html index a82d527..5ef5785 100644 --- a/backend/core/templates/signup.html +++ b/backend/core/templates/signup.html @@ -1213,6 +1213,31 @@ + +
+ +
+
+
+ + +
+
+ + +
+
+
diff --git a/backend/core/views.py b/backend/core/views.py index 4f7203c..132f7cb 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -6,8 +6,8 @@ import time from django.shortcuts import render, redirect , get_object_or_404 from .forms import OrganizationForm -from .models import Organization,Document, DocumentTemplate, DemoCode -from backend.accounts.utils import send_confirmation_email, send_document_email +from .models import Organization,Document, DocumentTemplate, DemoCode, DocumentRiskControl, Risk, Control +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 .utils import generate_pdf, generate_risk_graph, generate_residual_risk_graph 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 django.template.loader import render_to_string from django.views.decorators.csrf import csrf_exempt +from backend.accounts.models import ExpertAnalysisEmails # @login_required @@ -134,7 +135,23 @@ def payment_page(request): payment_code.save() document = Document.objects.get(organization = org) 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) + document.status = Document.STATUS_DONE + document.save(update_fields=['status', 'modified_at']) return redirect(url) except DemoCode.DoesNotExist: error = "❌ Invalid code"