diff --git a/octopoes/octopoes/models/ooi/reports.py b/octopoes/octopoes/models/ooi/reports.py
index 462dd0bb02d..e3af38fe23e 100644
--- a/octopoes/octopoes/models/ooi/reports.py
+++ b/octopoes/octopoes/models/ooi/reports.py
@@ -55,7 +55,7 @@ class ReportRecipe(OOI):
recipe_id: UUID
report_name_format: str
- subreport_name_format: str
+ subreport_name_format: str | None = None
input_recipe: dict[str, Any] # can contain a query which maintains a live set of OOIs or manually picked OOIs.
report_types: list[str]
diff --git a/rocky/reports/templates/report_overview/scheduled_reports_table.html b/rocky/reports/templates/report_overview/scheduled_reports_table.html
index 5b549066f9d..ced610163e0 100644
--- a/rocky/reports/templates/report_overview/scheduled_reports_table.html
+++ b/rocky/reports/templates/report_overview/scheduled_reports_table.html
@@ -37,22 +37,18 @@
{{ schedule.deadline_at }} |
{{ schedule.cron }} |
- {% if schedule.reports %}
-
-
- |
- {% else %}
- |
- {% endif %}
+
+
+ |
- {% if schedule.reports %}
-
-
+ |
+
+ {% if schedule.reports %}
{% translate "Scheduled Reports:" %}
@@ -83,9 +79,13 @@
{% endfor %}
- |
-
- {% endif %}
+ {% endif %}
+
+
+
{% endif %}
{% endfor %}
diff --git a/rocky/reports/views/report_overview.py b/rocky/reports/views/report_overview.py
index 229f3efb317..46171a1ffa2 100644
--- a/rocky/reports/views/report_overview.py
+++ b/rocky/reports/views/report_overview.py
@@ -56,22 +56,24 @@ def get_queryset(self) -> list[dict[str, Any]]:
recipes = []
if report_schedules:
for schedule in report_schedules:
- recipe_id = schedule["data"]["report_recipe_id"]
- # TODO: This is a workaround to get the recipes and reports.
- # We should create an endpoint for this in octopoes
- recipe_ooi_tree = self.get_recipe_ooi_tree(recipe_id)
- if recipe_ooi_tree is not None:
- recipe_tree = recipe_ooi_tree.store.values()
- recipe_ooi = [ooi for ooi in recipe_tree if isinstance(ooi, ReportRecipe)][0]
- report_oois = [ooi for ooi in recipe_tree if isinstance(ooi, Report)]
- recipes.append(
- {
- "recipe": recipe_ooi,
- "cron": schedule["schedule"],
- "deadline_at": datetime.fromisoformat(schedule["deadline_at"]),
- "reports": report_oois,
- }
- )
+ if schedule["data"]:
+ recipe_id = schedule["data"]["report_recipe_id"]
+ # TODO: This is a workaround to get the recipes and reports.
+ # We should create an endpoint for this in octopoes
+ recipe_ooi_tree = self.get_recipe_ooi_tree(recipe_id)
+ if recipe_ooi_tree is not None:
+ recipe_tree = recipe_ooi_tree.store.values()
+ recipe_ooi = next(ooi for ooi in recipe_tree if isinstance(ooi, ReportRecipe))
+ report_oois = [ooi for ooi in recipe_tree if isinstance(ooi, Report)]
+ recipes.append(
+ {
+ "schedule_id": schedule["id"],
+ "recipe": recipe_ooi,
+ "cron": schedule["schedule"],
+ "deadline_at": datetime.fromisoformat(schedule["deadline_at"]),
+ "reports": report_oois,
+ }
+ )
return recipes
diff --git a/rocky/rocky/locale/django.pot b/rocky/rocky/locale/django.pot
index 41c7d24c5d9..05725644468 100644
--- a/rocky/rocky/locale/django.pot
+++ b/rocky/rocky/locale/django.pot
@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-10-21 16:21+0000\n"
+"POT-Creation-Date: 2024-10-28 12:21+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -4186,6 +4186,10 @@ msgstr ""
msgid "Show report details"
msgstr ""
+#: reports/templates/report_overview/scheduled_reports_table.html
+msgid "Edit report recipe"
+msgstr ""
+
#: reports/templates/report_overview/scheduled_reports_table.html
msgid "No scheduled reports have been generated yet."
msgstr ""
diff --git a/rocky/rocky/scheduler.py b/rocky/rocky/scheduler.py
index e417b8fa696..d94f28cb189 100644
--- a/rocky/rocky/scheduler.py
+++ b/rocky/rocky/scheduler.py
@@ -287,6 +287,21 @@ def get_schedule_details(self, schedule_id: str) -> ScheduleResponse:
except ConnectError:
raise SchedulerConnectError()
+ def post_schedule_search(self, filters: dict[str, str]) -> PaginatedSchedulesResponse:
+ try:
+ res = self._client.post("/schedules/search", json=filters)
+ res.raise_for_status()
+ return PaginatedSchedulesResponse.model_validate_json(res.content)
+ except ConnectError:
+ raise SchedulerConnectError()
+
+ def patch_schedule(self, schedule_id: str, params: dict[str, Any]) -> None:
+ try:
+ response = self._client.patch(f"/schedules/{schedule_id}", json=params)
+ response.raise_for_status()
+ except (HTTPStatusError, ConnectError):
+ raise SchedulerHTTPError()
+
def post_schedule(self, schedule: ScheduleRequest) -> ScheduleResponse:
try:
res = self._client.post("/schedules", json=schedule.model_dump(exclude_none=True))
diff --git a/rocky/rocky/views/ooi_edit.py b/rocky/rocky/views/ooi_edit.py
index 33e4ee3dc31..8d0535cc7f4 100644
--- a/rocky/rocky/views/ooi_edit.py
+++ b/rocky/rocky/views/ooi_edit.py
@@ -1,13 +1,16 @@
+from datetime import datetime, timezone
from enum import Enum
from django.utils.translation import gettext_lazy as _
from tools.view_helpers import get_ooi_url
from rocky.views.ooi_view import BaseOOIFormView
+from rocky.views.scheduler import SchedulerView
-class OOIEditView(BaseOOIFormView):
+class OOIEditView(BaseOOIFormView, SchedulerView):
template_name = "oois/ooi_edit.html"
+ task_type = "report"
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
@@ -36,6 +39,25 @@ def get_form_kwargs(self):
return kwargs
+ def form_valid(self, form):
+ form_data = form.cleaned_data
+ report_recipe_id = form_data.get("recipe_id")
+ cron_expression = form_data.get("cron_expression")
+
+ # If the cron_expression of the ReportRecipe is changed, the scheduler must also be updated
+ if report_recipe_id and cron_expression:
+ deadline_at = datetime.now(timezone.utc).isoformat()
+ filters = {
+ "filters": [
+ {"column": "data", "field": "report_recipe_id", "operator": "eq", "value": report_recipe_id}
+ ]
+ }
+
+ schedule_id = str(self.get_schedule_with_filters(filters).id)
+ self.edit_report_schedule(schedule_id, {"schedule": cron_expression, "deadline_at": deadline_at})
+
+ return super().form_valid(form)
+
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
diff --git a/rocky/rocky/views/scheduler.py b/rocky/rocky/views/scheduler.py
index cdb47b5ae51..25081cc3be9 100644
--- a/rocky/rocky/views/scheduler.py
+++ b/rocky/rocky/views/scheduler.py
@@ -139,6 +139,9 @@ def create_report_schedule(self, report_recipe: ReportRecipe, deadline_at: str)
except SchedulerError as error:
return messages.error(self.request, error.message)
+ def edit_report_schedule(self, schedule_id: str, params):
+ self.scheduler_client.patch_schedule(schedule_id=schedule_id, params=params)
+
def get_report_schedules(self) -> list[dict[str, Any]]:
try:
return self.scheduler_client.get_scheduled_reports(scheduler_id=self.scheduler_id)
@@ -180,6 +183,12 @@ def get_json_task_details(self) -> JsonResponse | None:
except SchedulerError as error:
return messages.error(self.request, error.message)
+ def get_schedule_with_filters(self, filters: dict[str, Any]) -> ScheduleResponse:
+ try:
+ return self.scheduler_client.post_schedule_search(filters).results[0]
+ except SchedulerError as error:
+ return messages.error(self.request, error.message)
+
def schedule_task(self, task: Task) -> None:
if not self.indemnification_present:
return self.indemnification_error()
diff --git a/rocky/tests/conftest.py b/rocky/tests/conftest.py
index dd3d3a0c68c..9edf54ac91f 100644
--- a/rocky/tests/conftest.py
+++ b/rocky/tests/conftest.py
@@ -32,7 +32,7 @@
from octopoes.models.ooi.dns.zone import Hostname
from octopoes.models.ooi.findings import CVEFindingType, Finding, KATFindingType, RiskLevelSeverity
from octopoes.models.ooi.network import IPAddressV4, IPAddressV6, IPPort, Network, Protocol
-from octopoes.models.ooi.reports import Report
+from octopoes.models.ooi.reports import Report, ReportRecipe
from octopoes.models.ooi.service import IPService, Service
from octopoes.models.ooi.software import Software
from octopoes.models.ooi.web import URL, SecurityTXT, Website
@@ -1310,6 +1310,105 @@ def get_subreports() -> list[tuple[str, Report]]:
]
+@pytest.fixture
+def report_recipe():
+ return ReportRecipe(
+ recipe_id="744d054e-9c70-4f18-ad27-122cfc1b7903",
+ report_name_format="Test Report Name Format",
+ subreport_name_format="Test Subreport Name Format",
+ input_recipe={"input_oois": ["Hostname|internet|mispo.es"]},
+ report_types=["dns-report"],
+ cron_expression="0 0 * * *",
+ )
+
+
+@pytest.fixture
+def get_report_schedules():
+ report_schedule = [
+ {
+ "id": "06a783b3-af62-429d-8b48-5934c8702366",
+ "scheduler_id": "report-madelon123",
+ "hash": "f429f60fda539f9017544758163a955e",
+ "data": {
+ "type": "report",
+ "organisation_id": "madelon123",
+ "report_recipe_id": "6a121a53-f795-4bfe-9aea-f410a96fca59",
+ },
+ "enabled": "true",
+ "schedule": "0 * * * *",
+ "tasks": [
+ {
+ "id": "21ce00b8-57c4-4d65-a53e-275b4e44da6f",
+ "scheduler_id": "report-madelon123",
+ "schedule_id": "06a783b3-af62-429d-8b48-5934c8702366",
+ "priority": 1729520184,
+ "status": "completed",
+ "type": "report",
+ "hash": "f429f60fda539f9017544758163a955e",
+ "data": {
+ "organisation_id": "madelon123",
+ "report_recipe_id": "6a121a53-f795-4bfe-9aea-f410a96fca59",
+ },
+ "created_at": "2024-10-21T14:16:24.702759Z",
+ "modified_at": "2024-10-21T14:16:24.702761Z",
+ },
+ {
+ "id": "f63c2224-a4b2-4161-9790-9b4d665a4683",
+ "scheduler_id": "report-madelon123",
+ "schedule_id": "06a783b3-af62-429d-8b48-5934c8702366",
+ "priority": 1729580020,
+ "status": "completed",
+ "type": "report",
+ "hash": "f429f60fda539f9017544758163a955e",
+ "data": {
+ "organisation_id": "madelon123",
+ "report_recipe_id": "6a121a53-f795-4bfe-9aea-f410a96fca59",
+ },
+ "created_at": "2024-10-22T06:53:40.405185Z",
+ "modified_at": "2024-10-22T06:53:40.405192Z",
+ },
+ ],
+ "deadline_at": "2024-10-22T12:00:00Z",
+ "created_at": "2024-10-21T14:14:00.359039Z",
+ "modified_at": "2024-10-21T14:14:00.359043Z",
+ },
+ {
+ "id": "e9a00bc1-850a-401a-89d9-252d98823bb3",
+ "scheduler_id": "report-madelon123",
+ "hash": "02e67c8676cb8135681d52ca62a9fe5b",
+ "data": {
+ "type": "report",
+ "organisation_id": "madelon123",
+ "report_recipe_id": "31c79490-fb51-440d-9108-0c388276f655",
+ },
+ "enabled": "true",
+ "schedule": "0 0 * * *",
+ "tasks": [
+ {
+ "id": "f4f938e0-5f2d-4bcd-9b28-11831b7835e4",
+ "scheduler_id": "report-madelon123",
+ "schedule_id": "e9a00bc1-850a-401a-89d9-252d98823bb3",
+ "priority": 1729521145,
+ "status": "completed",
+ "type": "report",
+ "hash": "02e67c8676cb8135681d52ca62a9fe5b",
+ "data": {
+ "organisation_id": "madelon123",
+ "report_recipe_id": "31c79490-fb51-440d-9108-0c388276f655",
+ },
+ "created_at": "2024-10-21T14:32:25.247525Z",
+ "modified_at": "2024-10-21T14:32:25.247527Z",
+ }
+ ],
+ "deadline_at": "2024-10-23T00:00:00Z",
+ "created_at": "2024-10-21T13:34:42.791561Z",
+ "modified_at": "2024-10-21T13:34:42.791563Z",
+ },
+ ]
+
+ return report_schedule
+
+
def setup_request(request, user):
request = SessionMiddleware(lambda r: r)(request)
request.session[DEVICE_ID_SESSION_KEY] = user.staticdevice_set.get().persistent_id
diff --git a/rocky/tests/objects/test_objects_edit.py b/rocky/tests/objects/test_objects_edit.py
index 88c81245a0b..dab3f83a6c4 100644
--- a/rocky/tests/objects/test_objects_edit.py
+++ b/rocky/tests/objects/test_objects_edit.py
@@ -1,3 +1,6 @@
+import json
+
+from django.urls import reverse
from pytest_django.asserts import assertContains
from rocky.views.ooi_edit import OOIEditView
@@ -13,3 +16,49 @@ def test_ooi_edit(rf, client_member, mock_organization_view_octopoes, network):
assert response.status_code == 200
assertContains(response, "testnetwork")
assertContains(response, "Save Network")
+
+
+def test_ooi_edit_report_recipe_get(rf, client_member, mock_organization_view_octopoes, report_recipe):
+ mock_organization_view_octopoes().get.return_value = report_recipe
+ ooi_id = f"ReportRecipe|{report_recipe.recipe_id}"
+
+ request = setup_request(rf.get("ooi_edit", {"ooi_id": ooi_id}), client_member.user)
+ response = OOIEditView.as_view()(request, organization_code=client_member.organization.code)
+
+ assert response.status_code == 200
+ assertContains(response, "Edit ReportRecipe: " + ooi_id)
+
+
+def test_ooi_edit_report_recipe_post(
+ rf, client_member, mock_organization_view_octopoes, report_recipe, mocker, mock_scheduler
+):
+ mock_organization_view_octopoes().get.return_value = report_recipe
+ mocker.patch("rocky.views.ooi_view.create_ooi")
+ ooi_id = f"ReportRecipe|{report_recipe.recipe_id}"
+
+ request_url = (
+ reverse("ooi_edit", kwargs={"organization_code": client_member.organization.code}) + f"?ooi_id={ooi_id}"
+ )
+
+ request = setup_request(
+ rf.post(
+ request_url,
+ {
+ "ooi_type": "ReportRecipe",
+ "user": client_member.user.email,
+ "recipe_id": report_recipe.recipe_id,
+ "report_name_format": report_recipe.report_name_format,
+ "subreport_name_format": report_recipe.subreport_name_format,
+ "input_recipe": json.dumps(report_recipe.input_recipe),
+ "report_types": json.dumps(report_recipe.report_types),
+ "cron_expression": report_recipe.cron_expression,
+ },
+ ),
+ client_member.user,
+ )
+ response = OOIEditView.as_view()(request, organization_code=client_member.organization.code)
+
+ assert response.status_code == 302
+
+ response_url = "/en/{}/objects/detail/?ooi_id=ReportRecipe%7C{}"
+ assert response.url == response_url.format(client_member.organization.code, report_recipe.recipe_id)
diff --git a/rocky/tools/forms/ooi_form.py b/rocky/tools/forms/ooi_form.py
index 6b14b566f07..9914d3b2fca 100644
--- a/rocky/tools/forms/ooi_form.py
+++ b/rocky/tools/forms/ooi_form.py
@@ -2,11 +2,11 @@
from enum import Enum
from inspect import isclass
from ipaddress import IPv4Address, IPv6Address
-from typing import Literal, Union, get_args, get_origin
+from typing import Any, Literal, Union, get_args, get_origin
from django import forms
from django.utils.translation import gettext_lazy as _
-from pydantic import AnyUrl, JsonValue
+from pydantic import AnyUrl
from pydantic.fields import FieldInfo
from octopoes.connector.octopoes import OctopoesAPIConnector
@@ -70,12 +70,7 @@ def generate_form_fields(self, hidden_ooi_fields: dict[str, str] | None = None)
fields[name] = generate_ip_field(field)
elif annotation == AnyUrl:
fields[name] = generate_url_field(field)
- elif (
- annotation == dict
- or annotation == dict[str, str]
- or annotation == list[str]
- or annotation == dict[str, JsonValue]
- ):
+ elif annotation == dict or annotation == list[str] or annotation == dict[str, Any]:
fields[name] = forms.JSONField(**default_attrs)
elif annotation == int or (hasattr(annotation, "__args__") and int in annotation.__args__):
fields[name] = forms.IntegerField(**default_attrs)