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/3] 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/3] 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/3] 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"), ]