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 @@
# reports app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReportsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reports"
+63
View File
@@ -0,0 +1,63 @@
# Generated by Django 5.2.17 on 2026-09-15 09:29
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('orders', '0001_initial'),
('workflows', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='FinalReport',
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)),
('data', models.JSONField(blank=True, default=dict)),
('generated_at', models.DateTimeField(auto_now_add=True)),
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='final_report', to='orders.processingorder')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ReportDefinition',
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)),
('name', models.CharField(max_length=200)),
('active', models.BooleanField(default=True)),
('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_definitions', to='workflows.workflow')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ReportField',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('key', models.SlugField(max_length=100)),
('label', models.CharField(max_length=200)),
('field_type', models.CharField(choices=[('text', 'Text'), ('integer', 'Integer'), ('decimal', 'Decimal'), ('boolean', 'Boolean'), ('date', 'Date'), ('datetime', 'DateTime'), ('choice', 'Choice'), ('multichoice', 'Multi-choice'), ('measurement', 'Measurement'), ('percentage', 'Percentage'), ('weight', 'Weight'), ('file', 'File / image')], default='text', max_length=16)),
('required', models.BooleanField(default=False)),
('choices', models.JSONField(blank=True, default=list)),
('order', models.PositiveIntegerField(default=0)),
('definition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='reports.reportdefinition')),
],
options={
'ordering': ['order'],
'constraints': [models.UniqueConstraint(fields=('definition', 'key'), name='uniq_report_field_key')],
},
),
]
View File
+48
View File
@@ -0,0 +1,48 @@
from django.db import models
from apps.core.models import TimeStampedModel, UUIDModel
class ReportDefinition(UUIDModel, TimeStampedModel):
workflow = models.ForeignKey("workflows.Workflow", on_delete=models.CASCADE, related_name="report_definitions")
name = models.CharField(max_length=200)
active = models.BooleanField(default=True)
def __str__(self) -> str:
return self.name
class ReportField(UUIDModel):
class FieldType(models.TextChoices):
TEXT = "text", "Text"
INTEGER = "integer", "Integer"
DECIMAL = "decimal", "Decimal"
BOOLEAN = "boolean", "Boolean"
DATE = "date", "Date"
DATETIME = "datetime", "DateTime"
CHOICE = "choice", "Choice"
MULTICHOICE = "multichoice", "Multi-choice"
MEASUREMENT = "measurement", "Measurement"
PERCENTAGE = "percentage", "Percentage"
WEIGHT = "weight", "Weight"
FILE = "file", "File / image"
definition = models.ForeignKey(ReportDefinition, on_delete=models.CASCADE, related_name="fields")
key = models.SlugField(max_length=100)
label = models.CharField(max_length=200)
field_type = models.CharField(max_length=16, choices=FieldType.choices, default=FieldType.TEXT)
required = models.BooleanField(default=False)
choices = models.JSONField(default=list, blank=True)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order"]
constraints = [
models.UniqueConstraint(fields=["definition", "key"], name="uniq_report_field_key")
]
class FinalReport(UUIDModel, TimeStampedModel):
order = models.OneToOneField("orders.ProcessingOrder", on_delete=models.CASCADE, related_name="final_report")
data = models.JSONField(default=dict, blank=True)
generated_at = models.DateTimeField(auto_now_add=True)
+24
View File
@@ -0,0 +1,24 @@
from rest_framework import serializers
from apps.reports.models import FinalReport, ReportDefinition, ReportField
class ReportFieldSerializer(serializers.ModelSerializer):
class Meta:
model = ReportField
fields = "__all__"
read_only_fields = ["id"]
class ReportDefinitionSerializer(serializers.ModelSerializer):
fields = ReportFieldSerializer(many=True, read_only=True)
class Meta:
model = ReportDefinition
fields = ["id", "workflow", "name", "active", "fields"]
class FinalReportSerializer(serializers.ModelSerializer):
class Meta:
model = FinalReport
fields = "__all__"
+194
View File
@@ -0,0 +1,194 @@
"""Final report generation.
Reports are built from the workflow's active report definition and the order's
data (customer, weights, selected steps, price breakdown, execution timeline,
measurements, payments and invoices). The schema is configurable — there is no
hard-coded pistachio quality schema.
"""
from io import BytesIO
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from apps.reports.models import FinalReport, ReportDefinition
GREEN = colors.HexColor("#166534")
DARK = colors.HexColor("#123B24")
LINE = colors.HexColor("#E7E1D4")
CREAM = colors.HexColor("#FAF6EE")
class ReportService:
def generate(self, order) -> FinalReport:
definition = ReportDefinition.objects.filter(
workflow=order.workflow_version.workflow, active=True
).first()
data = self._build_data(order, definition)
report, _ = FinalReport.objects.update_or_create(order=order, defaults={"data": data})
return report
def _build_data(self, order, definition) -> dict:
measurements = {}
timeline = []
for execution in order.step_executions.select_related("step", "assigned_to").order_by("created_at"):
for key, value in (execution.measurements or {}).items():
measurements.setdefault(key, value)
timeline.append({
"step": execution.step.name,
"state": execution.state,
"attempt": execution.attempt,
"operator": execution.assigned_to.username if execution.assigned_to else "",
"started_at": execution.started_at.isoformat() if execution.started_at else "",
"completed_at": execution.completed_at.isoformat() if execution.completed_at else "",
})
fields = []
if definition:
for field in definition.fields.all():
fields.append({
"key": field.key,
"label": field.label,
"type": field.field_type,
"value": measurements.get(field.key, ""),
})
customer = order.customer
customer_name = customer.company_name or str(customer.user)
return {
"order_number": order.order_number,
"customer": {
"name": customer_name,
"code": customer.customer_code or "",
"phone": customer.user.phone,
"email": customer.user.email,
},
"customer_name": customer_name,
"gross_weight_kg": str(order.gross_weight_kg or ""),
"tare_weight_kg": str(order.tare_weight_kg or ""),
"net_weight_kg": str(order.net_weight_kg or ""),
"selected_steps": order.selected_step_keys or [],
"price": order.price_snapshot or {},
"status": order.status,
"created_at": order.created_at.isoformat(),
"confirmed_at": order.confirmed_at.isoformat() if order.confirmed_at else "",
"timeline": timeline,
"fields": fields,
"measurements": measurements,
"payments": [
{"status": p.status, "amount": str(p.amount), "currency": p.currency}
for p in order.payments.all()
],
"invoices": [
{"number": i.number, "sync_state": i.sync_state, "amount": str(i.amount)}
for i in order.invoices.all()
],
}
# ------------------------------------------------------------------ PDF
def render_pdf(self, report) -> bytes:
buf = BytesIO()
doc = SimpleDocTemplate(
buf, pagesize=A4,
rightMargin=18 * mm, leftMargin=18 * mm, topMargin=18 * mm, bottomMargin=18 * mm,
title=f"Cetana report {report.order.order_number}",
)
styles = getSampleStyleSheet()
h2 = ParagraphStyle("H2", parent=styles["Heading2"], textColor=GREEN, spaceBefore=10, spaceAfter=6)
title = ParagraphStyle("T", parent=styles["Title"], fontSize=20, textColor=DARK)
data = report.data or {}
story = [Paragraph("Cetana — Processing Report", title), Spacer(1, 4 * mm)]
# Summary
summary = [
["Order", data.get("order_number", "")],
["Customer", data.get("customer_name", "")],
["Status", data.get("status", "")],
["Net weight (kg)", data.get("net_weight_kg", "")],
["Gross / Tare (kg)", f"{data.get('gross_weight_kg', '')} / {data.get('tare_weight_kg', '')}"],
]
story.append(self._table(summary, [50 * mm, 120 * mm]))
story.append(Spacer(1, 4 * mm))
# Selected steps
steps = data.get("selected_steps") or []
if steps:
story.append(Paragraph("Selected processing steps", h2))
story.append(Paragraph(", ".join(steps), styles["Normal"]))
story.append(Spacer(1, 3 * mm))
# Execution timeline
timeline = data.get("timeline") or []
if timeline:
story.append(Paragraph("Execution timeline", h2))
rows = [["Step", "State", "Attempt", "Operator", "Completed"]]
for item in timeline:
rows.append([
item.get("step", ""), item.get("state", ""), str(item.get("attempt", "")),
item.get("operator", ""), item.get("completed_at", ""),
])
story.append(self._table(rows, [45 * mm, 30 * mm, 18 * mm, 35 * mm, 42 * mm], header=True))
story.append(Spacer(1, 3 * mm))
# Quality / dynamic fields
fields = data.get("fields") or []
if fields:
story.append(Paragraph("Quality & results", h2))
rows = [["Field", "Value"]]
for field in fields:
rows.append([field.get("label", ""), str(field.get("value", "") or "—")])
story.append(self._table(rows, [70 * mm, 100 * mm], header=True))
story.append(Spacer(1, 3 * mm))
# Price breakdown
price = data.get("price") or {}
if price:
story.append(Paragraph("Price breakdown", h2))
rows = [["Item", "Quantity", "Unit price", "Amount"]]
for line in price.get("lines", []):
rows.append([
line.get("rule", ""), line.get("quantity", ""),
line.get("unit_price", ""), line.get("amount", ""),
])
rows.append(["Subtotal", "", "", price.get("subtotal", "")])
rows.append(["Discounts", "", "", price.get("discounts", "")])
rows.append(["Taxes", "", "", price.get("taxes", "")])
rows.append(["TOTAL", "", "", f"{price.get('total', '')} {price.get('currency', '')}"])
story.append(self._table(rows, [70 * mm, 25 * mm, 30 * mm, 45 * mm], header=True))
story.append(Spacer(1, 3 * mm))
# Payments / invoices
payments = data.get("payments") or []
invoices = data.get("invoices") or []
if payments or invoices:
story.append(Paragraph("Payments & invoices", h2))
rows = [["Type", "Reference", "Status", "Amount"]]
for p in payments:
rows.append(["Payment", "—", p.get("status", ""), f"{p.get('amount', '')} {p.get('currency', '')}"])
for i in invoices:
rows.append(["Invoice", i.get("number", ""), i.get("sync_state", ""), i.get("amount", "")])
story.append(self._table(rows, [30 * mm, 60 * mm, 40 * mm, 40 * mm], header=True))
doc.build(story)
return buf.getvalue()
def _table(self, rows, col_widths, header=False):
table = Table(rows, colWidths=col_widths)
style = [
("GRID", (0, 0), (-1, -1), 0.5, LINE),
("FONTSIZE", (0, 0), (-1, -1), 9),
("PADDING", (0, 0), (-1, -1), 5),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.white, CREAM]),
]
if header:
style += [
("BACKGROUND", (0, 0), (-1, 0), GREEN),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
]
table.setStyle(TableStyle(style))
return table
+36
View File
@@ -0,0 +1,36 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from apps.accounts.models import Customer
from apps.orders.models import ProcessingOrder
from apps.reports.services import ReportService
from apps.workflows.models import Workflow, WorkflowVersion
User = get_user_model()
class ReportServiceTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="c", password="***")
self.customer = Customer.objects.create(user=self.user)
wf = Workflow.objects.create(name="W", slug="w-report")
version = WorkflowVersion.objects.create(workflow=wf, version=1)
self.order = ProcessingOrder.objects.create(
customer=self.customer,
workflow_version=version,
net_weight_kg=Decimal("920"),
price_snapshot={"total": "4600000.00", "currency": "IRR"},
)
def test_generate_builds_data(self):
report = ReportService().generate(self.order)
self.assertEqual(report.data["order_number"], self.order.order_number)
self.assertEqual(report.data["net_weight_kg"], "920")
def test_render_pdf(self):
report = ReportService().generate(self.order)
pdf = ReportService().render_pdf(report)
self.assertGreater(len(pdf), 1000)
self.assertTrue(pdf.startswith(b"%PDF"))
+9
View File
@@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from apps.reports.views import FinalReportViewSet, ReportDefinitionViewSet
router = DefaultRouter()
router.register("definitions", ReportDefinitionViewSet, basename="report-definition")
router.register("", FinalReportViewSet, basename="final-report")
urlpatterns = router.urls
+69
View File
@@ -0,0 +1,69 @@
from django.http import HttpResponse
from rest_framework import decorators, mixins, response, status, viewsets
from apps.core.errors import error_response
from apps.orders.models import ProcessingOrder
from apps.rbac.permissions import ActionPermission
from apps.reports.models import FinalReport, ReportDefinition
from apps.reports.serializers import FinalReportSerializer, ReportDefinitionSerializer
from apps.reports.services import ReportService
class ReportDefinitionViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = ReportDefinition.objects.prefetch_related("fields").all()
serializer_class = ReportDefinitionSerializer
permission_classes = [ActionPermission]
code_map = {"list": "report.view", "retrieve": "report.view"}
class FinalReportViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = FinalReport.objects.select_related("order").all()
serializer_class = FinalReportSerializer
permission_classes = [ActionPermission]
code_map = {
"list": "report.view",
"retrieve": "report.view",
"generate": "report.create",
"pdf": "report.view",
}
def get_queryset(self):
user = self.request.user
qs = FinalReport.objects.select_related("order").all()
if user.is_superuser:
return qs
return qs.filter(order__customer__user=user)
@decorators.action(detail=False, methods=["post"])
def generate(self, request):
order_id = request.data.get("order")
if not order_id:
return response.Response(
error_response("ORDER_REQUIRED", "An order id is required."),
status=status.HTTP_400_BAD_REQUEST,
)
try:
order = ProcessingOrder.objects.select_related("customer").get(pk=order_id)
except ProcessingOrder.DoesNotExist:
return response.Response(
error_response("ORDER_NOT_FOUND", "Order not found."),
status=status.HTTP_404_NOT_FOUND,
)
if not request.user.is_superuser and order.customer.user_id != request.user.id:
return response.Response(
error_response("FORBIDDEN", "You do not have access to this order."),
status=status.HTTP_403_FORBIDDEN,
)
report = ReportService().generate(order)
return response.Response(FinalReportSerializer(report).data)
@decorators.action(detail=True, methods=["get"])
def pdf(self, request, pk=None):
report = self.get_object()
pdf_bytes = ReportService().render_pdf(report)
filename = f"{report.order.order_number}.pdf"
return HttpResponse(
pdf_bytes,
content_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)