fix error in gitignore

This commit is contained in:
2026-09-24 12:32:59 +03:30
commit 86c117ee2a
165 changed files with 5963 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# processing app
+6
View File
@@ -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')],
},
),
]
+43
View File
@@ -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})"
+16
View File
@@ -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",
]
+94
View File
@@ -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)
+8
View File
@@ -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
+78
View File
@@ -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)