From 966b229c940585904694d07d66417534a36ddb94 Mon Sep 17 00:00:00 2001 From: B-Whitt <34513926+B-Whitt@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:20:27 -0400 Subject: [PATCH 1/4] feat(core): add management job data models and migration Add ManagementJob, ManagementJobSchedule, and ManagementJobExecution models with ManagementJobType and ExecutionStatus enums. Includes migration 0071 and 16 integration tests covering model creation, relationships, constraints, and cascade deletes. Jira: AAP-72399 Assisted-By: Claude Opus --- src/aap_eda/core/enums.py | 12 + .../core/migrations/0071_management_jobs.py | 69 +++++ src/aap_eda/core/models/__init__.py | 9 + src/aap_eda/core/models/management_job.py | 80 ++++++ tests/integration/core/test_management_job.py | 244 ++++++++++++++++++ 5 files changed, 414 insertions(+) create mode 100644 src/aap_eda/core/migrations/0071_management_jobs.py create mode 100644 src/aap_eda/core/models/management_job.py create mode 100644 tests/integration/core/test_management_job.py 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..f04cc1300 --- /dev/null +++ b/src/aap_eda/core/migrations/0071_management_jobs.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.13 on 2026-04-21 18:49 + +import aap_eda.core.enums +import django.db.models.deletion +from django.db import migrations, models + + +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/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 From 132b64db37df38a258af6302b0ff88023102cd72 Mon Sep 17 00:00:00 2001 From: B-Whitt <34513926+B-Whitt@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:53:47 -0400 Subject: [PATCH 2/4] style(core): apply black and isort formatting to migration Assisted-By: Claude Opus --- .../core/migrations/0071_management_jobs.py | 158 +++++++++++++----- 1 file changed, 120 insertions(+), 38 deletions(-) diff --git a/src/aap_eda/core/migrations/0071_management_jobs.py b/src/aap_eda/core/migrations/0071_management_jobs.py index f04cc1300..7da7446f6 100644 --- a/src/aap_eda/core/migrations/0071_management_jobs.py +++ b/src/aap_eda/core/migrations/0071_management_jobs.py @@ -1,69 +1,151 @@ # Generated by Django 5.2.13 on 2026-04-21 18:49 -import aap_eda.core.enums 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'), + ("core", "0070_activation_enable_persistence_and_more"), ] operations = [ migrations.CreateModel( - name='ManagementJob', + 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')), + ( + "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',), + "db_table": "core_management_job", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='ManagementJobExecution', + 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')), + ( + "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',), + "db_table": "core_management_job_execution", + "ordering": ("-started_at",), }, ), migrations.CreateModel( - name='ManagementJobSchedule', + 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')), + ( + "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',), + "db_table": "core_management_job_schedule", + "ordering": ("-next_run_at",), }, ), ] From 05a91285137c1a66ae0205eefe5a28e52a185043 Mon Sep 17 00:00:00 2001 From: B-Whitt <34513926+B-Whitt@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:59:33 -0400 Subject: [PATCH 3/4] style(core): fix black formatting for pinned version (23.3.0) Assisted-By: Claude Opus --- src/aap_eda/core/migrations/0071_management_jobs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/aap_eda/core/migrations/0071_management_jobs.py b/src/aap_eda/core/migrations/0071_management_jobs.py index 7da7446f6..a002537b9 100644 --- a/src/aap_eda/core/migrations/0071_management_jobs.py +++ b/src/aap_eda/core/migrations/0071_management_jobs.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [ ("core", "0070_activation_enable_persistence_and_more"), ] From 81f9f8a3e39ca75aae6cd3631248f20662954148 Mon Sep 17 00:00:00 2001 From: B-Whitt <34513926+B-Whitt@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:05:40 -0400 Subject: [PATCH 4/4] feat(api): add REST API endpoints for management jobs Add ManagementJobViewSet with list, retrieve, partial update, launch, and execution history endpoints. Includes read/update serializers, filters (name, job_type, is_enabled), and 18 integration tests. Jira: AAP-72400 Assisted-By: Claude Opus --- src/aap_eda/api/filters/__init__.py | 3 + src/aap_eda/api/filters/management_job.py | 38 ++ src/aap_eda/api/serializers/__init__.py | 11 + src/aap_eda/api/serializers/management_job.py | 77 +++++ src/aap_eda/api/urls.py | 5 + src/aap_eda/api/views/__init__.py | 3 + src/aap_eda/api/views/management_job.py | 111 ++++++ tests/integration/api/test_management_job.py | 327 ++++++++++++++++++ 8 files changed, 575 insertions(+) create mode 100644 src/aap_eda/api/filters/management_job.py create mode 100644 src/aap_eda/api/serializers/management_job.py create mode 100644 src/aap_eda/api/views/management_job.py create mode 100644 tests/integration/api/test_management_job.py 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/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