From f243e7384ee2101509a897b376c34031758f4a38 Mon Sep 17 00:00:00 2001
From: noamblitz <43830693+noamblitz@users.noreply.github.com>
Date: Thu, 5 Sep 2024 11:43:52 +0200
Subject: [PATCH] Fix add related, fix manual ooi task list, remove redundant
 octopoes call (#3421)

Co-authored-by: ammar92 <ammar.abdulamir@gmail.com>
Co-authored-by: Jan Klopper <janklopper+underdark@gmail.com>
---
 rocky/rocky/locale/django.pot                 |  6 +-
 .../oois/ooi_detail_origins_observations.html |  6 +-
 .../partials/ooi_detail_related_object.html   |  2 +-
 rocky/rocky/views/mixins.py                   | 22 +++--
 rocky/rocky/views/ooi_detail.py               |  4 +-
 .../rocky/views/ooi_detail_related_object.py  | 97 ++++++++++---------
 rocky/tests/objects/test_objects_detail.py    | 12 +--
 .../objects/test_objects_scan_profile.py      | 10 +-
 8 files changed, 86 insertions(+), 73 deletions(-)

diff --git a/rocky/rocky/locale/django.pot b/rocky/rocky/locale/django.pot
index 67ccc529f3c..8d7603aad8a 100644
--- a/rocky/rocky/locale/django.pot
+++ b/rocky/rocky/locale/django.pot
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-09-04 14:21+0000\n"
+"POT-Creation-Date: 2024-09-05 08:44+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -5271,6 +5271,10 @@ msgstr ""
 msgid "When"
 msgstr ""
 
+#: rocky/templates/oois/ooi_detail_origins_observations.html
+msgid "This scan was manually created."
+msgstr ""
+
 #: rocky/templates/oois/ooi_detail_origins_observations.html
 msgid "The boefje has since been deleted or disabled."
 msgstr ""
diff --git a/rocky/rocky/templates/oois/ooi_detail_origins_observations.html b/rocky/rocky/templates/oois/ooi_detail_origins_observations.html
index c4b4e60cc05..e60225d97d6 100644
--- a/rocky/rocky/templates/oois/ooi_detail_origins_observations.html
+++ b/rocky/rocky/templates/oois/ooi_detail_origins_observations.html
@@ -24,7 +24,11 @@ <h2>{% translate "Last observed by" %}</h2>
                                     <a href="{% url 'boefje_detail' organization_code=organization.code plugin_id=observation.boefje.id %}"
                                        title="{{ observation.boefje.id }}">{{ observation.boefje.name }}</a>
                                 {% else %}
-                                    {% translate "The boefje has since been deleted or disabled." %}
+                                    {% if observation.normalizer.raw_data.boefje_meta.boefje.id == "manual" %}
+                                        {% translate "This scan was manually created." %}
+                                    {% else %}
+                                        {% translate "The boefje has since been deleted or disabled." %}
+                                    {% endif %}
                                 {% endif %}
                             </td>
                             <td>
diff --git a/rocky/rocky/templates/partials/ooi_detail_related_object.html b/rocky/rocky/templates/partials/ooi_detail_related_object.html
index 30ebfcb5467..f70076a4c1e 100644
--- a/rocky/rocky/templates/partials/ooi_detail_related_object.html
+++ b/rocky/rocky/templates/partials/ooi_detail_related_object.html
@@ -12,7 +12,7 @@ <h2>{% translate "Related objects" %}</h2>
     {% if not ooi_past_due %}
         {% if not ooi|is_finding and not ooi|is_finding_type %}
             <div class="horizontal-view {% if related %}toolbar{% endif %}">
-                <a href="{% ooi_url "ooi_add_related" ooi.primary_key organization.code %}&add_ooi_type={{ ooi.get_ooi_type }}"
+                <a href="{% ooi_url "ooi_add_related" ooi.primary_key organization.code %}"
                    class="button ghost">{% translate "Add" %}</a>
             </div>
         {% endif %}
diff --git a/rocky/rocky/views/mixins.py b/rocky/rocky/views/mixins.py
index d8bc37268ed..116ae4cd234 100644
--- a/rocky/rocky/views/mixins.py
+++ b/rocky/rocky/views/mixins.py
@@ -172,16 +172,20 @@ def get_origins(
                 boefje_meta = normalizer_data["raw_data"]["boefje_meta"]
                 boefje_id = boefje_meta["boefje"]["id"]
                 if boefje_meta.get("ended_at"):
-                    boefje_meta["ended_at"] = datetime.strptime(boefje_meta["ended_at"], "%Y-%m-%dT%H:%M:%S.%fZ")
+                    try:
+                        boefje_meta["ended_at"] = datetime.strptime(boefje_meta["ended_at"], "%Y-%m-%dT%H:%M:%S.%fZ")
+                    except ValueError:
+                        boefje_meta["ended_at"] = datetime.strptime(boefje_meta["ended_at"], "%Y-%m-%dT%H:%M:%SZ")
                 origin.normalizer = normalizer_data
-                try:
-                    origin.boefje = katalogus.get_plugin(boefje_id)
-                except HTTPError as e:
-                    logger.error(
-                        "Could not load boefje: %s from katalogus, error: %s",
-                        boefje_id,
-                        e,
-                    )
+                if boefje_id != "manual":
+                    try:
+                        origin.boefje = katalogus.get_plugin(boefje_id)
+                    except HTTPError as e:
+                        logger.error(
+                            "Could not load boefje %s from katalogus: %s",
+                            boefje_id,
+                            e,
+                        )
             observations.append(origin)
 
         return results
diff --git a/rocky/rocky/views/ooi_detail.py b/rocky/rocky/views/ooi_detail.py
index fb086b97d28..16b610a4508 100644
--- a/rocky/rocky/views/ooi_detail.py
+++ b/rocky/rocky/views/ooi_detail.py
@@ -13,14 +13,14 @@
 
 from octopoes.models import Reference
 from octopoes.models.ooi.question import Question
-from rocky.views.ooi_detail_related_object import OOIFindingManager, OOIRelatedObjectAddView
+from rocky.views.ooi_detail_related_object import OOIFindingManager, OOIRelatedObjectManager
 from rocky.views.ooi_view import BaseOOIDetailView
 from rocky.views.tasks import TaskListView
 
 
 class OOIDetailView(
     BaseOOIDetailView,
-    OOIRelatedObjectAddView,
+    OOIRelatedObjectManager,
     OOIFindingManager,
     TaskListView,
 ):
diff --git a/rocky/rocky/views/ooi_detail_related_object.py b/rocky/rocky/views/ooi_detail_related_object.py
index 356c189b9c4..3840f235246 100644
--- a/rocky/rocky/views/ooi_detail_related_object.py
+++ b/rocky/rocky/views/ooi_detail_related_object.py
@@ -3,6 +3,7 @@
 from django.shortcuts import redirect
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
+from django.views.generic.base import TemplateView
 from tools.ooi_helpers import format_attr_name
 from tools.view_helpers import existing_ooi_type, get_mandatory_fields, url_with_querystring
 
@@ -29,54 +30,6 @@ def get_related_objects(self, observed_at):
                     related.append(rel)
         return related
 
-
-class OOIFindingManager(SingleOOITreeMixin):
-    def get_findings(self) -> list[Finding]:
-        findings = []
-        for relation in self.tree.root.children.values():
-            for child in relation:
-                ooi = self.tree.store[str(child.reference)]
-                if isinstance(ooi, Finding) and ooi.reference != self.tree.root.reference:
-                    findings.append(ooi)
-        return findings
-
-    def count_findings_per_severity(self) -> Counter:
-        counter = Counter({severity: 0 for severity in RiskLevelSeverity})
-        for finding in self.get_findings():
-            finding_type: FindingType | None = self.tree.store.get(str(finding.finding_type), None)
-            if finding_type is not None and finding_type.risk_severity is not None:
-                counter.update([finding_type.risk_severity])
-            else:
-                counter.update([RiskLevelSeverity.UNKNOWN])
-        return counter
-
-    def get_finding_details_sorted_by_score_desc(self) -> list[tuple[Finding, FindingType]]:
-        finding_details = self.get_finding_details()
-        return list(sorted(finding_details, key=lambda x: x[1].risk_score or 0, reverse=True))
-
-    def get_finding_details(self) -> list[tuple[Finding, FindingType]]:
-        return [(finding, self.tree.store[str(finding.finding_type)]) for finding in self.get_findings()]
-
-
-class OOIRelatedObjectAddView(OOIRelatedObjectManager):
-    template_name = "oois/ooi_detail_add_related_object.html"
-
-    def get(self, request, *args, **kwargs):
-        if "ooi_id" in request.GET:
-            self.ooi_id = self.get_ooi(pk=request.GET.get("ooi_id"))
-
-        if "add_ooi_type" in request.GET:
-            ooi_type_choice = self.split_ooi_type_choice(request.GET["add_ooi_type"])
-            if existing_ooi_type(ooi_type_choice["ooi_type"]):
-                return redirect(self.ooi_add_url(self.ooi_id, **ooi_type_choice))
-
-        if "status_code" in kwargs:
-            response = super().get(request, *args, **kwargs)
-            response.status_code = kwargs["status_code"]
-            return response
-
-        return super().get(request, *args, **kwargs)
-
     def split_ooi_type_choice(self, ooi_type_choice) -> dict[str, str]:
         ooi_type = ooi_type_choice.split("|", 1)
 
@@ -146,6 +99,54 @@ def get_ooi_types_input_values(self, ooi: OOI) -> list[dict[str, str]]:
 
         return input_values
 
+
+class OOIFindingManager(SingleOOITreeMixin):
+    def get_findings(self) -> list[Finding]:
+        findings = []
+        for relation in self.tree.root.children.values():
+            for child in relation:
+                ooi = self.tree.store[str(child.reference)]
+                if isinstance(ooi, Finding) and ooi.reference != self.tree.root.reference:
+                    findings.append(ooi)
+        return findings
+
+    def count_findings_per_severity(self) -> Counter:
+        counter = Counter({severity: 0 for severity in RiskLevelSeverity})
+        for finding in self.get_findings():
+            finding_type: FindingType | None = self.tree.store.get(str(finding.finding_type), None)
+            if finding_type is not None and finding_type.risk_severity is not None:
+                counter.update([finding_type.risk_severity])
+            else:
+                counter.update([RiskLevelSeverity.UNKNOWN])
+        return counter
+
+    def get_finding_details_sorted_by_score_desc(self) -> list[tuple[Finding, FindingType]]:
+        finding_details = self.get_finding_details()
+        return list(sorted(finding_details, key=lambda x: x[1].risk_score or 0, reverse=True))
+
+    def get_finding_details(self) -> list[tuple[Finding, FindingType]]:
+        return [(finding, self.tree.store[str(finding.finding_type)]) for finding in self.get_findings()]
+
+
+class OOIRelatedObjectAddView(OOIRelatedObjectManager, TemplateView):
+    template_name = "oois/ooi_detail_add_related_object.html"
+
+    def get(self, request, *args, **kwargs):
+        if "ooi_id" in request.GET:
+            self.ooi_id = self.get_ooi(pk=request.GET["ooi_id"])
+
+        if "add_ooi_type" in request.GET:
+            ooi_type_choice = self.split_ooi_type_choice(request.GET["add_ooi_type"])
+            if existing_ooi_type(ooi_type_choice["ooi_type"]):
+                return redirect(self.ooi_add_url(self.ooi_id, **ooi_type_choice))
+
+        if "status_code" in kwargs:
+            response = super().get(request, *args, **kwargs)
+            response.status_code = kwargs["status_code"]
+            return response
+
+        return super().get(request, *args, **kwargs)
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context["ooi_id"] = self.ooi_id
diff --git a/rocky/tests/objects/test_objects_detail.py b/rocky/tests/objects/test_objects_detail.py
index 71d5d4d63a1..4bd7b7653e1 100644
--- a/rocky/tests/objects/test_objects_detail.py
+++ b/rocky/tests/objects/test_objects_detail.py
@@ -71,7 +71,7 @@ def test_ooi_detail(
     response = OOIDetailView.as_view()(request, organization_code=client_member.organization.code)
 
     assert response.status_code == 200
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
     assertContains(response, "Object")
     assertContains(response, "Network|testnetwork")
 
@@ -109,7 +109,7 @@ def test_question_detail(
     response = OOIDetailView.as_view()(request, organization_code=client_member.organization.code)
 
     assert response.status_code == 200
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
 
     assertContains(response, "Question")
     assertContains(response, "Rendered Question Form")
@@ -142,7 +142,7 @@ def test_answer_question(
     response = OOIDetailView.as_view()(request, organization_code=client_member.organization.code)
 
     assertContains(response, "Question has been answered.", status_code=200)
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
 
 
 def test_answer_question_bad_schema(
@@ -217,7 +217,7 @@ def test_ooi_detail_start_scan(
     )
     response = OOIDetailView.as_view()(request, organization_code=client_member.organization.code)
 
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
 
     assert response.status_code == 200
 
@@ -263,7 +263,7 @@ def test_ooi_detail_start_scan_no_indemnification(
     )
     response = OOIDetailView.as_view()(request, organization_code=client_member.organization.code)
 
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
     assertContains(response, "Object details")
     assertContains(response, "Indemnification not present")
 
@@ -295,7 +295,7 @@ def test_ooi_detail_start_scan_no_action(
     )
     response = OOIDetailView.as_view()(request, organization_code=client_member.organization.code)
 
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
     assertContains(response, "Object details")
 
 
diff --git a/rocky/tests/objects/test_objects_scan_profile.py b/rocky/tests/objects/test_objects_scan_profile.py
index 1c2f6fa5aaa..98626ec7407 100644
--- a/rocky/tests/objects/test_objects_scan_profile.py
+++ b/rocky/tests/objects/test_objects_scan_profile.py
@@ -59,7 +59,7 @@ def test_scan_profile(rf, redteam_member, mock_scheduler, mock_organization_view
     response = ScanProfileDetailView.as_view()(request, organization_code=redteam_member.organization.code)
 
     assert response.status_code == 200
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
 
     assertContains(response, "Set clearance level")
 
@@ -125,7 +125,7 @@ def test_scan_profile_no_permissions_acknowledged(
     response = ScanProfileDetailView.as_view()(request, organization_code=redteam_member.organization.code)
 
     assert response.status_code == 200
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
 
     assertNotContains(response, "Set clearance level")
 
@@ -146,7 +146,7 @@ def test_scan_profile_no_permissions_trusted(
     response = ScanProfileDetailView.as_view()(request, organization_code=redteam_member.organization.code)
 
     assert response.status_code == 200
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
 
     assertNotContains(response, "Set clearance level")
 
@@ -162,7 +162,7 @@ def test_scan_profile_reset_view(rf, redteam_member, mock_scheduler, mock_organi
     response = ScanProfileResetView.as_view()(request, organization_code=redteam_member.organization.code)
 
     assert response.status_code == 200
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
 
     assertContains(response, "Set clearance level")
     assertContains(response, "Yes, set to inherit")
@@ -183,5 +183,5 @@ def test_scan_reset_calls_octopoes(rf, redteam_member, mock_scheduler, mock_orga
     response = ScanProfileResetView.as_view()(request, organization_code=redteam_member.organization.code)
 
     assert response.status_code == 302
-    assert mock_organization_view_octopoes().get_tree.call_count == 2
+    assert mock_organization_view_octopoes().get_tree.call_count == 1
     assert mock_organization_view_octopoes().save_scan_profile.call_count == 1