fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# processing app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProcessingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.processing"
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('orders', '0001_initial'),
|
||||
('workflows', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StepExecution',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('state', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready'), ('in_progress', 'In progress'), ('paused', 'Paused'), ('waiting', 'Waiting'), ('completed', 'Completed'), ('failed', 'Failed'), ('rejected', 'Rejected'), ('skipped', 'Skipped'), ('cancelled', 'Cancelled'), ('rework_required', 'Rework required')], default='pending', max_length=20)),
|
||||
('attempt', models.PositiveIntegerField(default=1)),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('measurements', models.JSONField(blank=True, default=dict)),
|
||||
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_steps', to=settings.AUTH_USER_MODEL)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='step_executions', to='orders.processingorder')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rework_children', to='processing.stepexecution')),
|
||||
('step', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='workflows.workflowstep')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
'constraints': [models.UniqueConstraint(fields=('order', 'step', 'attempt'), name='uniq_step_execution_attempt')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class StepExecution(UUIDModel, TimeStampedModel):
|
||||
class State(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
READY = "ready", "Ready"
|
||||
IN_PROGRESS = "in_progress", "In progress"
|
||||
PAUSED = "paused", "Paused"
|
||||
WAITING = "waiting", "Waiting"
|
||||
COMPLETED = "completed", "Completed"
|
||||
FAILED = "failed", "Failed"
|
||||
REJECTED = "rejected", "Rejected"
|
||||
SKIPPED = "skipped", "Skipped"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
REWORK_REQUIRED = "rework_required", "Rework required"
|
||||
|
||||
order = models.ForeignKey("orders.ProcessingOrder", on_delete=models.CASCADE, related_name="step_executions")
|
||||
step = models.ForeignKey("workflows.WorkflowStep", on_delete=models.PROTECT)
|
||||
state = models.CharField(max_length=20, choices=State.choices, default=State.PENDING)
|
||||
attempt = models.PositiveIntegerField(default=1)
|
||||
assigned_to = models.ForeignKey(
|
||||
"accounts.User", null=True, blank=True, on_delete=models.SET_NULL, related_name="assigned_steps"
|
||||
)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
measurements = models.JSONField(default=dict, blank=True)
|
||||
# For rework history: a rework execution points back to the failed one.
|
||||
parent = models.ForeignKey(
|
||||
"self", null=True, blank=True, on_delete=models.SET_NULL, related_name="rework_children"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["order", "step", "attempt"], name="uniq_step_execution_attempt")
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.order.order_number} / {self.step.key} (attempt {self.attempt})"
|
||||
@@ -0,0 +1,16 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.processing.models import StepExecution
|
||||
|
||||
|
||||
class StepExecutionSerializer(serializers.ModelSerializer):
|
||||
order_number = serializers.CharField(source="order.order_number", read_only=True)
|
||||
step_key = serializers.CharField(source="step.key", read_only=True)
|
||||
assigned_to_name = serializers.CharField(source="assigned_to.username", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = StepExecution
|
||||
fields = [
|
||||
"id", "order", "order_number", "step", "step_key", "state", "attempt",
|
||||
"assigned_to", "assigned_to_name", "started_at", "completed_at",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.errors import ApiError
|
||||
from apps.processing.models import StepExecution
|
||||
|
||||
|
||||
class ProcessingService:
|
||||
"""Step execution state machine. Transitions are validated server-side."""
|
||||
|
||||
_STARTABLE = {"pending", "ready", "rework_required"}
|
||||
_PAUSABLE = {"in_progress"}
|
||||
_RESUMABLE = {"paused"}
|
||||
_COMPLETABLE = {"in_progress"}
|
||||
_REJECTABLE = {"in_progress", "waiting"}
|
||||
|
||||
@transaction.atomic
|
||||
def start(self, execution, user) -> StepExecution:
|
||||
execution = self._locked(execution)
|
||||
if execution.state not in self._STARTABLE:
|
||||
raise ApiError("INVALID_STEP_TRANSITION", f"Cannot start from state '{execution.state}'.")
|
||||
execution.state = StepExecution.State.IN_PROGRESS
|
||||
execution.started_at = timezone.now()
|
||||
execution.assigned_to = user
|
||||
execution.save()
|
||||
return execution
|
||||
|
||||
@transaction.atomic
|
||||
def pause(self, execution) -> StepExecution:
|
||||
execution = self._locked(execution)
|
||||
if execution.state not in self._PAUSABLE:
|
||||
raise ApiError("INVALID_STEP_TRANSITION", f"Cannot pause from state '{execution.state}'.")
|
||||
execution.state = StepExecution.State.PAUSED
|
||||
execution.save()
|
||||
return execution
|
||||
|
||||
@transaction.atomic
|
||||
def resume(self, execution) -> StepExecution:
|
||||
execution = self._locked(execution)
|
||||
if execution.state not in self._RESUMABLE:
|
||||
raise ApiError("INVALID_STEP_TRANSITION", f"Cannot resume from state '{execution.state}'.")
|
||||
execution.state = StepExecution.State.IN_PROGRESS
|
||||
execution.save()
|
||||
return execution
|
||||
|
||||
@transaction.atomic
|
||||
def complete(self, execution, measurements=None, notes="") -> StepExecution:
|
||||
execution = self._locked(execution)
|
||||
if execution.state not in self._COMPLETABLE:
|
||||
raise ApiError("INVALID_STEP_TRANSITION", f"Cannot complete from state '{execution.state}'.")
|
||||
execution.state = StepExecution.State.COMPLETED
|
||||
execution.completed_at = timezone.now()
|
||||
if measurements is not None:
|
||||
execution.measurements = measurements
|
||||
if notes:
|
||||
execution.notes = notes
|
||||
execution.save()
|
||||
return execution
|
||||
|
||||
@transaction.atomic
|
||||
def reject(self, execution, notes="") -> StepExecution:
|
||||
execution = self._locked(execution)
|
||||
if execution.state not in self._REJECTABLE:
|
||||
raise ApiError("INVALID_STEP_TRANSITION", f"Cannot reject from state '{execution.state}'.")
|
||||
execution.state = StepExecution.State.REJECTED
|
||||
execution.notes = notes or execution.notes
|
||||
execution.save()
|
||||
return execution
|
||||
|
||||
@transaction.atomic
|
||||
def rework(self, execution, target_step) -> StepExecution:
|
||||
"""Create a new execution on `target_step`; preserve history via parent link."""
|
||||
execution = self._locked(execution)
|
||||
if execution.state != StepExecution.State.COMPLETED:
|
||||
raise ApiError("INVALID_STEP_TRANSITION", "Only a completed step can trigger rework.")
|
||||
execution.state = StepExecution.State.REWORK_REQUIRED
|
||||
execution.save()
|
||||
latest = (
|
||||
StepExecution.objects.filter(order=execution.order, step=target_step)
|
||||
.order_by("-attempt")
|
||||
.first()
|
||||
)
|
||||
attempt = (latest.attempt if latest else 0) + 1
|
||||
return StepExecution.objects.create(
|
||||
order=execution.order,
|
||||
step=target_step,
|
||||
state=StepExecution.State.READY,
|
||||
attempt=attempt,
|
||||
parent=execution,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _locked(execution) -> StepExecution:
|
||||
return StepExecution.objects.select_for_update().get(pk=execution.pk)
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.processing.views import StepExecutionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", StepExecutionViewSet, basename="step-execution")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,78 @@
|
||||
from rest_framework import decorators, mixins, response, status, viewsets
|
||||
|
||||
from apps.core.errors import ApiError, error_response
|
||||
from apps.processing.models import StepExecution
|
||||
from apps.processing.serializers import StepExecutionSerializer
|
||||
from apps.processing.services import ProcessingService
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class StepExecutionViewSet(
|
||||
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
serializer_class = StepExecutionSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "processing.view",
|
||||
"retrieve": "processing.view",
|
||||
"start": "processing.start",
|
||||
"pause": "processing.pause",
|
||||
"resume": "processing.start",
|
||||
"complete": "processing.complete",
|
||||
"reject": "processing.complete",
|
||||
"rework": "processing.complete",
|
||||
}
|
||||
service = ProcessingService()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = StepExecution.objects.select_related("order", "step", "assigned_to").all()
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(order__customer__user=user)
|
||||
|
||||
def _run(self, request, pk, method, **kwargs):
|
||||
execution = self.get_object()
|
||||
try:
|
||||
if method == "start":
|
||||
execution = self.service.start(execution, request.user)
|
||||
elif method == "rework":
|
||||
from apps.workflows.models import WorkflowStep
|
||||
|
||||
target = WorkflowStep.objects.get(pk=kwargs["target_step"])
|
||||
execution = self.service.rework(execution, target)
|
||||
else:
|
||||
execution = getattr(self.service, method)(execution)
|
||||
except ApiError as exc:
|
||||
return response.Response(error_response(exc.code, exc.message, exc.details), status=exc.status)
|
||||
return response.Response(StepExecutionSerializer(execution).data)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def start(self, request, pk=None):
|
||||
return self._run(request, pk, "start")
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def pause(self, request, pk=None):
|
||||
return self._run(request, pk, "pause")
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def resume(self, request, pk=None):
|
||||
return self._run(request, pk, "resume")
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def complete(self, request, pk=None):
|
||||
return self._run(request, pk, "complete")
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def reject(self, request, pk=None):
|
||||
return self._run(request, pk, "reject")
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def rework(self, request, pk=None):
|
||||
target_step = request.data.get("target_step")
|
||||
if not target_step:
|
||||
return response.Response(
|
||||
error_response("TARGET_STEP_REQUIRED", "A target_step id is required."),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return self._run(request, pk, "rework", target_step=target_step)
|
||||
Reference in New Issue
Block a user