Skip to content

AAP-47731: Async create() PatternIstanceViewSet #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/units.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Unit Tests

on:
pull_request:
branches:
- main
- stable-*
tags:
- "*"

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
env:
DJANGO_SETTINGS_MODULE: pattern_service.settings

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements/requirements-dev.txt

- name: Run migrations
run: python manage.py migrate

- name: Run core tests
run: python manage.py test core

1 change: 1 addition & 0 deletions core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
admin.site.register(models.ControllerLabel)
admin.site.register(models.PatternInstance)
admin.site.register(models.Automation)
admin.site.register(models.Task)
59 changes: 59 additions & 0 deletions core/migrations/0003_task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 4.2.23 on 2025-06-26 19:27

import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0002_pattern_created_pattern_created_by_pattern_modified_and_more'),
]

operations = [
migrations.CreateModel(
name='Task',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('modified', models.DateTimeField(auto_now=True, help_text='The date/time this resource was created.')),
('created', models.DateTimeField(auto_now_add=True, help_text='The date/time this resource was created.')),
(
'status',
models.CharField(
choices=[('Initiated', 'Initiated'), ('Running', 'Running'), ('Completed', 'Completed'), ('Failed', 'Failed')], max_length=20
),
),
('details', models.JSONField(blank=True, null=True)),
(
'created_by',
models.ForeignKey(
default=None,
editable=False,
help_text='The user who created this resource.',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='%(app_label)s_%(class)s_created+',
to=settings.AUTH_USER_MODEL,
),
),
(
'modified_by',
models.ForeignKey(
default=None,
editable=False,
help_text='The user who last modified this resource.',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='%(app_label)s_%(class)s_modified+',
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'ordering': ['id'],
},
),
]
16 changes: 16 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,19 @@ class Meta:
primary: models.BooleanField = models.BooleanField(default=False)

pattern_instance: models.ForeignKey = models.ForeignKey(PatternInstance, on_delete=models.CASCADE, related_name="automations")


class Task(CommonModel):
class Meta:
app_label = 'core'
ordering = ['id']

status_choices = (
("Initiated", "Initiated"),
("Running", "Running"),
("Completed", "Completed"),
("Failed", "Failed"),
)

status: models.CharField = models.CharField(max_length=20, choices=status_choices)
details: models.JSONField = models.JSONField(null=True, blank=True)
10 changes: 10 additions & 0 deletions core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .models import ControllerLabel
from .models import Pattern
from .models import PatternInstance
from .models import Task


class PatternSerializer(CommonModelSerializer):
Expand Down Expand Up @@ -54,3 +55,12 @@ class Meta(CommonModelSerializer.Meta):
'primary',
'pattern_instance',
]


class TaskSerializer(CommonModelSerializer):
class Meta(CommonModelSerializer.Meta):
model = Task
fields = CommonModelSerializer.Meta.fields + [
'status',
'details',
]
79 changes: 79 additions & 0 deletions core/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import json
import os
import tarfile
import tempfile

import aiohttp
from asgiref.sync import sync_to_async
from django.db import transaction

from .models import ControllerLabel
from .models import PatternInstance
from .models import Task


async def update_task_status(task: Task, status_: str, details: dict):
task.status = status_
task.details = details
await sync_to_async(task.save, thread_sensitive=True)()


async def download_and_extract_tarball(url: str) -> dict:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status != 200:
raise Exception(f"Failed to download tarball: HTTP {resp.status}")

with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write(await resp.read())
tmp_path = tmp.name

extract_dir = tempfile.mkdtemp()
with tarfile.open(tmp_path, 'r:*') as tar:
tar.extractall(path=extract_dir)

# Look for a .pattern.json or similar file
for root, _, files in os.walk(extract_dir):
for fname in files:
if fname.endswith(".json"):
with open(os.path.join(root, fname)) as f:
return json.load(f)

raise Exception("Pattern definition JSON file not found in tarball")


async def run_pattern_instance_task(instance_id: int, task_id: int):
task = await sync_to_async(Task.objects.get, thread_sensitive=True)(id=task_id)

try:
instance = await sync_to_async(PatternInstance.objects.select_related("pattern").get, thread_sensitive=True)(id=instance_id)
pattern = instance.pattern

# Make sure the Pattern has pattern_definition loaded (could be empty)
pattern_def = pattern.pattern_definition or {}

await update_task_status(task, "Running", {"info": "Processing PatternInstance"})

if not pattern_def:
raise Exception("Pattern definition is missing. Cannot process instance.")

# Update instance fields with data from pattern definition inside transaction
def update_instance():
with transaction.atomic():
if "execution_environment_id" in pattern_def:
instance.controller_ee_id = int(pattern_def["execution_environment_id"])
if "executors" in pattern_def:
instance.executors = pattern_def["executors"]
if "controller_labels" in pattern_def:
for label_id in pattern_def["controller_labels"]:
label_obj, _ = ControllerLabel.objects.get_or_create(label_id=label_id)
instance.controller_labels.add(label_obj)

instance.controller_project_id = hash(pattern.pattern_name) % 10**6
instance.save()

await sync_to_async(update_instance, thread_sensitive=True)()

await update_task_status(task, "Completed", {"info": "PatternInstance processed"})
except Exception as e:
await update_task_status(task, "Failed", {"error": str(e)})
34 changes: 34 additions & 0 deletions core/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
from core.models import ControllerLabel
from core.models import Pattern
from core.models import PatternInstance
from core.models import Task
from core.serializers import AutomationSerializer
from core.serializers import ControllerLabelSerializer
from core.serializers import PatternInstanceSerializer
from core.serializers import PatternSerializer
from core.serializers import TaskSerializer


class SharedTestFixture(TestCase):
Expand Down Expand Up @@ -163,3 +165,35 @@ def test_serializer_validation_failure(self):
self.assertFalse(serializer.is_valid())
self.assertIn('automation_type', serializer.errors)
self.assertIn('automation_id', serializer.errors)


class TaskSerializerTest(SharedTestFixture):
def test_serializer_fields_present(self):
task = Task.objects.create(status="Initiated", details={"info": "test"})
serializer = TaskSerializer(instance=task)
data = serializer.data

self.assertIn("id", data)
self.assertIn("status", data)
self.assertIn("details", data)
self.assertEqual(data["status"], "Initiated")
self.assertEqual(data["details"], {"info": "test"})

def test_serializer_validation_success(self):
input_data = {
"status": "Running",
"details": {"step": 1, "info": "in progress"},
}
serializer = TaskSerializer(data=input_data)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertEqual(serializer.validated_data["status"], "Running")
self.assertEqual(serializer.validated_data["details"], {"step": 1, "info": "in progress"})

def test_serializer_invalid_status(self):
input_data = {
"status": "UnknownStatus",
"details": {},
}
serializer = TaskSerializer(data=input_data)
self.assertFalse(serializer.is_valid())
self.assertIn("status", serializer.errors)
114 changes: 114 additions & 0 deletions core/tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from unittest.mock import patch

from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase

from core.models import Automation
from core.models import ControllerLabel
from core.models import Pattern
from core.models import PatternInstance
from core.models import Task


class SharedDataMixin:
@classmethod
def setUpTestData(cls):
cls.pattern = Pattern.objects.create(
collection_name="mynamespace.mycollection",
collection_version="1.0.0",
collection_version_uri="https://example.com/mynamespace/mycollection/",
pattern_name="example_pattern",
pattern_definition={"key": "value"},
)

cls.pattern_instance = PatternInstance.objects.create(
organization_id=1,
controller_project_id=123,
controller_ee_id=456,
credentials={"user": "admin"},
executors=[{"executor_type": "container"}],
pattern=cls.pattern,
)

cls.label = ControllerLabel.objects.create(label_id=5)
cls.pattern_instance.controller_labels.add(cls.label)

cls.automation = Automation.objects.create(
automation_type="job_template",
automation_id=789,
primary=True,
pattern_instance=cls.pattern_instance,
)

cls.task = Task.objects.create(status="Running", details={"progress": "50%"})


class PatternInstanceViewSetTest(SharedDataMixin, APITestCase):
def test_pattern_instance_list_view(self):
url = reverse("patterninstance-list")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)

def test_pattern_instance_detail_view(self):
url = reverse("patterninstance-detail", args=[self.pattern_instance.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["organization_id"], 1)

@patch("core.views.async_to_sync")
def test_pattern_instance_create_view(self, mock_async_to_sync):
url = reverse("patterninstance-list")
data = {
"organization_id": 2,
"controller_project_id": 0,
"controller_ee_id": 0,
"credentials": {"user": "tester"},
"executors": [],
"pattern": self.pattern.id,
}

response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)

instance = PatternInstance.objects.get(organization_id=2)
self.assertIsNotNone(instance)

task_id = response.data["task_id"]
task = Task.objects.get(id=task_id)
self.assertEqual(task.status, "Initiated")

mock_async_to_sync.assert_called_once()
self.assertIn("task_id", response.data)
self.assertIn("message", response.data)


class AutomationViewSetTest(SharedDataMixin, APITestCase):
def test_automation_list_view(self):
url = reverse("automation-list")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)

def test_automation_detail_view(self):
url = reverse("automation-detail", args=[self.automation.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["automation_type"], "job_template")


class ControllerLabelViewSetTest(SharedDataMixin, APITestCase):
def test_label_list_view(self):
url = reverse("controllerlabel-list")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)

def test_label_detail_view(self):
url = reverse("controllerlabel-detail", args=[self.label.id])
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('id', response.data)
self.assertIn('label_id', response.data)
self.assertEqual(response.data['label_id'], 5)
Loading
Loading