diff --git a/src/aap_eda/api/filters/__init__.py b/src/aap_eda/api/filters/__init__.py index 8e18e1e11..62aef2a82 100644 --- a/src/aap_eda/api/filters/__init__.py +++ b/src/aap_eda/api/filters/__init__.py @@ -22,6 +22,7 @@ from .decision_environment import DecisionEnvironmentFilter from .eda_credential import EdaCredentialFilter from .event_stream import EventStreamFilter +from .management_job import ManagementJobFilter from .organization import OrganizationFilter from .project import ProjectFilter from .rulebook import RulebookFilter @@ -52,4 +53,6 @@ "OrganizationTeamFilter", # EventStream "EventStreamFilter", + # Management jobs + "ManagementJobFilter", ) diff --git a/src/aap_eda/api/filters/management_job.py b/src/aap_eda/api/filters/management_job.py new file mode 100644 index 000000000..554b1713a --- /dev/null +++ b/src/aap_eda/api/filters/management_job.py @@ -0,0 +1,38 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import django_filters + +from aap_eda.core import models + + +class ManagementJobFilter(django_filters.FilterSet): + name = django_filters.CharFilter( + field_name="name", + lookup_expr="icontains", + label="Filter by management job name.", + ) + job_type = django_filters.CharFilter( + field_name="job_type", + lookup_expr="exact", + label="Filter by job type.", + ) + is_enabled = django_filters.BooleanFilter( + field_name="is_enabled", + label="Filter by enabled status.", + ) + + class Meta: + model = models.ManagementJob + fields = ["name", "job_type", "is_enabled"] diff --git a/src/aap_eda/api/serializers/__init__.py b/src/aap_eda/api/serializers/__init__.py index 9c072c486..cea8dd25a 100644 --- a/src/aap_eda/api/serializers/__init__.py +++ b/src/aap_eda/api/serializers/__init__.py @@ -52,6 +52,12 @@ EdaCredentialUpdateSerializer, ) from .event_stream import EventStreamInSerializer, EventStreamOutSerializer +from .management_job import ( + ManagementJobExecutionDetailSerializer, + ManagementJobExecutionSerializer, + ManagementJobReadSerializer, + ManagementJobUpdateSerializer, +) from .organization import ( OrganizationCreateSerializer, OrganizationRefSerializer, @@ -155,4 +161,9 @@ # event streams "EventStreamInSerializer", "EventStreamOutSerializer", + # management jobs + "ManagementJobReadSerializer", + "ManagementJobUpdateSerializer", + "ManagementJobExecutionSerializer", + "ManagementJobExecutionDetailSerializer", ) diff --git a/src/aap_eda/api/serializers/management_job.py b/src/aap_eda/api/serializers/management_job.py new file mode 100644 index 000000000..009b90075 --- /dev/null +++ b/src/aap_eda/api/serializers/management_job.py @@ -0,0 +1,77 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from rest_framework import serializers + +from aap_eda.core import models + +__all__ = ( + "ManagementJobReadSerializer", + "ManagementJobUpdateSerializer", + "ManagementJobExecutionSerializer", + "ManagementJobExecutionDetailSerializer", +) + + +class ManagementJobReadSerializer(serializers.ModelSerializer): + class Meta: + model = models.ManagementJob + read_only_fields = [ + "id", + "name", + "description", + "job_type", + "organization", + "created_at", + "modified_at", + ] + fields = [ + "is_enabled", + "parameters", + *read_only_fields, + ] + + +class ManagementJobUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = models.ManagementJob + fields = ["is_enabled", "parameters"] + + +class ManagementJobExecutionSerializer(serializers.ModelSerializer): + class Meta: + model = models.ManagementJobExecution + read_only_fields = [ + "id", + "status", + "started_at", + "finished_at", + "created_at", + ] + fields = read_only_fields + + +class ManagementJobExecutionDetailSerializer(serializers.ModelSerializer): + class Meta: + model = models.ManagementJobExecution + read_only_fields = [ + "id", + "status", + "started_at", + "finished_at", + "output", + "errors", + "created_at", + ] + fields = read_only_fields diff --git a/src/aap_eda/api/urls.py b/src/aap_eda/api/urls.py index 83c673d29..495b35ab6 100644 --- a/src/aap_eda/api/urls.py +++ b/src/aap_eda/api/urls.py @@ -56,6 +56,11 @@ router.register("organizations", views.OrganizationViewSet) router.register("teams", views.TeamViewSet) router.register("event-streams", views.EventStreamViewSet) +router.register( + "management-jobs", + views.ManagementJobViewSet, + basename="management-job", +) router.register( "external_event_stream", views.ExternalEventStreamViewSet, diff --git a/src/aap_eda/api/views/__init__.py b/src/aap_eda/api/views/__init__.py index 3b61fdf77..5785add17 100644 --- a/src/aap_eda/api/views/__init__.py +++ b/src/aap_eda/api/views/__init__.py @@ -20,6 +20,7 @@ from .decision_environment import DecisionEnvironmentViewSet from .eda_credential import EdaCredentialViewSet from .event_stream import EventStreamViewSet +from .management_job import ManagementJobViewSet from .external_event_stream import ExternalEventStreamViewSet from .organization import OrganizationViewSet from .project import ProjectViewSet @@ -63,4 +64,6 @@ "EventStreamViewSet", # External event stream "ExternalEventStreamViewSet", + # Management jobs + "ManagementJobViewSet", ) diff --git a/src/aap_eda/api/views/management_job.py b/src/aap_eda/api/views/management_job.py new file mode 100644 index 000000000..dc5c16716 --- /dev/null +++ b/src/aap_eda/api/views/management_job.py @@ -0,0 +1,111 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django_filters import rest_framework as defaultfilters +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from aap_eda.api import filters, serializers +from aap_eda.api.views.mixins import PartialUpdateOnlyModelMixin +from aap_eda.core import models +from aap_eda.core.enums import ExecutionStatus + + +class ManagementJobViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + PartialUpdateOnlyModelMixin, + viewsets.GenericViewSet, +): + queryset = models.ManagementJob.objects.order_by("-created_at") + filter_backends = (defaultfilters.DjangoFilterBackend,) + filterset_class = filters.ManagementJobFilter + + def get_serializer_class(self): + if self.action in ("list", "retrieve"): + return serializers.ManagementJobReadSerializer + if self.action == "partial_update": + return serializers.ManagementJobUpdateSerializer + return serializers.ManagementJobReadSerializer + + def get_response_serializer_class(self): + return serializers.ManagementJobReadSerializer + + @extend_schema( + description="Launch an on-demand execution of a management job.", + request=None, + responses={ + status.HTTP_201_CREATED: serializers.ManagementJobExecutionDetailSerializer, # noqa: E501 + }, + ) + @action(methods=["post"], detail=True) + def launch(self, request, pk=None): + management_job = self.get_object() + execution = models.ManagementJobExecution.objects.create( + management_job=management_job, + status=ExecutionStatus.PENDING, + organization=management_job.organization, + ) + serializer = serializers.ManagementJobExecutionDetailSerializer( + execution + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + description="List execution history for a management job.", + responses={ + status.HTTP_200_OK: serializers.ManagementJobExecutionSerializer( + many=True + ), + }, + ) + @action(methods=["get"], detail=True) + def executions(self, request, pk=None): + management_job = self.get_object() + queryset = models.ManagementJobExecution.objects.filter( + management_job=management_job, + ).order_by("-created_at") + result = self.paginate_queryset(queryset) + serializer = serializers.ManagementJobExecutionSerializer( + result, many=True + ) + return self.get_paginated_response(serializer.data) + + @extend_schema( + description="Get execution details for a management job.", + responses={ + status.HTTP_200_OK: serializers.ManagementJobExecutionDetailSerializer, # noqa: E501 + }, + ) + @action( + methods=["get"], + detail=True, + url_path="executions/(?P[^/.]+)", + url_name="execution-detail", + ) + def execution_detail(self, request, pk=None, exec_id=None): + management_job = self.get_object() + try: + execution = models.ManagementJobExecution.objects.get( + pk=exec_id, + management_job=management_job, + ) + except models.ManagementJobExecution.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + serializer = serializers.ManagementJobExecutionDetailSerializer( + execution + ) + return Response(serializer.data) diff --git a/src/aap_eda/core/enums.py b/src/aap_eda/core/enums.py index aa2e3a007..b09730a3c 100644 --- a/src/aap_eda/core/enums.py +++ b/src/aap_eda/core/enums.py @@ -231,3 +231,15 @@ class AnalyticsCredentialType(DjangoStrEnum): AnalyticsCredentialType.BASIC, AnalyticsCredentialType.OAUTH, ] + + +class ManagementJobType(DjangoStrEnum): + CLEANUP_AUDIT_LOGS = "cleanup_audit_logs" + CLEANUP_STALE_ACTIVATIONS = "cleanup_stale_activations" + + +class ExecutionStatus(DjangoStrEnum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" diff --git a/src/aap_eda/core/migrations/0071_management_jobs.py b/src/aap_eda/core/migrations/0071_management_jobs.py new file mode 100644 index 000000000..a002537b9 --- /dev/null +++ b/src/aap_eda/core/migrations/0071_management_jobs.py @@ -0,0 +1,150 @@ +# Generated by Django 5.2.13 on 2026-04-21 18:49 + +import django.db.models.deletion +from django.db import migrations, models + +import aap_eda.core.enums + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0070_activation_enable_persistence_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ManagementJob", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.TextField(unique=True)), + ("description", models.TextField(blank=True, default="")), + ( + "job_type", + models.TextField( + choices=[ + ("cleanup_audit_logs", "cleanup_audit_logs"), + ( + "cleanup_stale_activations", + "cleanup_stale_activations", + ), + ], + default=aap_eda.core.enums.ManagementJobType[ + "CLEANUP_AUDIT_LOGS" + ], + ), + ), + ("is_enabled", models.BooleanField(default=True)), + ("parameters", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="core.organization", + ), + ), + ], + options={ + "db_table": "core_management_job", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="ManagementJobExecution", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.TextField( + choices=[ + ("pending", "pending"), + ("running", "running"), + ("completed", "completed"), + ("failed", "failed"), + ], + default=aap_eda.core.enums.ExecutionStatus["PENDING"], + ), + ), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ("output", models.TextField(blank=True, default="")), + ("errors", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "management_job", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="executions", + to="core.managementjob", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="core.organization", + ), + ), + ], + options={ + "db_table": "core_management_job_execution", + "ordering": ("-started_at",), + }, + ), + migrations.CreateModel( + name="ManagementJobSchedule", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("schedule", models.TextField(help_text="Cron expression")), + ("next_run_at", models.DateTimeField(blank=True, null=True)), + ("last_run_at", models.DateTimeField(blank=True, null=True)), + ("is_enabled", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ( + "management_job", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="schedules", + to="core.managementjob", + ), + ), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="core.organization", + ), + ), + ], + options={ + "db_table": "core_management_job_schedule", + "ordering": ("-next_run_at",), + }, + ), + ] diff --git a/src/aap_eda/core/models/__init__.py b/src/aap_eda/core/models/__init__.py index fba9fd3d6..e7735772d 100644 --- a/src/aap_eda/core/models/__init__.py +++ b/src/aap_eda/core/models/__init__.py @@ -27,6 +27,11 @@ JobInstanceEvent, JobInstanceHost, ) +from .management_job import ( + ManagementJob, + ManagementJobExecution, + ManagementJobSchedule, +) from .organization import Organization from .project import Project from .queue import ActivationRequestQueue @@ -65,6 +70,9 @@ "Organization", "Team", "EventStream", + "ManagementJob", + "ManagementJobExecution", + "ManagementJobSchedule", "Setting", ] @@ -73,6 +81,7 @@ EdaCredential, CredentialInputSource, DecisionEnvironment, + ManagementJob, Project, Organization, Team, diff --git a/src/aap_eda/core/models/management_job.py b/src/aap_eda/core/models/management_job.py new file mode 100644 index 000000000..8feeab8a6 --- /dev/null +++ b/src/aap_eda/core/models/management_job.py @@ -0,0 +1,80 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.db import models + +from aap_eda.core.enums import ExecutionStatus, ManagementJobType + +from .base import BaseOrgModel, UniqueNamedModel + +__all__ = ( + "ManagementJob", + "ManagementJobSchedule", + "ManagementJobExecution", +) + + +class ManagementJob(BaseOrgModel, UniqueNamedModel): + class Meta: + db_table = "core_management_job" + ordering = ("-created_at",) + + description = models.TextField(default="", blank=True) + job_type = models.TextField( + choices=ManagementJobType.choices(), + default=ManagementJobType.CLEANUP_AUDIT_LOGS, + ) + is_enabled = models.BooleanField(default=True) + parameters = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, null=False) + modified_at = models.DateTimeField(auto_now=True, null=False) + + +class ManagementJobSchedule(BaseOrgModel): + class Meta: + db_table = "core_management_job_schedule" + ordering = ("-next_run_at",) + + management_job = models.ForeignKey( + ManagementJob, + on_delete=models.CASCADE, + related_name="schedules", + ) + schedule = models.TextField(help_text="Cron expression") + next_run_at = models.DateTimeField(null=True, blank=True) + last_run_at = models.DateTimeField(null=True, blank=True) + is_enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True, null=False) + modified_at = models.DateTimeField(auto_now=True, null=False) + + +class ManagementJobExecution(BaseOrgModel): + class Meta: + db_table = "core_management_job_execution" + ordering = ("-started_at",) + + management_job = models.ForeignKey( + ManagementJob, + on_delete=models.CASCADE, + related_name="executions", + ) + status = models.TextField( + choices=ExecutionStatus.choices(), + default=ExecutionStatus.PENDING, + ) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + output = models.TextField(default="", blank=True) + errors = models.TextField(default="", blank=True) + created_at = models.DateTimeField(auto_now_add=True, null=False) diff --git a/tests/integration/api/test_management_job.py b/tests/integration/api/test_management_job.py new file mode 100644 index 000000000..6e8761cf5 --- /dev/null +++ b/tests/integration/api/test_management_job.py @@ -0,0 +1,327 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from aap_eda.core import models +from aap_eda.core.enums import ExecutionStatus, ManagementJobType +from tests.integration.constants import api_url_v1 + +MANAGEMENT_JOBS_URL = f"{api_url_v1}/management-jobs" + + +@pytest.fixture() +def management_job(default_organization): + return models.ManagementJob.objects.create( + name="Cleanup Audit Logs", + description="Remove old audit rule logs", + job_type=ManagementJobType.CLEANUP_AUDIT_LOGS, + is_enabled=True, + parameters={"retention_days": 90}, + organization=default_organization, + ) + + +@pytest.fixture() +def second_management_job(default_organization): + return models.ManagementJob.objects.create( + name="Cleanup Stale Activations", + description="Remove stale activations", + job_type=ManagementJobType.CLEANUP_STALE_ACTIVATIONS, + is_enabled=False, + organization=default_organization, + ) + + +@pytest.fixture() +def management_job_execution(management_job, default_organization): + return models.ManagementJobExecution.objects.create( + management_job=management_job, + status=ExecutionStatus.COMPLETED, + output="Deleted 42 records", + organization=default_organization, + ) + + +# -- List -- + + +@pytest.mark.django_db +def test_list_management_jobs( + admin_client: APIClient, + management_job: models.ManagementJob, + second_management_job: models.ManagementJob, +): + response = admin_client.get(f"{MANAGEMENT_JOBS_URL}/") + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 2 + + +@pytest.mark.django_db +def test_list_management_jobs_filter_by_name( + admin_client: APIClient, + management_job: models.ManagementJob, + second_management_job: models.ManagementJob, +): + response = admin_client.get(f"{MANAGEMENT_JOBS_URL}/?name=Audit") + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["name"] == "Cleanup Audit Logs" + + +@pytest.mark.django_db +def test_list_management_jobs_filter_by_job_type( + admin_client: APIClient, + management_job: models.ManagementJob, + second_management_job: models.ManagementJob, +): + response = admin_client.get( + f"{MANAGEMENT_JOBS_URL}/?job_type=cleanup_audit_logs" + ) + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + + +@pytest.mark.django_db +def test_list_management_jobs_filter_by_enabled( + admin_client: APIClient, + management_job: models.ManagementJob, + second_management_job: models.ManagementJob, +): + response = admin_client.get(f"{MANAGEMENT_JOBS_URL}/?is_enabled=false") + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["is_enabled"] is False + + +# -- Retrieve -- + + +@pytest.mark.django_db +def test_retrieve_management_job( + admin_client: APIClient, + management_job: models.ManagementJob, +): + response = admin_client.get(f"{MANAGEMENT_JOBS_URL}/{management_job.id}/") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Cleanup Audit Logs" + assert data["description"] == "Remove old audit rule logs" + assert data["job_type"] == "cleanup_audit_logs" + assert data["is_enabled"] is True + assert data["parameters"] == {"retention_days": 90} + assert "created_at" in data + assert "modified_at" in data + + +@pytest.mark.django_db +def test_retrieve_management_job_not_found( + admin_client: APIClient, +): + response = admin_client.get(f"{MANAGEMENT_JOBS_URL}/99999/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# -- Partial Update -- + + +@pytest.mark.django_db +def test_patch_management_job_is_enabled( + admin_client: APIClient, + management_job: models.ManagementJob, +): + response = admin_client.patch( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/", + data={"is_enabled": False}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["is_enabled"] is False + management_job.refresh_from_db() + assert management_job.is_enabled is False + + +@pytest.mark.django_db +def test_patch_management_job_parameters( + admin_client: APIClient, + management_job: models.ManagementJob, +): + response = admin_client.patch( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/", + data={"parameters": {"retention_days": 30}}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["parameters"] == {"retention_days": 30} + management_job.refresh_from_db() + assert management_job.parameters == {"retention_days": 30} + + +@pytest.mark.django_db +def test_patch_management_job_read_only_fields_ignored( + admin_client: APIClient, + management_job: models.ManagementJob, +): + response = admin_client.patch( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/", + data={"name": "Hacked Name", "is_enabled": False}, + ) + assert response.status_code == status.HTTP_200_OK + management_job.refresh_from_db() + assert management_job.name == "Cleanup Audit Logs" + assert management_job.is_enabled is False + + +# -- Launch -- + + +@pytest.mark.django_db +def test_launch_management_job( + admin_client: APIClient, + management_job: models.ManagementJob, +): + response = admin_client.post( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/launch/" + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["status"] == "pending" + assert data["output"] == "" + assert data["errors"] == "" + assert ( + models.ManagementJobExecution.objects.filter( + management_job=management_job, + ).count() + == 1 + ) + + +@pytest.mark.django_db +def test_launch_management_job_creates_multiple_executions( + admin_client: APIClient, + management_job: models.ManagementJob, +): + admin_client.post(f"{MANAGEMENT_JOBS_URL}/{management_job.id}/launch/") + admin_client.post(f"{MANAGEMENT_JOBS_URL}/{management_job.id}/launch/") + assert ( + models.ManagementJobExecution.objects.filter( + management_job=management_job, + ).count() + == 2 + ) + + +# -- Executions List -- + + +@pytest.mark.django_db +def test_list_executions( + admin_client: APIClient, + management_job: models.ManagementJob, + management_job_execution: models.ManagementJobExecution, +): + response = admin_client.get( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/executions/" + ) + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["status"] == "completed" + assert "output" not in results[0] + + +@pytest.mark.django_db +def test_list_executions_empty( + admin_client: APIClient, + management_job: models.ManagementJob, +): + response = admin_client.get( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/executions/" + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"] == [] + + +# -- Execution Detail -- + + +@pytest.mark.django_db +def test_retrieve_execution_detail( + admin_client: APIClient, + management_job: models.ManagementJob, + management_job_execution: models.ManagementJobExecution, +): + response = admin_client.get( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/" + f"executions/{management_job_execution.id}/" + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["status"] == "completed" + assert data["output"] == "Deleted 42 records" + assert data["errors"] == "" + + +@pytest.mark.django_db +def test_retrieve_execution_detail_not_found( + admin_client: APIClient, + management_job: models.ManagementJob, +): + response = admin_client.get( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/executions/99999/" + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_retrieve_execution_wrong_job( + admin_client: APIClient, + management_job: models.ManagementJob, + second_management_job: models.ManagementJob, + management_job_execution: models.ManagementJobExecution, +): + response = admin_client.get( + f"{MANAGEMENT_JOBS_URL}/{second_management_job.id}/" + f"executions/{management_job_execution.id}/" + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# -- No Create / No Delete -- + + +@pytest.mark.django_db +def test_create_management_job_not_allowed( + admin_client: APIClient, +): + response = admin_client.post( + f"{MANAGEMENT_JOBS_URL}/", + data={"name": "New Job"}, + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +@pytest.mark.django_db +def test_delete_management_job_not_allowed( + admin_client: APIClient, + management_job: models.ManagementJob, +): + response = admin_client.delete( + f"{MANAGEMENT_JOBS_URL}/{management_job.id}/" + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/tests/integration/core/test_management_job.py b/tests/integration/core/test_management_job.py new file mode 100644 index 000000000..d65ce2731 --- /dev/null +++ b/tests/integration/core/test_management_job.py @@ -0,0 +1,244 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from django.db import IntegrityError +from django.utils import timezone + +from aap_eda.core.enums import ExecutionStatus, ManagementJobType +from aap_eda.core.models import ( + ManagementJob, + ManagementJobExecution, + ManagementJobSchedule, +) + + +@pytest.fixture() +def management_job(default_organization): + return ManagementJob.objects.create( + name="Cleanup Audit Logs", + description="Remove old audit rule logs", + job_type=ManagementJobType.CLEANUP_AUDIT_LOGS, + is_enabled=True, + parameters={"retention_days": 90}, + organization=default_organization, + ) + + +@pytest.fixture() +def management_job_schedule(management_job, default_organization): + return ManagementJobSchedule.objects.create( + management_job=management_job, + schedule="0 2 * * *", + is_enabled=True, + organization=default_organization, + ) + + +@pytest.fixture() +def management_job_execution(management_job, default_organization): + return ManagementJobExecution.objects.create( + management_job=management_job, + status=ExecutionStatus.PENDING, + organization=default_organization, + ) + + +# -- ManagementJob model tests -- + + +@pytest.mark.django_db +def test_create_management_job(management_job): + assert management_job.pk is not None + assert management_job.name == "Cleanup Audit Logs" + assert management_job.description == "Remove old audit rule logs" + assert management_job.job_type == ManagementJobType.CLEANUP_AUDIT_LOGS + assert management_job.is_enabled is True + assert management_job.parameters == {"retention_days": 90} + assert management_job.created_at is not None + assert management_job.modified_at is not None + + +@pytest.mark.django_db +def test_management_job_unique_name(management_job, default_organization): + with pytest.raises(IntegrityError): + ManagementJob.objects.create( + name="Cleanup Audit Logs", + job_type=ManagementJobType.CLEANUP_STALE_ACTIVATIONS, + organization=default_organization, + ) + + +@pytest.mark.django_db +def test_management_job_default_values(default_organization): + job = ManagementJob.objects.create( + name="Default Job", + organization=default_organization, + ) + assert job.is_enabled is True + assert job.description == "" + assert job.parameters == {} + assert job.job_type == ManagementJobType.CLEANUP_AUDIT_LOGS + + +@pytest.mark.django_db +def test_management_job_types(): + assert ManagementJobType.CLEANUP_AUDIT_LOGS == "cleanup_audit_logs" + assert ManagementJobType.CLEANUP_STALE_ACTIVATIONS == ( + "cleanup_stale_activations" + ) + + +# -- ManagementJobSchedule model tests -- + + +@pytest.mark.django_db +def test_create_management_job_schedule(management_job_schedule): + assert management_job_schedule.pk is not None + assert management_job_schedule.schedule == "0 2 * * *" + assert management_job_schedule.is_enabled is True + assert management_job_schedule.next_run_at is None + assert management_job_schedule.last_run_at is None + assert management_job_schedule.created_at is not None + assert management_job_schedule.modified_at is not None + + +@pytest.mark.django_db +def test_schedule_belongs_to_job(management_job, management_job_schedule): + assert management_job_schedule.management_job == management_job + assert management_job.schedules.count() == 1 + assert management_job.schedules.first() == management_job_schedule + + +@pytest.mark.django_db +def test_schedule_timestamps(management_job_schedule): + now = timezone.now() + management_job_schedule.next_run_at = now + management_job_schedule.last_run_at = now + management_job_schedule.save() + management_job_schedule.refresh_from_db() + assert management_job_schedule.next_run_at is not None + assert management_job_schedule.last_run_at is not None + + +# -- ManagementJobExecution model tests -- + + +@pytest.mark.django_db +def test_create_management_job_execution(management_job_execution): + assert management_job_execution.pk is not None + assert management_job_execution.status == ExecutionStatus.PENDING + assert management_job_execution.started_at is None + assert management_job_execution.finished_at is None + assert management_job_execution.output == "" + assert management_job_execution.errors == "" + assert management_job_execution.created_at is not None + + +@pytest.mark.django_db +def test_execution_belongs_to_job(management_job, management_job_execution): + assert management_job_execution.management_job == management_job + assert management_job.executions.count() == 1 + assert management_job.executions.first() == management_job_execution + + +@pytest.mark.django_db +def test_execution_status_transitions(management_job_execution): + now = timezone.now() + + management_job_execution.status = ExecutionStatus.RUNNING + management_job_execution.started_at = now + management_job_execution.save() + management_job_execution.refresh_from_db() + assert management_job_execution.status == ExecutionStatus.RUNNING + + management_job_execution.status = ExecutionStatus.COMPLETED + management_job_execution.finished_at = now + management_job_execution.output = "Deleted 150 records" + management_job_execution.save() + management_job_execution.refresh_from_db() + assert management_job_execution.status == ExecutionStatus.COMPLETED + assert management_job_execution.output == "Deleted 150 records" + + +@pytest.mark.django_db +def test_execution_failed_status(management_job_execution): + management_job_execution.status = ExecutionStatus.FAILED + management_job_execution.errors = "Database connection timeout" + management_job_execution.save() + management_job_execution.refresh_from_db() + assert management_job_execution.status == ExecutionStatus.FAILED + assert management_job_execution.errors == "Database connection timeout" + + +@pytest.mark.django_db +def test_execution_status_enum(): + assert ExecutionStatus.PENDING == "pending" + assert ExecutionStatus.RUNNING == "running" + assert ExecutionStatus.COMPLETED == "completed" + assert ExecutionStatus.FAILED == "failed" + + +# -- Relationship and cascade tests -- + + +@pytest.mark.django_db +def test_cascade_delete_job_deletes_schedules( + management_job, management_job_schedule +): + job_id = management_job.pk + management_job.delete() + assert ( + ManagementJobSchedule.objects.filter(management_job_id=job_id).count() + == 0 + ) + + +@pytest.mark.django_db +def test_cascade_delete_job_deletes_executions( + management_job, management_job_execution +): + job_id = management_job.pk + management_job.delete() + assert ( + ManagementJobExecution.objects.filter(management_job_id=job_id).count() + == 0 + ) + + +@pytest.mark.django_db +def test_multiple_executions_per_job(management_job, default_organization): + for i in range(3): + ManagementJobExecution.objects.create( + management_job=management_job, + status=ExecutionStatus.COMPLETED, + output=f"Run {i}", + organization=default_organization, + ) + assert management_job.executions.count() == 3 + + +@pytest.mark.django_db +def test_multiple_schedules_per_job(management_job, default_organization): + ManagementJobSchedule.objects.create( + management_job=management_job, + schedule="0 3 * * *", + organization=default_organization, + ) + ManagementJobSchedule.objects.create( + management_job=management_job, + schedule="0 6 * * *", + organization=default_organization, + ) + assert management_job.schedules.count() == 2