refactor(B1): migrate to domain-driven project structure

Move all models/schemas/services/routers into app/domains/.
Keep backward-compat shims in old locations for imports.
Preserves domains/rendering/tasks.py from Phase A.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 16:24:11 +01:00
parent 82bf46725b
commit b87df4a3e5
69 changed files with 1729 additions and 1831 deletions
+1
View File
@@ -0,0 +1 @@
# Domain: admin
+6
View File
@@ -0,0 +1,6 @@
# Re-export from original routers.
from app.api.routers.admin import router as admin_router
from app.api.routers.analytics import router as analytics_router
from app.api.routers.worker import router as worker_router
__all__ = ["admin_router", "analytics_router", "worker_router"]
+6
View File
@@ -0,0 +1,6 @@
"""Admin / KPI service."""
# Re-export from original service file for backward compatibility.
from app.services import kpi_service
__all__ = ["kpi_service"]
+1
View File
@@ -0,0 +1 @@
# Domain: auth
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
import enum
class UserRole(str, enum.Enum):
admin = "admin"
project_manager = "project_manager"
client = "client"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.client, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
orders: Mapped[list["Order"]] = relationship("Order", back_populates="created_by_user", foreign_keys="Order.created_by")
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="user", foreign_keys="AuditLog.user_id")
+3
View File
@@ -0,0 +1,3 @@
# Re-export from original router.
from app.api.routers.auth import router
__all__ = ["router"]
+39
View File
@@ -0,0 +1,39 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr
from app.domains.auth.models import UserRole
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: str
role: UserRole = UserRole.client
class UserUpdate(BaseModel):
full_name: str | None = None
is_active: bool | None = None
role: UserRole | None = None
class UserOut(BaseModel):
id: uuid.UUID
email: str
full_name: str
role: UserRole
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
class LoginRequest(BaseModel):
email: EmailStr
password: str
+1
View File
@@ -0,0 +1 @@
# Domain: billing
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PricingTier(Base):
__tablename__ = "pricing_tiers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category_key: Mapped[str] = mapped_column(String(100), nullable=False)
quality_level: Mapped[str] = mapped_column(String(50), nullable=False, default="Normal")
price_per_item: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
output_types: Mapped[list["OutputType"]] = relationship("OutputType", back_populates="pricing_tier")
__table_args__ = (
UniqueConstraint("category_key", "quality_level", name="uq_pricing_tier"),
Index("ix_pricing_tiers_category_key", "category_key"),
)
+4
View File
@@ -0,0 +1,4 @@
# Re-export from original router.
from app.api.routers.pricing import router
__all__ = ["router"]
+183
View File
@@ -0,0 +1,183 @@
"""Pricing service — price lookup and order price computation.
Price resolution cascade for order lines:
1. OutputType's linked pricing_tier (if active) → use its price_per_item
2. Product's category_key → look up PricingTier by category
3. "default" category tier → global fallback
4. None if nothing configured
"""
from decimal import Decimal
from typing import Any
from sqlalchemy import select, update as sql_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.domains.billing.models import PricingTier
async def get_price_for(
db: AsyncSession,
category_key: str,
quality_level: str = "Normal",
) -> Decimal | None:
"""Return price_per_item for the given category + quality level."""
# 1. Exact match
result = await db.execute(
select(PricingTier).where(
PricingTier.category_key == category_key,
PricingTier.quality_level == quality_level,
PricingTier.is_active.is_(True),
)
)
tier = result.scalar_one_or_none()
if tier is not None:
return tier.price_per_item
if category_key == "default":
return None
# 2. Fallback: default category
result = await db.execute(
select(PricingTier).where(
PricingTier.category_key == "default",
PricingTier.quality_level == quality_level,
PricingTier.is_active.is_(True),
)
)
tier = result.scalar_one_or_none()
return tier.price_per_item if tier is not None else None
async def resolve_line_price(
db: AsyncSession,
output_type_id: str | None,
product_category_key: str | None,
) -> Decimal | None:
"""Resolve the unit price for a single order line using the cascade."""
if output_type_id is not None:
from app.domains.rendering.models import OutputType
result = await db.execute(
select(OutputType)
.options(selectinload(OutputType.pricing_tier))
.where(OutputType.id == output_type_id)
)
ot = result.scalar_one_or_none()
if ot and ot.pricing_tier and ot.pricing_tier.is_active:
return ot.pricing_tier.price_per_item
# Step 2+3: category lookup with default fallback
cat = product_category_key or "default"
return await get_price_for(db, cat)
async def estimate_order_price(
db: AsyncSession,
lines: list[dict[str, Any]],
) -> dict:
"""Estimate price for a list of prospective order lines."""
from app.domains.products.models import Product
breakdown: list[dict] = []
total = Decimal("0.00")
has_unpriced = False
for line in lines:
product_id = line.get("product_id")
output_type_id = line.get("output_type_id")
# Get product category
cat = None
if product_id:
prod_result = await db.execute(
select(Product).where(Product.id == product_id)
)
prod = prod_result.scalar_one_or_none()
if prod:
cat = prod.category_key
price = await resolve_line_price(db, output_type_id, cat)
breakdown.append({
"output_type_id": str(output_type_id) if output_type_id else None,
"product_id": str(product_id) if product_id else None,
"unit_price": float(price) if price is not None else None,
})
if price is not None:
total += price
else:
has_unpriced = True
return {
"total": float(total),
"line_count": len(lines),
"breakdown": breakdown,
"has_unpriced": has_unpriced,
}
async def refresh_order_price(db: AsyncSession, order_id) -> Decimal | None:
"""Re-fetch order + lines, resolve per-line prices, snapshot to unit_price, update order total."""
from app.domains.orders.models import Order, OrderLine
from app.domains.rendering.models import OutputType
order_result = await db.execute(select(Order).where(Order.id == order_id))
order = order_result.scalar_one_or_none()
if order is None:
return None
lines_result = await db.execute(
select(OrderLine)
.options(
selectinload(OrderLine.output_type).selectinload(OutputType.pricing_tier),
selectinload(OrderLine.product),
)
.where(
OrderLine.order_id == order_id,
OrderLine.output_type_id.is_not(None),
)
)
lines = lines_result.scalars().all()
if not lines:
await db.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(estimated_price=Decimal("0.00"))
)
await db.commit()
return Decimal("0.00")
total = Decimal("0.00")
any_priced = False
for line in lines:
# Cascade: 1) OT pricing tier, 2) product category, 3) default
price = None
if line.output_type and line.output_type.pricing_tier and line.output_type.pricing_tier.is_active:
price = line.output_type.pricing_tier.price_per_item
else:
cat = line.product.category_key if line.product else None
price = await get_price_for(db, cat or "default")
# Snapshot to line
await db.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(unit_price=price)
)
if price is not None:
total += price
any_priced = True
new_price = total if any_priced else None
await db.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(estimated_price=new_price)
)
await db.commit()
return new_price
+1
View File
@@ -0,0 +1 @@
# Domain: imports
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class Template(Base):
__tablename__ = "templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
category_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
# JSONB config for each of the 11 standard columns: {col_index: {label, required, optional}}
standard_fields: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# JSONB schema for expected component pairs
component_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
description: Mapped[str] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
orders: Mapped[list["Order"]] = relationship("Order", back_populates="template")
+5
View File
@@ -0,0 +1,5 @@
# Re-export from original routers.
from app.api.routers.uploads import router as uploads_router
from app.api.routers.templates import router as templates_router
__all__ = ["uploads_router", "templates_router"]
+43
View File
@@ -0,0 +1,43 @@
from pydantic import BaseModel
from typing import Any
class ParsedComponent(BaseModel):
part_name: str | None = None
material: str | None = None
component_type: str | None = None
column_index: int
class ParsedRow(BaseModel):
row_index: int
ebene1: str | None = None
ebene2: str | None = None
baureihe: str | None = None
pim_id: str | None = None
produkt_baureihe: str | None = None
gewaehltes_produkt: str | None = None
name_cad_modell: str | None = None
gewuenschte_bildnummer: str | None = None
lagertyp: str | None = None
medias_rendering: bool | None = None
components: list[ParsedComponent] = []
class ParsedExcelResponse(BaseModel):
filename: str
excel_path: str | None = None # server-side path of the saved file
category_key: str | None = None
template_name: str | None = None
row_count: int
column_headers: list[str]
rows: list[ParsedRow]
warnings: list[str] = []
class StepUploadResponse(BaseModel):
cad_file_id: str
original_name: str
file_hash: str
status: str
matched_items: list[str] = []
+12
View File
@@ -0,0 +1,12 @@
"""Import services — Excel parsing and product import."""
# Re-export from original service files for backward compatibility.
from app.services.excel_parser import parse_excel, parsed_excel_to_dict
from app.services.excel_import import import_excel_to_products, preview_excel_rows
__all__ = [
"parse_excel",
"parsed_excel_to_dict",
"import_excel_to_products",
"preview_excel_rows",
]
@@ -0,0 +1 @@
# Domain: materials
+37
View File
@@ -0,0 +1,37 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Text, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Material(Base):
__tablename__ = "materials"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True)
description: Mapped[str] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
schaeffler_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="select") # type: ignore[name-defined]
aliases = relationship("MaterialAlias", back_populates="material", cascade="all, delete-orphan")
class MaterialAlias(Base):
__tablename__ = "material_aliases"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
material_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
)
alias: Mapped[str] = mapped_column(String(300), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
material = relationship("Material", back_populates="aliases")
+4
View File
@@ -0,0 +1,4 @@
# Re-export from original router.
from app.api.routers.materials import router
__all__ = ["router"]
+140
View File
@@ -0,0 +1,140 @@
"""Material alias resolution service.
Used from Celery tasks (sync context) to resolve raw material names
(from Excel / user input) to SCHAEFFLER library material names via aliases.
Resolution chain:
1. Alias lookup (case-insensitive) → use alias.material.name
2. Exact Material.name match (case-insensitive) → use it
3. Pass through unchanged → Blender will show FailedMaterial magenta
"""
import logging
from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session, selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.domains.materials.models import Material, MaterialAlias
logger = logging.getLogger(__name__)
_engine = None
def _get_engine():
global _engine
if _engine is None:
from app.config import settings as app_settings
_engine = create_engine(app_settings.database_url_sync)
return _engine
def resolve_material_map(raw_map: dict[str, str]) -> dict[str, str]:
"""Resolve raw material names to SCHAEFFLER library names via aliases.
For each value in raw_map:
1. Alias lookup (case-insensitive) → return alias.material.name
2. Exact Material.name match (case-insensitive) → use canonical name
3. Pass through unchanged
Returns a new dict with the same keys but resolved material names.
"""
if not raw_map:
return raw_map
engine = _get_engine()
with Session(engine) as session:
# Load all materials
materials = session.execute(
select(Material).options(selectinload(Material.aliases))
).scalars().all()
# Build lookup dicts (case-insensitive)
# material name (lower) → canonical Material.name
name_lookup: dict[str, str] = {}
# alias (lower) → Material.name
alias_lookup: dict[str, str] = {}
for mat in materials:
name_lookup[mat.name.lower()] = mat.name
for a in mat.aliases:
alias_lookup[a.alias.lower()] = mat.name
resolved = {}
for part_name, raw_material in raw_map.items():
raw_lower = raw_material.lower()
# 1. Alias lookup first — aliases explicitly map intermediate/display names
# to the canonical SCHAEFFLER library names
if raw_lower in alias_lookup:
target = alias_lookup[raw_lower]
logger.info("resolved '%s''%s' (alias match)", raw_material, target)
resolved[part_name] = target
continue
# 2. Exact material name match (canonical name used as-is)
if raw_lower in name_lookup:
canonical = name_lookup[raw_lower]
if canonical != raw_material:
logger.info("resolved '%s''%s' (exact name match)", raw_material, canonical)
resolved[part_name] = canonical
continue
# 3. Pass through unchanged
logger.warning("no material match for '%s' — will use FailedMaterial fallback", raw_material)
resolved[part_name] = raw_material
return resolved
async def seed_material_aliases_from_mappings(
db: AsyncSession, mappings: list[dict]
) -> dict:
"""Seed material aliases from Excel materialmapping sheet.
For each {display_name, render_name}:
- Find or create Material by render_name
- Add display_name as alias if not already present
Returns {"created": N, "skipped": N}.
"""
created = 0
skipped = 0
for mapping in mappings:
display_name = mapping.get("display_name", "").strip()
render_name = mapping.get("render_name", "").strip()
if not display_name or not render_name:
skipped += 1
continue
# Find or create Material by render_name
result = await db.execute(
select(Material).where(func.lower(Material.name) == render_name.lower())
)
material = result.scalar_one_or_none()
if material is None:
material = Material(name=render_name, source="excel_mapping")
db.add(material)
await db.flush()
# Check if alias already exists
alias_result = await db.execute(
select(MaterialAlias).where(
func.lower(MaterialAlias.alias) == display_name.lower()
)
)
existing_alias = alias_result.scalar_one_or_none()
if existing_alias:
skipped += 1
continue
# Create alias
alias = MaterialAlias(material_id=material.id, alias=display_name)
db.add(alias)
created += 1
if created > 0:
await db.flush()
return {"created": created, "skipped": skipped}
@@ -0,0 +1 @@
# Domain: notifications
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
action: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str] = mapped_column(String(100), nullable=True)
entity_id: Mapped[str] = mapped_column(String(255), nullable=True)
details: Mapped[dict] = mapped_column(JSONB, nullable=True)
timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
# Notification center columns
target_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
)
read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
notification: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship("User", back_populates="audit_logs", foreign_keys=[user_id])
target_user: Mapped["User"] = relationship("User", foreign_keys=[target_user_id])
@@ -0,0 +1,4 @@
# Re-export from original router.
from app.api.routers.notifications import router
__all__ = ["router"]
@@ -0,0 +1,84 @@
"""Notification emission helpers.
Provides async (for routers) and sync (for Celery tasks) entry points
to create notification rows in the audit_log table.
"""
import logging
import uuid
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from app.domains.notifications.models import AuditLog
logger = logging.getLogger(__name__)
_engine = None
def _get_engine():
global _engine
if _engine is None:
from app.config import settings as app_settings
_engine = create_engine(app_settings.database_url_sync)
return _engine
async def emit_notification(
db: AsyncSession,
*,
actor_user_id: str | uuid.UUID | None = None,
target_user_id: str | uuid.UUID | None = None,
action: str,
entity_type: str | None = None,
entity_id: str | None = None,
details: dict | None = None,
) -> None:
"""Create a notification (async — for use inside FastAPI routers)."""
try:
entry = AuditLog(
user_id=str(actor_user_id) if actor_user_id else None,
target_user_id=str(target_user_id) if target_user_id else None,
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id else None,
details=details,
notification=True,
timestamp=datetime.utcnow(),
)
db.add(entry)
await db.commit()
except Exception:
logger.exception("Failed to emit notification (async)")
await db.rollback()
def emit_notification_sync(
*,
actor_user_id: str | uuid.UUID | None = None,
target_user_id: str | uuid.UUID | None = None,
action: str,
entity_type: str | None = None,
entity_id: str | None = None,
details: dict | None = None,
) -> None:
"""Create a notification (sync — for use inside Celery tasks)."""
engine = _get_engine()
try:
with Session(engine) as session:
entry = AuditLog(
user_id=str(actor_user_id) if actor_user_id else None,
target_user_id=str(target_user_id) if target_user_id else None,
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id else None,
details=details,
notification=True,
timestamp=datetime.utcnow(),
)
session.add(entry)
session.commit()
except Exception:
logger.exception("Failed to emit notification (sync)")
+1
View File
@@ -0,0 +1 @@
# Domain: orders
+150
View File
@@ -0,0 +1,150 @@
import uuid
import enum
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Text, Integer, Boolean, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class OrderStatus(str, enum.Enum):
draft = "draft"
submitted = "submitted"
processing = "processing"
completed = "completed"
rejected = "rejected"
class Order(Base):
__tablename__ = "orders"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_number: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("templates.id"), nullable=True)
status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus), default=OrderStatus.draft, nullable=False)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
source_excel: Mapped[str] = mapped_column(String(1000), nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
processing_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
estimated_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
template: Mapped["Template"] = relationship("Template", back_populates="orders")
created_by_user: Mapped["User"] = relationship("User", back_populates="orders", foreign_keys=[created_by])
items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="order", cascade="all, delete-orphan"
)
class ItemStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class AIValidationStatus(str, enum.Enum):
not_started = "not_started"
pending = "pending"
completed = "completed"
failed = "failed"
class OrderItem(Base):
__tablename__ = "order_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("orders.id"), nullable=False)
row_index: Mapped[int] = mapped_column(Integer, nullable=False)
# 11 Standard fields (columns 0-10, skip col 5)
ebene1: Mapped[str] = mapped_column(String(500), nullable=True)
ebene2: Mapped[str] = mapped_column(String(500), nullable=True)
baureihe: Mapped[str] = mapped_column(String(500), nullable=True)
pim_id: Mapped[str] = mapped_column(String(500), nullable=True)
produkt_baureihe: Mapped[str] = mapped_column(String(500), nullable=True)
# col 5 is skipped (separator)
gewaehltes_produkt: Mapped[str] = mapped_column(String(500), nullable=True)
name_cad_modell: Mapped[str] = mapped_column(String(500), nullable=True)
gewuenschte_bildnummer: Mapped[str] = mapped_column(String(500), nullable=True)
lagertyp: Mapped[str] = mapped_column(String(500), nullable=True)
medias_rendering: Mapped[bool] = mapped_column(Boolean, nullable=True)
# Component pairs (cols 11+): [{part_name, material, component_type, column_index}]
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# CAD linkage
cad_file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cad_files.id"), nullable=True)
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
# Material assignments per CAD part: [{part_name, material}]
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# AI validation
ai_validation_status: Mapped[AIValidationStatus] = mapped_column(
SAEnum(AIValidationStatus), default=AIValidationStatus.not_started, nullable=False
)
ai_validation_result: Mapped[dict] = mapped_column(JSONB, nullable=True)
item_status: Mapped[ItemStatus] = mapped_column(SAEnum(ItemStatus), default=ItemStatus.pending, nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
order: Mapped["Order"] = relationship("Order", back_populates="items")
cad_file: Mapped["CadFile"] = relationship("CadFile", back_populates="order_items")
@property
def cad_parsed_objects(self) -> list[str] | None:
"""Part names extracted from the linked STEP file, for Pydantic serialization."""
if self.cad_file and self.cad_file.parsed_objects:
return self.cad_file.parsed_objects.get("objects") or []
return None
class OrderLine(Base):
__tablename__ = "order_lines"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, index=True
)
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, index=True
)
output_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("output_types.id"), nullable=True
)
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
item_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
render_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
result_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
render_log: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
ai_validation_status: Mapped[str] = mapped_column(String(20), nullable=False, default="not_started")
ai_validation_result: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
flamenco_job_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
render_backend_used: Mapped[str | None] = mapped_column(String(20), nullable=True)
render_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
render_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
unit_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
render_position_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("product_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
order: Mapped["Order"] = relationship("Order", back_populates="lines")
product: Mapped["Product"] = relationship("Product", back_populates="order_lines")
output_type: Mapped["OutputType | None"] = relationship("OutputType", back_populates="order_lines")
render_position: Mapped["ProductRenderPosition | None"] = relationship(
"ProductRenderPosition", back_populates="order_lines"
)
+5
View File
@@ -0,0 +1,5 @@
# Re-export from original routers.
from app.api.routers.orders import router as orders_router
from app.api.routers.order_items import router as order_items_router
__all__ = ["orders_router", "order_items_router"]
+124
View File
@@ -0,0 +1,124 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from app.domains.orders.models import OrderStatus, ItemStatus, AIValidationStatus
class ComponentData(BaseModel):
part_name: str | None = None
material: str | None = None
component_type: str | None = None
column_index: int | None = None
class OrderItemCreate(BaseModel):
row_index: int
ebene1: str | None = None
ebene2: str | None = None
baureihe: str | None = None
pim_id: str | None = None
produkt_baureihe: str | None = None
gewaehltes_produkt: str | None = None
name_cad_modell: str | None = None
gewuenschte_bildnummer: str | None = None
lagertyp: str | None = None
medias_rendering: bool | None = None
components: list[ComponentData] = []
class OrderItemOut(BaseModel):
id: uuid.UUID
order_id: uuid.UUID
row_index: int
ebene1: str | None
ebene2: str | None
baureihe: str | None
pim_id: str | None
produkt_baureihe: str | None
gewaehltes_produkt: str | None
name_cad_modell: str | None
gewuenschte_bildnummer: str | None
lagertyp: str | None
medias_rendering: bool | None
components: list[dict]
cad_file_id: uuid.UUID | None
thumbnail_path: str | None
cad_parsed_objects: list[str] | None = None
cad_part_materials: list[dict] = []
ai_validation_status: AIValidationStatus
ai_validation_result: dict | None
item_status: ItemStatus
notes: str | None
created_at: datetime
model_config = {"from_attributes": True}
class OrderLineCreate(BaseModel):
product_id: uuid.UUID
output_type_id: uuid.UUID | None = None
render_position_id: uuid.UUID | None = None
gewuenschte_bildnummer: str | None = None
notes: str | None = None
class OrderLineOut(BaseModel):
id: uuid.UUID
order_id: uuid.UUID
product_id: uuid.UUID
product: Any # ProductOut — forward ref to avoid circular imports
output_type_id: uuid.UUID | None
output_type: Any | None # OutputTypeOut
gewuenschte_bildnummer: str | None
item_status: str
render_status: str
result_path: str | None
thumbnail_url: str | None = None
ai_validation_status: str
ai_validation_result: dict | None
render_backend_used: str | None = None
flamenco_job_id: str | None = None
unit_price: float | None = None
render_position_id: uuid.UUID | None = None
render_position_name: str | None = None
notes: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class OrderCreate(BaseModel):
template_id: uuid.UUID | None = None
source_excel: str | None = None
notes: str | None = None
items: list[OrderItemCreate] = []
lines: list[OrderLineCreate] = []
class OrderOut(BaseModel):
id: uuid.UUID
order_number: str
template_id: uuid.UUID | None
status: OrderStatus
created_by: uuid.UUID
source_excel: str | None
notes: str | None
created_at: datetime
updated_at: datetime
submitted_at: datetime | None = None
processing_started_at: datetime | None = None
completed_at: datetime | None = None
rejected_at: datetime | None = None
estimated_price: float | None = None
item_count: int = 0
line_count: int = 0
render_progress: dict | None = None
model_config = {"from_attributes": True}
class OrderDetailOut(OrderOut):
items: list[OrderItemOut] = []
lines: list[OrderLineOut] = []
+101
View File
@@ -0,0 +1,101 @@
"""Order service — order number generation and business logic."""
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, create_engine, update as sql_update
from sqlalchemy.orm import Session
from app.domains.orders.models import Order, OrderLine, OrderStatus
import logging
logger = logging.getLogger(__name__)
async def generate_order_number(db: AsyncSession) -> str:
"""Generate next sequential order number: SA-2026-XXXXX."""
year = datetime.utcnow().year
prefix = f"SA-{year}-"
# Use MAX to find the highest existing sequence number this year.
# COUNT-based approach breaks when orders are deleted (produces duplicates).
result = await db.execute(
select(func.max(Order.order_number)).where(Order.order_number.like(f"{prefix}%"))
)
max_num = result.scalar()
if max_num:
last_seq = int(max_num.split("-")[-1])
return f"{prefix}{last_seq + 1:05d}"
return f"{prefix}00001"
def check_order_completion(order_id: str) -> bool:
"""If all renderable lines are done, auto-advance order to completed.
Called from Celery tasks (sync context).
Returns True if the order was advanced to completed.
"""
from app.config import settings as app_settings
sync_url = app_settings.database_url.replace("+asyncpg", "")
engine = create_engine(sync_url)
try:
with Session(engine) as session:
# Get all lines that have an output type (i.e. renderable)
lines = session.execute(
select(OrderLine).where(
OrderLine.order_id == order_id,
OrderLine.output_type_id.isnot(None),
)
).scalars().all()
if not lines:
return False
# Check if all renderable lines are in a terminal state
all_terminal = all(
line.render_status in ("completed", "failed", "cancelled")
for line in lines
)
if not all_terminal:
return False
# Check order is still in processing state
order = session.execute(
select(Order).where(Order.id == order_id)
).scalar_one_or_none()
if order is None or order.status != OrderStatus.processing:
return False
# Auto-advance to completed
now = datetime.utcnow()
session.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(
status=OrderStatus.completed,
completed_at=now,
updated_at=now,
)
)
session.commit()
logger.info(f"Order {order_id} auto-advanced to completed (all {len(lines)} lines done)")
# Notify order creator
try:
from app.domains.notifications.service import emit_notification_sync
emit_notification_sync(
actor_user_id=None,
target_user_id=str(order.created_by),
action="order.completed",
entity_type="order",
entity_id=str(order_id),
details={"order_number": order.order_number},
)
except Exception:
logger.exception("Failed to emit order.completed notification")
return True
finally:
engine.dispose()
+1
View File
@@ -0,0 +1 @@
# Domain: products
+97
View File
@@ -0,0 +1,97 @@
import uuid
import enum
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey, BigInteger, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class ProcessingStatus(str, enum.Enum):
pending = "pending"
processing = "processing"
completed = "completed"
failed = "failed"
class CadFile(Base):
__tablename__ = "cad_files"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
original_name: Mapped[str] = mapped_column(String(500), nullable=False)
stored_path: Mapped[str] = mapped_column(String(1000), nullable=False)
file_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=True)
parsed_objects: Mapped[dict] = mapped_column(JSONB, nullable=True)
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
gltf_path: Mapped[str] = mapped_column(String(1000), nullable=True)
processing_status: Mapped[ProcessingStatus] = mapped_column(
SAEnum(ProcessingStatus), default=ProcessingStatus.pending, nullable=False
)
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
render_log: Mapped[dict] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
order_items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="cad_file")
products: Mapped[list["Product"]] = relationship("Product", back_populates="cad_file")
class Product(Base):
__tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
pim_id: Mapped[str] = mapped_column(String(500), nullable=False)
name: Mapped[str | None] = mapped_column(String(500), nullable=True)
category_key: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
ebene1: Mapped[str | None] = mapped_column(String(500), nullable=True)
ebene2: Mapped[str | None] = mapped_column(String(500), nullable=True)
baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
produkt_baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
lagertyp: Mapped[str | None] = mapped_column(String(500), nullable=True)
name_cad_modell: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
medias_rendering: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
cad_file_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("cad_files.id", ondelete="SET NULL"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
arbeitspaket: Mapped[str | None] = mapped_column(String(500), nullable=True)
source_excel: Mapped[str | None] = mapped_column(String(1000), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
cad_file: Mapped["CadFile | None"] = relationship("CadFile", back_populates="products")
order_lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="product", cascade="all, delete-orphan"
)
render_positions: Mapped[list["ProductRenderPosition"]] = relationship(
"ProductRenderPosition", back_populates="product",
cascade="all, delete-orphan", order_by="ProductRenderPosition.sort_order"
)
@property
def thumbnail_url(self) -> str | None:
if self.cad_file and self.cad_file.thumbnail_path:
from pathlib import Path
return f"/thumbnails/{Path(self.cad_file.thumbnail_path).name}"
return None
@property
def processing_status(self) -> str | None:
if self.cad_file:
return self.cad_file.processing_status.value if hasattr(
self.cad_file.processing_status, 'value'
) else str(self.cad_file.processing_status)
return None
@property
def cad_parsed_objects(self) -> list[str] | None:
if self.cad_file and self.cad_file.parsed_objects:
return self.cad_file.parsed_objects.get("objects") or []
return None
+7
View File
@@ -0,0 +1,7 @@
# Re-export from original routers (products and cad kept as separate files).
# The original app/api/routers/products.py and app/api/routers/cad.py are
# the canonical implementations; they are imported directly in main.py.
from app.api.routers.products import router as products_router
from app.api.routers.cad import router as cad_router
__all__ = ["products_router", "cad_router"]
+72
View File
@@ -0,0 +1,72 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
from app.domains.rendering.schemas import RenderPositionOut
class ProductCreate(BaseModel):
pim_id: str
name: str | None = None
category_key: str | None = None
ebene1: str | None = None
ebene2: str | None = None
baureihe: str | None = None
produkt_baureihe: str | None = None
lagertyp: str | None = None
name_cad_modell: str | None = None
gewuenschte_bildnummer: str | None = None
medias_rendering: bool | None = None
components: list[dict] = []
cad_part_materials: list[dict] = []
notes: str | None = None
is_active: bool = True
source_excel: str | None = None
class ProductPatch(BaseModel):
name: str | None = None
category_key: str | None = None
ebene1: str | None = None
ebene2: str | None = None
baureihe: str | None = None
produkt_baureihe: str | None = None
lagertyp: str | None = None
name_cad_modell: str | None = None
gewuenschte_bildnummer: str | None = None
medias_rendering: bool | None = None
components: list[dict] | None = None
cad_part_materials: list[dict] | None = None
notes: str | None = None
is_active: bool | None = None
class ProductOut(BaseModel):
id: uuid.UUID
pim_id: str
name: str | None
category_key: str | None
ebene1: str | None
ebene2: str | None
baureihe: str | None
produkt_baureihe: str | None
lagertyp: str | None
name_cad_modell: str | None
gewuenschte_bildnummer: str | None
medias_rendering: bool | None
components: list[dict]
cad_part_materials: list[dict]
cad_file_id: uuid.UUID | None
thumbnail_url: str | None = None
render_image_url: str | None = None
processing_status: str | None = None
stl_cached: list[str] = []
cad_parsed_objects: list[str] | None = None
arbeitspaket: str | None = None
notes: str | None
is_active: bool
source_excel: str | None
render_positions: list[RenderPositionOut] = []
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+141
View File
@@ -0,0 +1,141 @@
"""Product service — lookup/create products, link CAD files."""
import uuid
from sqlalchemy import select, func, update as sql_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.domains.products.models import Product
# Default render positions added to every newly created product.
DEFAULT_RENDER_POSITIONS = [
{"name": "3/4 Front", "rotation_x": -15.0, "rotation_y": 45.0, "rotation_z": 0.0, "is_default": True, "sort_order": 0},
{"name": "3/4 Rear", "rotation_x": -15.0, "rotation_y": -135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 1},
{"name": "Default", "rotation_x": 0.0, "rotation_y": 0.0, "rotation_z": 0.0, "is_default": False, "sort_order": 2},
]
async def create_default_positions(db: AsyncSession, product_id: uuid.UUID) -> None:
"""Insert the default render positions for a newly created product."""
from app.domains.rendering.models import ProductRenderPosition
for pos_data in DEFAULT_RENDER_POSITIONS:
db.add(ProductRenderPosition(product_id=product_id, **pos_data))
await db.flush()
def _fill_missing_fields(product: Product, pim_id: str | None, fields: dict) -> None:
"""Fill in null/empty fields on an existing product without overwriting manual edits."""
if pim_id and not product.pim_id:
product.pim_id = pim_id
for attr in (
"name", "category_key", "ebene1", "ebene2", "baureihe",
"lagertyp", "name_cad_modell", "arbeitspaket",
):
if fields.get(attr) and not getattr(product, attr, None):
setattr(product, attr, fields[attr])
# Update medias_rendering if not set
if fields.get("medias_rendering") is not None and product.medias_rendering is None:
product.medias_rendering = fields["medias_rendering"]
# Always update components from the latest Excel import (needed for auto-reassign)
if fields.get("components"):
product.components = fields["components"]
async def lookup_product(
db: AsyncSession, pim_id: str | None, produkt_baureihe: str | None
) -> Product | None:
"""Read-only lookup: produkt_baureihe (primary), then pim_id (fallback).
Same cascade as lookup_or_create_product but never creates or mutates.
"""
if produkt_baureihe:
result = await db.execute(
select(Product).where(
func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(),
Product.is_active.is_(True),
)
)
product = result.scalar_one_or_none()
if product is not None:
return product
# baureihe provided but not found — skip pim_id fallback (same logic)
return None
if pim_id:
result = await db.execute(
select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True))
)
return result.scalar_one_or_none()
return None
async def lookup_or_create_product(
db: AsyncSession, pim_id: str | None, fields: dict
) -> tuple[Product, bool]:
"""Look up by produkt_baureihe (primary), then pim_id (fallback). Create if not found.
Returns (product, was_created).
Does NOT overwrite existing fields — preserves manual edits.
"""
produkt_baureihe = fields.get("produkt_baureihe")
# Primary lookup: by produkt_baureihe (case-insensitive)
if produkt_baureihe:
result = await db.execute(
select(Product).where(
func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(),
Product.is_active.is_(True),
)
)
product = result.scalar_one_or_none()
if product is not None:
_fill_missing_fields(product, pim_id, fields)
await db.flush()
return product, False
# produkt_baureihe was provided but not found — each baureihe is a
# distinct product, so skip the pim_id fallback and create a new one.
# Fallback lookup: by pim_id (only when produkt_baureihe is absent,
# e.g. old per-category Excel files that don't have a Baureihe column).
if not produkt_baureihe and pim_id:
result = await db.execute(
select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True))
)
product = result.scalar_one_or_none()
if product is not None:
_fill_missing_fields(product, pim_id, fields)
await db.flush()
return product, False
product = Product(
pim_id=pim_id or f"auto-{uuid.uuid4().hex[:8]}",
name=fields.get("name"),
category_key=fields.get("category_key"),
ebene1=fields.get("ebene1"),
ebene2=fields.get("ebene2"),
baureihe=fields.get("baureihe"),
produkt_baureihe=produkt_baureihe,
lagertyp=fields.get("lagertyp"),
name_cad_modell=fields.get("name_cad_modell"),
arbeitspaket=fields.get("arbeitspaket"),
components=fields.get("components", []),
cad_part_materials=fields.get("cad_part_materials", []),
source_excel=fields.get("source_excel"),
)
db.add(product)
await db.flush()
await create_default_positions(db, product.id)
return product, True
async def link_cad_to_product(
db: AsyncSession, product_id: uuid.UUID, cad_file_id: uuid.UUID
) -> Product:
"""Set product.cad_file_id via direct SQL UPDATE."""
await db.execute(
sql_update(Product)
.where(Product.id == product_id)
.values(cad_file_id=cad_file_id)
)
await db.commit()
result = await db.execute(select(Product).where(Product.id == product_id))
return result.scalar_one()
+81
View File
@@ -0,0 +1,81 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text, Integer, Float, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
VALID_RENDER_BACKENDS = {"celery"}
class OutputType(Base):
__tablename__ = "output_types"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
renderer: Mapped[str] = mapped_column(String(50), nullable=False, default="threejs")
render_settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="png")
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
compatible_categories: Mapped[list] = mapped_column(JSONB, default=list, server_default="[]")
render_backend: Mapped[str] = mapped_column(String(20), nullable=False, default="auto", server_default="auto")
is_animation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
transparent_bg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
cycles_device: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
pricing_tier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("pricing_tiers.id", ondelete="SET NULL"), nullable=True, index=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="output_type")
pricing_tier: Mapped["PricingTier | None"] = relationship("PricingTier", back_populates="output_types")
class RenderTemplate(Base):
__tablename__ = "render_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(300), nullable=False)
category_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
output_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("output_types.id", ondelete="SET NULL"), nullable=True
)
blend_file_path: Mapped[str] = mapped_column(Text, nullable=False)
original_filename: Mapped[str] = mapped_column(String(500), nullable=False)
target_collection: Mapped[str] = mapped_column(String(200), nullable=False, default="Product", server_default="Product")
material_replace_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
lighting_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
shadow_catcher_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
camera_orbit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()")
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()", onupdate=datetime.utcnow)
output_type = relationship("OutputType", lazy="joined")
class ProductRenderPosition(Base):
__tablename__ = "product_render_positions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
rotation_x: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_y: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
product: Mapped["Product"] = relationship("Product", back_populates="render_positions")
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="render_position")
+5
View File
@@ -0,0 +1,5 @@
# Re-export from original routers.
from app.api.routers.render_templates import router as render_templates_router
from app.api.routers.output_types import router as output_types_router
__all__ = ["render_templates_router", "output_types_router"]
+91
View File
@@ -0,0 +1,91 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class OutputTypeCreate(BaseModel):
name: str
description: str | None = None
renderer: str = "threejs"
render_settings: dict = {}
output_format: str = "png"
sort_order: int = 0
is_active: bool = True
compatible_categories: list[str] = []
render_backend: str = "auto"
is_animation: bool = False
transparent_bg: bool = False
pricing_tier_id: int | None = None
cycles_device: str | None = None
class OutputTypePatch(BaseModel):
name: str | None = None
description: str | None = None
renderer: str | None = None
render_settings: dict | None = None
output_format: str | None = None
sort_order: int | None = None
is_active: bool | None = None
compatible_categories: list[str] | None = None
render_backend: str | None = None
is_animation: bool | None = None
transparent_bg: bool | None = None
pricing_tier_id: int | None = None
cycles_device: str | None = None
class OutputTypeOut(BaseModel):
id: uuid.UUID
name: str
description: str | None
renderer: str
render_settings: dict
output_format: str
sort_order: int
compatible_categories: list[str]
render_backend: str
is_animation: bool
transparent_bg: bool
cycles_device: str | None = None
pricing_tier_id: int | None = None
pricing_tier_name: str | None = None
price_per_item: float | None = None
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class RenderPositionCreate(BaseModel):
name: str
rotation_x: float = 0.0
rotation_y: float = 0.0
rotation_z: float = 0.0
is_default: bool = False
sort_order: int = 0
class RenderPositionPatch(BaseModel):
name: str | None = None
rotation_x: float | None = None
rotation_y: float | None = None
rotation_z: float | None = None
is_default: bool | None = None
sort_order: int | None = None
class RenderPositionOut(BaseModel):
id: uuid.UUID
product_id: uuid.UUID
name: str
rotation_x: float
rotation_y: float
rotation_z: float
is_default: bool
sort_order: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+14
View File
@@ -0,0 +1,14 @@
"""Rendering services — template resolution, dispatch, and Blender utilities."""
# Re-export from original service files for backward compatibility.
from app.services.template_service import resolve_template, get_material_library_path
from app.services.render_dispatcher import dispatch_render
from app.services.render_blender import find_blender, is_blender_available
__all__ = [
"resolve_template",
"get_material_library_path",
"dispatch_render",
"find_blender",
"is_blender_available",
]
+27 -17
View File
@@ -6,7 +6,17 @@ from pathlib import Path
from app.config import settings from app.config import settings
from app.database import engine, Base from app.database import engine, Base
from app.api.routers import auth, uploads, orders, templates, admin, order_items, cad, materials, worker, analytics, pricing, products, output_types, render_templates, notifications
# Import routers from domain locations
from app.domains.auth.router import router as auth_router
from app.domains.imports.router import uploads_router, templates_router
from app.domains.orders.router import orders_router, order_items_router
from app.domains.admin.router import admin_router, analytics_router, worker_router
from app.domains.products.router import products_router, cad_router
from app.domains.materials.router import router as materials_router
from app.domains.rendering.router import render_templates_router, output_types_router
from app.domains.notifications.router import router as notifications_router
from app.domains.billing.router import router as pricing_router
@asynccontextmanager @asynccontextmanager
@@ -48,22 +58,22 @@ try:
except (PermissionError, OSError): except (PermissionError, OSError):
pass pass
# Include routers # Include routers (via domain locations)
app.include_router(auth.router, prefix="/api") app.include_router(auth_router, prefix="/api")
app.include_router(uploads.router, prefix="/api") app.include_router(uploads_router, prefix="/api")
app.include_router(orders.router, prefix="/api") app.include_router(orders_router, prefix="/api")
app.include_router(templates.router, prefix="/api") app.include_router(templates_router, prefix="/api")
app.include_router(admin.router, prefix="/api") app.include_router(admin_router, prefix="/api")
app.include_router(order_items.router, prefix="/api") app.include_router(order_items_router, prefix="/api")
app.include_router(cad.router, prefix="/api") app.include_router(cad_router, prefix="/api")
app.include_router(materials.router, prefix="/api") app.include_router(materials_router, prefix="/api")
app.include_router(worker.router, prefix="/api") app.include_router(worker_router, prefix="/api")
app.include_router(analytics.router, prefix="/api") app.include_router(analytics_router, prefix="/api")
app.include_router(pricing.router, prefix="/api") app.include_router(pricing_router, prefix="/api")
app.include_router(products.router, prefix="/api") app.include_router(products_router, prefix="/api")
app.include_router(output_types.router, prefix="/api") app.include_router(output_types_router, prefix="/api")
app.include_router(render_templates.router, prefix="/api") app.include_router(render_templates_router, prefix="/api")
app.include_router(notifications.router, prefix="/api") app.include_router(notifications_router, prefix="/api")
@app.get("/health") @app.get("/health")
+19 -17
View File
@@ -1,20 +1,22 @@
from app.models.user import User """Re-export all models from domain locations.
from app.models.template import Template
from app.models.cad_file import CadFile This file ensures that `from app.models import X` continues to work.
from app.models.order import Order The canonical definitions live in app/domains/*/models.py.
from app.models.order_item import OrderItem """
from app.models.audit_log import AuditLog from app.domains.auth.models import User
from app.models.pricing_tier import PricingTier from app.domains.imports.models import Template
from app.models.product import Product from app.domains.products.models import CadFile, Product
from app.models.output_type import OutputType from app.domains.orders.models import Order, OrderItem, OrderLine
from app.models.order_line import OrderLine from app.domains.notifications.models import AuditLog
from app.models.render_template import RenderTemplate from app.domains.billing.models import PricingTier
from app.models.material import Material from app.domains.rendering.models import OutputType, RenderTemplate, ProductRenderPosition
from app.models.material_alias import MaterialAlias from app.domains.materials.models import Material, MaterialAlias
from app.models.render_position import ProductRenderPosition
# Also re-export SystemSetting (no domain assigned — stays as-is)
from app.models.system_setting import SystemSetting
__all__ = [ __all__ = [
"User", "Template", "CadFile", "Order", "OrderItem", "AuditLog", "User", "Template", "CadFile", "Product", "Order", "OrderItem", "OrderLine",
"PricingTier", "Product", "OutputType", "OrderLine", "AuditLog", "PricingTier", "OutputType", "RenderTemplate", "ProductRenderPosition",
"RenderTemplate", "Material", "MaterialAlias", "ProductRenderPosition", "Material", "MaterialAlias", "SystemSetting",
] ]
+3 -28
View File
@@ -1,28 +1,3 @@
import uuid # Compat shim — use app.domains.notifications.models instead
from datetime import datetime from app.domains.notifications.models import AuditLog
from sqlalchemy import String, Boolean, DateTime, ForeignKey __all__ = ["AuditLog"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
action: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str] = mapped_column(String(100), nullable=True)
entity_id: Mapped[str] = mapped_column(String(255), nullable=True)
details: Mapped[dict] = mapped_column(JSONB, nullable=True)
timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
# Notification center columns
target_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
)
read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
notification: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship("User", back_populates="audit_logs", foreign_keys=[user_id])
target_user: Mapped["User"] = relationship("User", foreign_keys=[target_user_id])
+3 -37
View File
@@ -1,37 +1,3 @@
import uuid # Compat shim — use app.domains.products.models instead
from datetime import datetime from app.domains.products.models import CadFile, ProcessingStatus
from sqlalchemy import String, DateTime, Enum as SAEnum, BigInteger __all__ = ["CadFile", "ProcessingStatus"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
import enum
class ProcessingStatus(str, enum.Enum):
pending = "pending"
processing = "processing"
completed = "completed"
failed = "failed"
class CadFile(Base):
__tablename__ = "cad_files"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
original_name: Mapped[str] = mapped_column(String(500), nullable=False)
stored_path: Mapped[str] = mapped_column(String(1000), nullable=False)
file_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=True)
parsed_objects: Mapped[dict] = mapped_column(JSONB, nullable=True)
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
gltf_path: Mapped[str] = mapped_column(String(1000), nullable=True)
processing_status: Mapped[ProcessingStatus] = mapped_column(
SAEnum(ProcessingStatus), default=ProcessingStatus.pending, nullable=False
)
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
render_log: Mapped[dict] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
order_items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="cad_file")
products: Mapped[list["Product"]] = relationship("Product", back_populates="cad_file")
+3 -24
View File
@@ -1,24 +1,3 @@
import uuid # Compat shim — use app.domains.materials.models instead
from datetime import datetime from app.domains.materials.models import Material
from sqlalchemy import String, DateTime, Text, ForeignKey, Integer __all__ = ["Material"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Material(Base):
__tablename__ = "materials"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True)
description: Mapped[str] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
schaeffler_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="select") # type: ignore[name-defined]
aliases = relationship("MaterialAlias", back_populates="material", cascade="all, delete-orphan")
+3 -19
View File
@@ -1,19 +1,3 @@
import uuid # Compat shim — use app.domains.materials.models instead
from datetime import datetime from app.domains.materials.models import MaterialAlias
from sqlalchemy import String, DateTime, ForeignKey __all__ = ["MaterialAlias"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class MaterialAlias(Base):
__tablename__ = "material_aliases"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
material_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
)
alias: Mapped[str] = mapped_column(String(300), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
material = relationship("Material", back_populates="aliases")
+3 -42
View File
@@ -1,42 +1,3 @@
import uuid # Compat shim — use app.domains.orders.models instead
from datetime import datetime from app.domains.orders.models import Order, OrderStatus
from decimal import Decimal __all__ = ["Order", "OrderStatus"]
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Text, Integer, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
import enum
class OrderStatus(str, enum.Enum):
draft = "draft"
submitted = "submitted"
processing = "processing"
completed = "completed"
rejected = "rejected"
class Order(Base):
__tablename__ = "orders"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_number: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("templates.id"), nullable=True)
status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus), default=OrderStatus.draft, nullable=False)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
source_excel: Mapped[str] = mapped_column(String(1000), nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
processing_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
estimated_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
template: Mapped["Template"] = relationship("Template", back_populates="orders")
created_by_user: Mapped["User"] = relationship("User", back_populates="orders", foreign_keys=[created_by])
items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="order", cascade="all, delete-orphan"
)
+3 -71
View File
@@ -1,71 +1,3 @@
import uuid # Compat shim — use app.domains.orders.models instead
from datetime import datetime from app.domains.orders.models import OrderItem, ItemStatus, AIValidationStatus
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Integer, Boolean, Text __all__ = ["OrderItem", "ItemStatus", "AIValidationStatus"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
import enum
class ItemStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class AIValidationStatus(str, enum.Enum):
not_started = "not_started"
pending = "pending"
completed = "completed"
failed = "failed"
class OrderItem(Base):
__tablename__ = "order_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("orders.id"), nullable=False)
row_index: Mapped[int] = mapped_column(Integer, nullable=False)
# 11 Standard fields (columns 0-10, skip col 5)
ebene1: Mapped[str] = mapped_column(String(500), nullable=True)
ebene2: Mapped[str] = mapped_column(String(500), nullable=True)
baureihe: Mapped[str] = mapped_column(String(500), nullable=True)
pim_id: Mapped[str] = mapped_column(String(500), nullable=True)
produkt_baureihe: Mapped[str] = mapped_column(String(500), nullable=True)
# col 5 is skipped (separator)
gewaehltes_produkt: Mapped[str] = mapped_column(String(500), nullable=True)
name_cad_modell: Mapped[str] = mapped_column(String(500), nullable=True)
gewuenschte_bildnummer: Mapped[str] = mapped_column(String(500), nullable=True)
lagertyp: Mapped[str] = mapped_column(String(500), nullable=True)
medias_rendering: Mapped[bool] = mapped_column(Boolean, nullable=True)
# Component pairs (cols 11+): [{part_name, material, component_type, column_index}]
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# CAD linkage
cad_file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cad_files.id"), nullable=True)
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
# Material assignments per CAD part: [{part_name, material}]
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# AI validation
ai_validation_status: Mapped[AIValidationStatus] = mapped_column(
SAEnum(AIValidationStatus), default=AIValidationStatus.not_started, nullable=False
)
ai_validation_result: Mapped[dict] = mapped_column(JSONB, nullable=True)
item_status: Mapped[ItemStatus] = mapped_column(SAEnum(ItemStatus), default=ItemStatus.pending, nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
order: Mapped["Order"] = relationship("Order", back_populates="items")
cad_file: Mapped["CadFile"] = relationship("CadFile", back_populates="order_items")
@property
def cad_parsed_objects(self) -> list[str] | None:
"""Part names extracted from the linked STEP file, for Pydantic serialization."""
if self.cad_file and self.cad_file.parsed_objects:
return self.cad_file.parsed_objects.get("objects") or []
return None
+3 -52
View File
@@ -1,52 +1,3 @@
import uuid # Compat shim — use app.domains.orders.models instead
import enum from app.domains.orders.models import OrderLine
from datetime import datetime __all__ = ["OrderLine"]
from decimal import Decimal
from sqlalchemy import String, DateTime, Text, ForeignKey, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class OrderLine(Base):
__tablename__ = "order_lines"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, index=True
)
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, index=True
)
output_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("output_types.id"), nullable=True
)
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
item_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
render_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
result_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
render_log: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
ai_validation_status: Mapped[str] = mapped_column(String(20), nullable=False, default="not_started")
ai_validation_result: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
flamenco_job_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
render_backend_used: Mapped[str | None] = mapped_column(String(20), nullable=True)
render_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
render_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
unit_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
render_position_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("product_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
order: Mapped["Order"] = relationship("Order", back_populates="lines")
product: Mapped["Product"] = relationship("Product", back_populates="order_lines")
output_type: Mapped["OutputType | None"] = relationship("OutputType", back_populates="order_lines")
render_position: Mapped["ProductRenderPosition | None"] = relationship(
"ProductRenderPosition", back_populates="order_lines"
)
+3 -36
View File
@@ -1,36 +1,3 @@
import uuid # Compat shim — use app.domains.rendering.models instead
from datetime import datetime from app.domains.rendering.models import OutputType, VALID_RENDER_BACKENDS
from sqlalchemy import String, DateTime, Boolean, Text, Integer, ForeignKey __all__ = ["OutputType", "VALID_RENDER_BACKENDS"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
VALID_RENDER_BACKENDS = {"celery"}
from app.database import Base
class OutputType(Base):
__tablename__ = "output_types"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
renderer: Mapped[str] = mapped_column(String(50), nullable=False, default="threejs")
render_settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="png")
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
compatible_categories: Mapped[list] = mapped_column(JSONB, default=list, server_default="[]")
render_backend: Mapped[str] = mapped_column(String(20), nullable=False, default="auto", server_default="auto")
is_animation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
transparent_bg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
cycles_device: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
pricing_tier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("pricing_tiers.id", ondelete="SET NULL"), nullable=True, index=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="output_type")
pricing_tier: Mapped["PricingTier | None"] = relationship("PricingTier", back_populates="output_types")
+3 -25
View File
@@ -1,25 +1,3 @@
from datetime import datetime # Compat shim — use app.domains.billing.models instead
from decimal import Decimal from app.domains.billing.models import PricingTier
from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index __all__ = ["PricingTier"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PricingTier(Base):
__tablename__ = "pricing_tiers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category_key: Mapped[str] = mapped_column(String(100), nullable=False)
quality_level: Mapped[str] = mapped_column(String(50), nullable=False, default="Normal")
price_per_item: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
output_types: Mapped[list["OutputType"]] = relationship("OutputType", back_populates="pricing_tier")
__table_args__ = (
UniqueConstraint("category_key", "quality_level", name="uq_pricing_tier"),
Index("ix_pricing_tiers_category_key", "category_key"),
)
+3 -66
View File
@@ -1,66 +1,3 @@
import uuid # Compat shim — use app.domains.products.models instead
from datetime import datetime from app.domains.products.models import Product
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey __all__ = ["Product"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class Product(Base):
__tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
pim_id: Mapped[str] = mapped_column(String(500), nullable=False)
name: Mapped[str | None] = mapped_column(String(500), nullable=True)
category_key: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
ebene1: Mapped[str | None] = mapped_column(String(500), nullable=True)
ebene2: Mapped[str | None] = mapped_column(String(500), nullable=True)
baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
produkt_baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True)
lagertyp: Mapped[str | None] = mapped_column(String(500), nullable=True)
name_cad_modell: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
medias_rendering: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
cad_file_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("cad_files.id", ondelete="SET NULL"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
arbeitspaket: Mapped[str | None] = mapped_column(String(500), nullable=True)
source_excel: Mapped[str | None] = mapped_column(String(1000), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
cad_file: Mapped["CadFile | None"] = relationship("CadFile", back_populates="products")
order_lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="product", cascade="all, delete-orphan"
)
render_positions: Mapped[list["ProductRenderPosition"]] = relationship(
"ProductRenderPosition", back_populates="product",
cascade="all, delete-orphan", order_by="ProductRenderPosition.sort_order"
)
@property
def thumbnail_url(self) -> str | None:
if self.cad_file and self.cad_file.thumbnail_path:
from pathlib import Path
return f"/thumbnails/{Path(self.cad_file.thumbnail_path).name}"
return None
@property
def processing_status(self) -> str | None:
if self.cad_file:
return self.cad_file.processing_status.value if hasattr(
self.cad_file.processing_status, 'value'
) else str(self.cad_file.processing_status)
return None
@property
def cad_parsed_objects(self) -> list[str] | None:
if self.cad_file and self.cad_file.parsed_objects:
return self.cad_file.parsed_objects.get("objects") or []
return None
+3 -28
View File
@@ -1,28 +1,3 @@
import uuid # Compat shim — use app.domains.rendering.models instead
from datetime import datetime from app.domains.rendering.models import ProductRenderPosition
from sqlalchemy import String, DateTime, Boolean, Integer, Float, ForeignKey __all__ = ["ProductRenderPosition"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class ProductRenderPosition(Base):
__tablename__ = "product_render_positions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
rotation_x: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_y: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
product: Mapped["Product"] = relationship("Product", back_populates="render_positions")
order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="render_position")
+3 -30
View File
@@ -1,30 +1,3 @@
import uuid # Compat shim — use app.domains.rendering.models instead
from datetime import datetime from app.domains.rendering.models import RenderTemplate
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey __all__ = ["RenderTemplate"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class RenderTemplate(Base):
__tablename__ = "render_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(300), nullable=False)
category_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
output_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("output_types.id", ondelete="SET NULL"), nullable=True
)
blend_file_path: Mapped[str] = mapped_column(Text, nullable=False)
original_filename: Mapped[str] = mapped_column(String(500), nullable=False)
target_collection: Mapped[str] = mapped_column(String(200), nullable=False, default="Product", server_default="Product")
material_replace_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
lighting_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
shadow_catcher_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
camera_orbit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()")
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()", onupdate=datetime.utcnow)
output_type = relationship("OutputType", lazy="joined")
+3 -24
View File
@@ -1,24 +1,3 @@
import uuid # Compat shim — use app.domains.imports.models instead
from datetime import datetime from app.domains.imports.models import Template
from sqlalchemy import String, Boolean, DateTime, Text __all__ = ["Template"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class Template(Base):
__tablename__ = "templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
category_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
# JSONB config for each of the 11 standard columns: {col_index: {label, required, optional}}
standard_fields: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# JSONB schema for expected component pairs
component_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
description: Mapped[str] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
orders: Mapped[list["Order"]] = relationship("Order", back_populates="template")
+3 -29
View File
@@ -1,29 +1,3 @@
import uuid # Compat shim — use app.domains.auth.models instead
from datetime import datetime from app.domains.auth.models import User, UserRole
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum __all__ = ["User", "UserRole"]
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
import enum
class UserRole(str, enum.Enum):
admin = "admin"
project_manager = "project_manager"
client = "client"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.client, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
orders: Mapped[list["Order"]] = relationship("Order", back_populates="created_by_user", foreign_keys="Order.created_by")
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="user", foreign_keys="AuditLog.user_id")
+11 -92
View File
@@ -1,92 +1,11 @@
import uuid # Compat shim — use app.domains.orders.schemas instead
from datetime import datetime from app.domains.orders.schemas import (
from typing import Any ComponentData, OrderItemCreate, OrderItemOut,
from pydantic import BaseModel OrderLineCreate, OrderLineOut,
from app.models.order import OrderStatus OrderCreate, OrderOut, OrderDetailOut,
from app.models.order_item import ItemStatus, AIValidationStatus )
from app.schemas.order_line import OrderLineOut, OrderLineCreate # noqa: F401 __all__ = [
"ComponentData", "OrderItemCreate", "OrderItemOut",
"OrderLineCreate", "OrderLineOut",
class ComponentData(BaseModel): "OrderCreate", "OrderOut", "OrderDetailOut",
part_name: str | None = None ]
material: str | None = None
component_type: str | None = None
column_index: int | None = None
class OrderItemCreate(BaseModel):
row_index: int
ebene1: str | None = None
ebene2: str | None = None
baureihe: str | None = None
pim_id: str | None = None
produkt_baureihe: str | None = None
gewaehltes_produkt: str | None = None
name_cad_modell: str | None = None
gewuenschte_bildnummer: str | None = None
lagertyp: str | None = None
medias_rendering: bool | None = None
components: list[ComponentData] = []
class OrderItemOut(BaseModel):
id: uuid.UUID
order_id: uuid.UUID
row_index: int
ebene1: str | None
ebene2: str | None
baureihe: str | None
pim_id: str | None
produkt_baureihe: str | None
gewaehltes_produkt: str | None
name_cad_modell: str | None
gewuenschte_bildnummer: str | None
lagertyp: str | None
medias_rendering: bool | None
components: list[dict]
cad_file_id: uuid.UUID | None
thumbnail_path: str | None
cad_parsed_objects: list[str] | None = None
cad_part_materials: list[dict] = []
ai_validation_status: AIValidationStatus
ai_validation_result: dict | None
item_status: ItemStatus
notes: str | None
created_at: datetime
model_config = {"from_attributes": True}
class OrderCreate(BaseModel):
template_id: uuid.UUID | None = None
source_excel: str | None = None
notes: str | None = None
items: list[OrderItemCreate] = []
lines: list[OrderLineCreate] = []
class OrderOut(BaseModel):
id: uuid.UUID
order_number: str
template_id: uuid.UUID | None
status: OrderStatus
created_by: uuid.UUID
source_excel: str | None
notes: str | None
created_at: datetime
updated_at: datetime
submitted_at: datetime | None = None
processing_started_at: datetime | None = None
completed_at: datetime | None = None
rejected_at: datetime | None = None
estimated_price: float | None = None
item_count: int = 0
line_count: int = 0
render_progress: dict | None = None
model_config = {"from_attributes": True}
class OrderDetailOut(OrderOut):
items: list[OrderItemOut] = []
lines: list[OrderLineOut] = []
+3 -39
View File
@@ -1,39 +1,3 @@
import uuid # Compat shim — use app.domains.orders.schemas instead
from datetime import datetime from app.domains.orders.schemas import OrderLineCreate, OrderLineOut
from pydantic import BaseModel __all__ = ["OrderLineCreate", "OrderLineOut"]
from app.schemas.product import ProductOut
from app.schemas.output_type import OutputTypeOut
class OrderLineCreate(BaseModel):
product_id: uuid.UUID
output_type_id: uuid.UUID | None = None
render_position_id: uuid.UUID | None = None
gewuenschte_bildnummer: str | None = None
notes: str | None = None
class OrderLineOut(BaseModel):
id: uuid.UUID
order_id: uuid.UUID
product_id: uuid.UUID
product: ProductOut
output_type_id: uuid.UUID | None
output_type: OutputTypeOut | None
gewuenschte_bildnummer: str | None
item_status: str
render_status: str
result_path: str | None
thumbnail_url: str | None = None
ai_validation_status: str
ai_validation_result: dict | None
render_backend_used: str | None = None
flamenco_job_id: str | None = None
unit_price: float | None = None
render_position_id: uuid.UUID | None = None
render_position_name: str | None = None
notes: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+3 -58
View File
@@ -1,58 +1,3 @@
import uuid # Compat shim — use app.domains.rendering.schemas instead
from datetime import datetime from app.domains.rendering.schemas import OutputTypeCreate, OutputTypePatch, OutputTypeOut
from pydantic import BaseModel __all__ = ["OutputTypeCreate", "OutputTypePatch", "OutputTypeOut"]
class OutputTypeCreate(BaseModel):
name: str
description: str | None = None
renderer: str = "threejs"
render_settings: dict = {}
output_format: str = "png"
sort_order: int = 0
is_active: bool = True
compatible_categories: list[str] = []
render_backend: str = "auto"
is_animation: bool = False
transparent_bg: bool = False
pricing_tier_id: int | None = None
cycles_device: str | None = None
class OutputTypePatch(BaseModel):
name: str | None = None
description: str | None = None
renderer: str | None = None
render_settings: dict | None = None
output_format: str | None = None
sort_order: int | None = None
is_active: bool | None = None
compatible_categories: list[str] | None = None
render_backend: str | None = None
is_animation: bool | None = None
transparent_bg: bool | None = None
pricing_tier_id: int | None = None
cycles_device: str | None = None
class OutputTypeOut(BaseModel):
id: uuid.UUID
name: str
description: str | None
renderer: str
render_settings: dict
output_format: str
sort_order: int
compatible_categories: list[str]
render_backend: str
is_animation: bool
transparent_bg: bool
cycles_device: str | None = None
pricing_tier_id: int | None = None
pricing_tier_name: str | None = None
price_per_item: float | None = None
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+3 -72
View File
@@ -1,72 +1,3 @@
import uuid # Compat shim — use app.domains.products.schemas instead
from datetime import datetime from app.domains.products.schemas import ProductCreate, ProductPatch, ProductOut
from pydantic import BaseModel __all__ = ["ProductCreate", "ProductPatch", "ProductOut"]
from app.schemas.render_position import RenderPositionOut
class ProductCreate(BaseModel):
pim_id: str
name: str | None = None
category_key: str | None = None
ebene1: str | None = None
ebene2: str | None = None
baureihe: str | None = None
produkt_baureihe: str | None = None
lagertyp: str | None = None
name_cad_modell: str | None = None
gewuenschte_bildnummer: str | None = None
medias_rendering: bool | None = None
components: list[dict] = []
cad_part_materials: list[dict] = []
notes: str | None = None
is_active: bool = True
source_excel: str | None = None
class ProductPatch(BaseModel):
name: str | None = None
category_key: str | None = None
ebene1: str | None = None
ebene2: str | None = None
baureihe: str | None = None
produkt_baureihe: str | None = None
lagertyp: str | None = None
name_cad_modell: str | None = None
gewuenschte_bildnummer: str | None = None
medias_rendering: bool | None = None
components: list[dict] | None = None
cad_part_materials: list[dict] | None = None
notes: str | None = None
is_active: bool | None = None
class ProductOut(BaseModel):
id: uuid.UUID
pim_id: str
name: str | None
category_key: str | None
ebene1: str | None
ebene2: str | None
baureihe: str | None
produkt_baureihe: str | None
lagertyp: str | None
name_cad_modell: str | None
gewuenschte_bildnummer: str | None
medias_rendering: bool | None
components: list[dict]
cad_part_materials: list[dict]
cad_file_id: uuid.UUID | None
thumbnail_url: str | None = None
render_image_url: str | None = None
processing_status: str | None = None
stl_cached: list[str] = []
cad_parsed_objects: list[str] | None = None
arbeitspaket: str | None = None
notes: str | None
is_active: bool
source_excel: str | None
render_positions: list[RenderPositionOut] = []
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+3 -36
View File
@@ -1,36 +1,3 @@
import uuid # Compat shim — use app.domains.rendering.schemas instead
from datetime import datetime from app.domains.rendering.schemas import RenderPositionCreate, RenderPositionPatch, RenderPositionOut
from pydantic import BaseModel __all__ = ["RenderPositionCreate", "RenderPositionPatch", "RenderPositionOut"]
class RenderPositionCreate(BaseModel):
name: str
rotation_x: float = 0.0
rotation_y: float = 0.0
rotation_z: float = 0.0
is_default: bool = False
sort_order: int = 0
class RenderPositionPatch(BaseModel):
name: str | None = None
rotation_x: float | None = None
rotation_y: float | None = None
rotation_z: float | None = None
is_default: bool | None = None
sort_order: int | None = None
class RenderPositionOut(BaseModel):
id: uuid.UUID
product_id: uuid.UUID
name: str
rotation_x: float
rotation_y: float
rotation_z: float
is_default: bool
sort_order: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+3 -43
View File
@@ -1,43 +1,3 @@
from pydantic import BaseModel # Compat shim — use app.domains.imports.schemas instead
from typing import Any from app.domains.imports.schemas import ParsedComponent, ParsedRow, ParsedExcelResponse, StepUploadResponse
__all__ = ["ParsedComponent", "ParsedRow", "ParsedExcelResponse", "StepUploadResponse"]
class ParsedComponent(BaseModel):
part_name: str | None = None
material: str | None = None
component_type: str | None = None
column_index: int
class ParsedRow(BaseModel):
row_index: int
ebene1: str | None = None
ebene2: str | None = None
baureihe: str | None = None
pim_id: str | None = None
produkt_baureihe: str | None = None
gewaehltes_produkt: str | None = None
name_cad_modell: str | None = None
gewuenschte_bildnummer: str | None = None
lagertyp: str | None = None
medias_rendering: bool | None = None
components: list[ParsedComponent] = []
class ParsedExcelResponse(BaseModel):
filename: str
excel_path: str | None = None # server-side path of the saved file
category_key: str | None = None
template_name: str | None = None
row_count: int
column_headers: list[str]
rows: list[ParsedRow]
warnings: list[str] = []
class StepUploadResponse(BaseModel):
cad_file_id: str
original_name: str
file_hash: str
status: str
matched_items: list[str] = []
+3 -39
View File
@@ -1,39 +1,3 @@
import uuid # Compat shim — use app.domains.auth.schemas instead
from datetime import datetime from app.domains.auth.schemas import UserCreate, UserUpdate, UserOut, TokenResponse, LoginRequest
from pydantic import BaseModel, EmailStr __all__ = ["UserCreate", "UserUpdate", "UserOut", "TokenResponse", "LoginRequest"]
from app.models.user import UserRole
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: str
role: UserRole = UserRole.client
class UserUpdate(BaseModel):
full_name: str | None = None
is_active: bool | None = None
role: UserRole | None = None
class UserOut(BaseModel):
id: uuid.UUID
email: str
full_name: str
role: UserRole
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
class LoginRequest(BaseModel):
email: EmailStr
password: str
+3 -143
View File
@@ -1,143 +1,3 @@
"""Material alias resolution service. # Compat shim — use app.domains.materials.service instead
from app.domains.materials.service import resolve_material_map, seed_material_aliases_from_mappings
Used from Celery tasks (sync context) to resolve raw material names __all__ = ["resolve_material_map", "seed_material_aliases_from_mappings"]
(from Excel / user input) to SCHAEFFLER library material names via aliases.
Resolution chain:
1. Exact Material.name match (case-insensitive) → use it
2. MaterialAlias lookup (case-insensitive) → use alias.material.name
3. Pass through unchanged → Blender will show FailedMaterial magenta
"""
import logging
from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session, selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.material import Material
from app.models.material_alias import MaterialAlias
logger = logging.getLogger(__name__)
_engine = None
def _get_engine():
global _engine
if _engine is None:
from app.config import settings as app_settings
_engine = create_engine(app_settings.database_url_sync)
return _engine
def resolve_material_map(raw_map: dict[str, str]) -> dict[str, str]:
"""Resolve raw material names to SCHAEFFLER library names via aliases.
For each value in raw_map:
1. If it already matches a Material.name (case-insensitive) → keep as-is (use canonical name)
2. Else look up MaterialAlias.alias (case-insensitive) → return alias.material.name
3. Else keep original (Blender will use FailedMaterial fallback)
Returns a new dict with the same keys but resolved material names.
"""
if not raw_map:
return raw_map
engine = _get_engine()
with Session(engine) as session:
# Load all materials
materials = session.execute(
select(Material).options(selectinload(Material.aliases))
).scalars().all()
# Build lookup dicts (case-insensitive)
# material name (lower) → canonical Material.name
name_lookup: dict[str, str] = {}
# alias (lower) → Material.name
alias_lookup: dict[str, str] = {}
for mat in materials:
name_lookup[mat.name.lower()] = mat.name
for a in mat.aliases:
alias_lookup[a.alias.lower()] = mat.name
resolved = {}
for part_name, raw_material in raw_map.items():
raw_lower = raw_material.lower()
# 1. Alias lookup first — aliases explicitly map intermediate/display names
# to the canonical SCHAEFFLER library names (e.g. "Steel--Stahl" →
# "SCHAEFFLER_010101_Steel-Bare"). This must take priority over the
# direct name match so that intermediate names are properly redirected.
if raw_lower in alias_lookup:
target = alias_lookup[raw_lower]
logger.info("resolved '%s''%s' (alias match)", raw_material, target)
resolved[part_name] = target
continue
# 2. Exact material name match (canonical name used as-is)
if raw_lower in name_lookup:
canonical = name_lookup[raw_lower]
if canonical != raw_material:
logger.info("resolved '%s''%s' (exact name match)", raw_material, canonical)
resolved[part_name] = canonical
continue
# 3. Pass through unchanged
logger.warning("no material match for '%s' — will use FailedMaterial fallback", raw_material)
resolved[part_name] = raw_material
return resolved
async def seed_material_aliases_from_mappings(
db: AsyncSession, mappings: list[dict]
) -> dict:
"""Seed material aliases from Excel materialmapping sheet.
For each {display_name, render_name}:
- Find or create Material by render_name
- Add display_name as alias if not already present
Returns {"created": N, "skipped": N}.
"""
created = 0
skipped = 0
for mapping in mappings:
display_name = mapping.get("display_name", "").strip()
render_name = mapping.get("render_name", "").strip()
if not display_name or not render_name:
skipped += 1
continue
# Find or create Material by render_name
result = await db.execute(
select(Material).where(func.lower(Material.name) == render_name.lower())
)
material = result.scalar_one_or_none()
if material is None:
material = Material(name=render_name, source="excel_mapping")
db.add(material)
await db.flush()
# Check if alias already exists
alias_result = await db.execute(
select(MaterialAlias).where(
func.lower(MaterialAlias.alias) == display_name.lower()
)
)
existing_alias = alias_result.scalar_one_or_none()
if existing_alias:
skipped += 1
continue
# Create alias
alias = MaterialAlias(material_id=material.id, alias=display_name)
db.add(alias)
created += 1
if created > 0:
await db.flush()
return {"created": created, "skipped": skipped}
+3 -84
View File
@@ -1,84 +1,3 @@
"""Notification emission helpers. # Compat shim — use app.domains.notifications.service instead
from app.domains.notifications.service import emit_notification, emit_notification_sync
Provides async (for routers) and sync (for Celery tasks) entry points __all__ = ["emit_notification", "emit_notification_sync"]
to create notification rows in the audit_log table.
"""
import logging
import uuid
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
logger = logging.getLogger(__name__)
_engine = None
def _get_engine():
global _engine
if _engine is None:
from app.config import settings as app_settings
_engine = create_engine(app_settings.database_url_sync)
return _engine
async def emit_notification(
db: AsyncSession,
*,
actor_user_id: str | uuid.UUID | None = None,
target_user_id: str | uuid.UUID | None = None,
action: str,
entity_type: str | None = None,
entity_id: str | None = None,
details: dict | None = None,
) -> None:
"""Create a notification (async — for use inside FastAPI routers)."""
try:
entry = AuditLog(
user_id=str(actor_user_id) if actor_user_id else None,
target_user_id=str(target_user_id) if target_user_id else None,
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id else None,
details=details,
notification=True,
timestamp=datetime.utcnow(),
)
db.add(entry)
await db.commit()
except Exception:
logger.exception("Failed to emit notification (async)")
await db.rollback()
def emit_notification_sync(
*,
actor_user_id: str | uuid.UUID | None = None,
target_user_id: str | uuid.UUID | None = None,
action: str,
entity_type: str | None = None,
entity_id: str | None = None,
details: dict | None = None,
) -> None:
"""Create a notification (sync — for use inside Celery tasks)."""
engine = _get_engine()
try:
with Session(engine) as session:
entry = AuditLog(
user_id=str(actor_user_id) if actor_user_id else None,
target_user_id=str(target_user_id) if target_user_id else None,
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id else None,
details=details,
notification=True,
timestamp=datetime.utcnow(),
)
session.add(entry)
session.commit()
except Exception:
logger.exception("Failed to emit notification (sync)")
+3 -22
View File
@@ -1,22 +1,3 @@
"""Order number generation and business logic.""" # Compat shim — use app.domains.orders.service instead
from datetime import datetime from app.domains.orders.service import generate_order_number, check_order_completion
from sqlalchemy.ext.asyncio import AsyncSession __all__ = ["generate_order_number", "check_order_completion"]
from sqlalchemy import select, func
from app.models.order import Order
async def generate_order_number(db: AsyncSession) -> str:
"""Generate next sequential order number: SA-2026-XXXXX."""
year = datetime.utcnow().year
prefix = f"SA-{year}-"
# Use MAX to find the highest existing sequence number this year.
# COUNT-based approach breaks when orders are deleted (produces duplicates).
result = await db.execute(
select(func.max(Order.order_number)).where(Order.order_number.like(f"{prefix}%"))
)
max_num = result.scalar()
if max_num:
last_seq = int(max_num.split("-")[-1])
return f"{prefix}{last_seq + 1:05d}"
return f"{prefix}00001"
+3 -86
View File
@@ -1,86 +1,3 @@
"""Service to auto-advance order status when all renders complete.""" # Compat shim — use app.domains.orders.service instead
import logging from app.domains.orders.service import check_order_completion
from datetime import datetime __all__ = ["check_order_completion"]
from sqlalchemy import create_engine, select, update as sql_update
from sqlalchemy.orm import Session
from app.models.order import Order, OrderStatus
from app.models.order_line import OrderLine
logger = logging.getLogger(__name__)
def check_order_completion(order_id: str) -> bool:
"""If all renderable lines are done, auto-advance order to completed.
Called from Celery tasks (sync context).
Returns True if the order was advanced to completed.
"""
from app.config import settings as app_settings
sync_url = app_settings.database_url.replace("+asyncpg", "")
engine = create_engine(sync_url)
try:
with Session(engine) as session:
# Get all lines that have an output type (i.e. renderable)
lines = session.execute(
select(OrderLine).where(
OrderLine.order_id == order_id,
OrderLine.output_type_id.isnot(None),
)
).scalars().all()
if not lines:
return False
# Check if all renderable lines are in a terminal state
all_terminal = all(
line.render_status in ("completed", "failed", "cancelled")
for line in lines
)
if not all_terminal:
return False
# Check order is still in processing state
order = session.execute(
select(Order).where(Order.id == order_id)
).scalar_one_or_none()
if order is None or order.status != OrderStatus.processing:
return False
# Auto-advance to completed
now = datetime.utcnow()
session.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(
status=OrderStatus.completed,
completed_at=now,
updated_at=now,
)
)
session.commit()
logger.info(f"Order {order_id} auto-advanced to completed (all {len(lines)} lines done)")
# Notify order creator
try:
from app.services.notification_service import emit_notification_sync
emit_notification_sync(
actor_user_id=None,
target_user_id=str(order.created_by),
action="order.completed",
entity_type="order",
entity_id=str(order_id),
details={"order_number": order.order_number},
)
except Exception:
logger.exception("Failed to emit order.completed notification")
return True
finally:
engine.dispose()
+7 -231
View File
@@ -1,232 +1,8 @@
"""Pricing service — price lookup and order price computation. # Compat shim — use app.domains.billing.service instead
from app.domains.billing.service import (
Price resolution cascade for order lines: get_price_for,
1. OutputType's linked pricing_tier (if active) → use its price_per_item resolve_line_price,
2. Product's category_key → look up PricingTier by category estimate_order_price,
3. "default" category tier → global fallback refresh_order_price,
4. None if nothing configured
"""
from decimal import Decimal
from typing import Any
from sqlalchemy import select, update as sql_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.pricing_tier import PricingTier
async def get_price_for(
db: AsyncSession,
category_key: str,
quality_level: str = "Normal",
) -> Decimal | None:
"""Return price_per_item for the given category + quality level.
Falls back to category_key='default' if no exact match is found.
Returns None if nothing is configured.
"""
# 1. Exact match
result = await db.execute(
select(PricingTier).where(
PricingTier.category_key == category_key,
PricingTier.quality_level == quality_level,
PricingTier.is_active.is_(True),
) )
) __all__ = ["get_price_for", "resolve_line_price", "estimate_order_price", "refresh_order_price"]
tier = result.scalar_one_or_none()
if tier is not None:
return tier.price_per_item
if category_key == "default":
return None
# 2. Fallback: default category
result = await db.execute(
select(PricingTier).where(
PricingTier.category_key == "default",
PricingTier.quality_level == quality_level,
PricingTier.is_active.is_(True),
)
)
tier = result.scalar_one_or_none()
return tier.price_per_item if tier is not None else None
async def resolve_line_price(
db: AsyncSession,
output_type_id: str | None,
product_category_key: str | None,
) -> Decimal | None:
"""Resolve the unit price for a single order line using the cascade.
1. OutputType's linked pricing_tier (if active)
2. Product's category_key → PricingTier by category
3. "default" category tier → global fallback
4. None
"""
if output_type_id is not None:
from app.models.output_type import OutputType
result = await db.execute(
select(OutputType)
.options(selectinload(OutputType.pricing_tier))
.where(OutputType.id == output_type_id)
)
ot = result.scalar_one_or_none()
if ot and ot.pricing_tier and ot.pricing_tier.is_active:
return ot.pricing_tier.price_per_item
# Step 2+3: category lookup with default fallback
cat = product_category_key or "default"
return await get_price_for(db, cat)
async def estimate_order_price(
db: AsyncSession,
lines: list[dict[str, Any]],
) -> dict:
"""Estimate price for a list of prospective order lines.
Each line dict should have: product_id, output_type_id.
Returns {total, line_count, breakdown: [{output_type_id, product_id, unit_price}], has_unpriced}.
"""
from app.models.product import Product
breakdown: list[dict] = []
total = Decimal("0.00")
has_unpriced = False
for line in lines:
product_id = line.get("product_id")
output_type_id = line.get("output_type_id")
# Get product category
cat = None
if product_id:
prod_result = await db.execute(
select(Product).where(Product.id == product_id)
)
prod = prod_result.scalar_one_or_none()
if prod:
cat = prod.category_key
price = await resolve_line_price(db, output_type_id, cat)
breakdown.append({
"output_type_id": str(output_type_id) if output_type_id else None,
"product_id": str(product_id) if product_id else None,
"unit_price": float(price) if price is not None else None,
})
if price is not None:
total += price
else:
has_unpriced = True
return {
"total": float(total),
"line_count": len(lines),
"breakdown": breakdown,
"has_unpriced": has_unpriced,
}
async def compute_order_estimated_price(
db: AsyncSession,
order,
items,
quality_level: str = "Normal",
) -> Decimal | None:
"""Compute estimated price for an order based on rendering items.
Returns None if no pricing is configured, or Decimal('0.00') if there
are no rendering items.
"""
rendering_count = sum(1 for i in items if i.medias_rendering)
if rendering_count == 0:
return Decimal("0.00")
# Resolve category from template
category_key = "default"
if order.template_id is not None:
from app.models.template import Template
tmpl_result = await db.execute(
select(Template).where(Template.id == order.template_id)
)
tmpl = tmpl_result.scalar_one_or_none()
if tmpl and tmpl.category_key:
category_key = tmpl.category_key
unit_price = await get_price_for(db, category_key, quality_level)
if unit_price is None:
return None
return unit_price * rendering_count
async def refresh_order_price(db: AsyncSession, order_id) -> Decimal | None:
"""Re-fetch order + lines, resolve per-line prices, snapshot to unit_price, update order total."""
from app.models.order import Order
from app.models.order_line import OrderLine
from app.models.output_type import OutputType
from app.models.product import Product
order_result = await db.execute(select(Order).where(Order.id == order_id))
order = order_result.scalar_one_or_none()
if order is None:
return None
lines_result = await db.execute(
select(OrderLine)
.options(
selectinload(OrderLine.output_type).selectinload(OutputType.pricing_tier),
selectinload(OrderLine.product),
)
.where(
OrderLine.order_id == order_id,
OrderLine.output_type_id.is_not(None),
)
)
lines = lines_result.scalars().all()
if not lines:
await db.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(estimated_price=Decimal("0.00"))
)
await db.commit()
return Decimal("0.00")
total = Decimal("0.00")
any_priced = False
for line in lines:
# Cascade: 1) OT pricing tier, 2) product category, 3) default
price = None
if line.output_type and line.output_type.pricing_tier and line.output_type.pricing_tier.is_active:
price = line.output_type.pricing_tier.price_per_item
else:
cat = line.product.category_key if line.product else None
price = await get_price_for(db, cat or "default")
# Snapshot to line
await db.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(unit_price=price)
)
if price is not None:
total += price
any_priced = True
new_price = total if any_priced else None
await db.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(estimated_price=new_price)
)
await db.commit()
return new_price
+14 -142
View File
@@ -1,143 +1,15 @@
"""Product service — lookup/create products, link CAD files.""" # Compat shim — use app.domains.products.service instead
import uuid from app.domains.products.service import (
from sqlalchemy import select, func, update as sql_update create_default_positions,
from sqlalchemy.ext.asyncio import AsyncSession lookup_product,
lookup_or_create_product,
from app.models.product import Product link_cad_to_product,
DEFAULT_RENDER_POSITIONS,
# Default render positions added to every newly created product. )
DEFAULT_RENDER_POSITIONS = [ __all__ = [
{"name": "3/4 Front", "rotation_x": -15.0, "rotation_y": 45.0, "rotation_z": 0.0, "is_default": True, "sort_order": 0}, "create_default_positions",
{"name": "3/4 Rear", "rotation_x": -15.0, "rotation_y": -135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 1}, "lookup_product",
{"name": "Default", "rotation_x": 0.0, "rotation_y": 0.0, "rotation_z": 0.0, "is_default": False, "sort_order": 2}, "lookup_or_create_product",
"link_cad_to_product",
"DEFAULT_RENDER_POSITIONS",
] ]
async def create_default_positions(db: AsyncSession, product_id: uuid.UUID) -> None:
"""Insert the default render positions for a newly created product."""
from app.models.render_position import ProductRenderPosition
for pos_data in DEFAULT_RENDER_POSITIONS:
db.add(ProductRenderPosition(product_id=product_id, **pos_data))
await db.flush()
def _fill_missing_fields(product: Product, pim_id: str | None, fields: dict) -> None:
"""Fill in null/empty fields on an existing product without overwriting manual edits."""
if pim_id and not product.pim_id:
product.pim_id = pim_id
for attr in (
"name", "category_key", "ebene1", "ebene2", "baureihe",
"lagertyp", "name_cad_modell", "arbeitspaket",
):
if fields.get(attr) and not getattr(product, attr, None):
setattr(product, attr, fields[attr])
# Update medias_rendering if not set
if fields.get("medias_rendering") is not None and product.medias_rendering is None:
product.medias_rendering = fields["medias_rendering"]
# Always update components from the latest Excel import (needed for auto-reassign)
if fields.get("components"):
product.components = fields["components"]
async def lookup_product(
db: AsyncSession, pim_id: str | None, produkt_baureihe: str | None
) -> Product | None:
"""Read-only lookup: produkt_baureihe (primary), then pim_id (fallback).
Same cascade as lookup_or_create_product but never creates or mutates.
"""
if produkt_baureihe:
result = await db.execute(
select(Product).where(
func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(),
Product.is_active.is_(True),
)
)
product = result.scalar_one_or_none()
if product is not None:
return product
# baureihe provided but not found — skip pim_id fallback (same logic)
return None
if pim_id:
result = await db.execute(
select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True))
)
return result.scalar_one_or_none()
return None
async def lookup_or_create_product(
db: AsyncSession, pim_id: str | None, fields: dict
) -> tuple[Product, bool]:
"""Look up by produkt_baureihe (primary), then pim_id (fallback). Create if not found.
Returns (product, was_created).
Does NOT overwrite existing fields — preserves manual edits.
"""
produkt_baureihe = fields.get("produkt_baureihe")
# Primary lookup: by produkt_baureihe (case-insensitive)
if produkt_baureihe:
result = await db.execute(
select(Product).where(
func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(),
Product.is_active.is_(True),
)
)
product = result.scalar_one_or_none()
if product is not None:
_fill_missing_fields(product, pim_id, fields)
await db.flush()
return product, False
# produkt_baureihe was provided but not found — each baureihe is a
# distinct product, so skip the pim_id fallback and create a new one.
# Fallback lookup: by pim_id (only when produkt_baureihe is absent,
# e.g. old per-category Excel files that don't have a Baureihe column).
if not produkt_baureihe and pim_id:
result = await db.execute(
select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True))
)
product = result.scalar_one_or_none()
if product is not None:
_fill_missing_fields(product, pim_id, fields)
await db.flush()
return product, False
product = Product(
pim_id=pim_id or f"auto-{uuid.uuid4().hex[:8]}",
name=fields.get("name"),
category_key=fields.get("category_key"),
ebene1=fields.get("ebene1"),
ebene2=fields.get("ebene2"),
baureihe=fields.get("baureihe"),
produkt_baureihe=produkt_baureihe,
lagertyp=fields.get("lagertyp"),
name_cad_modell=fields.get("name_cad_modell"),
arbeitspaket=fields.get("arbeitspaket"),
components=fields.get("components", []),
cad_part_materials=fields.get("cad_part_materials", []),
source_excel=fields.get("source_excel"),
)
db.add(product)
await db.flush()
await create_default_positions(db, product.id)
return product, True
async def link_cad_to_product(
db: AsyncSession, product_id: uuid.UUID, cad_file_id: uuid.UUID
) -> Product:
"""Set product.cad_file_id via direct SQL UPDATE."""
await db.execute(
sql_update(Product)
.where(Product.id == product_id)
.values(cad_file_id=cad_file_id)
)
await db.commit()
result = await db.execute(select(Product).where(Product.id == product_id))
return result.scalar_one()
+3 -96
View File
@@ -1,96 +1,3 @@
"""Render dispatcher — routes render jobs to Celery. # Compat shim — use app.domains.rendering.service instead
from app.domains.rendering.service import dispatch_render
All renders run via Celery workers (Flamenco removed in v2 refactor). __all__ = ["dispatch_render"]
"""
import logging
from datetime import datetime
from sqlalchemy import select, update as sql_update
from sqlalchemy.orm import Session, joinedload
from app.models.order_line import OrderLine
from app.models.product import Product
from app.models.system_setting import SystemSetting
logger = logging.getLogger(__name__)
def _load_setting(session: Session, key: str, default: str = "") -> str:
"""Load a single system setting (sync)."""
row = session.execute(
select(SystemSetting).where(SystemSetting.key == key)
).scalar_one_or_none()
return row.value if row else default
def dispatch_render(order_line_id: str) -> dict:
"""Dispatch a render job to Celery.
Must be called from a sync context (Celery task or sync wrapper).
Returns {"backend": "celery", "job_ref": str}.
"""
from app.config import settings as app_settings
from app.services.render_log import emit, clear
clear(order_line_id)
emit(order_line_id, "Dispatch started — loading order line data")
sync_url = app_settings.database_url.replace("+asyncpg", "")
from sqlalchemy import create_engine
engine_db = create_engine(sync_url)
with Session(engine_db) as session:
line = session.execute(
select(OrderLine)
.where(OrderLine.id == order_line_id)
.options(
joinedload(OrderLine.product).joinedload(Product.cad_file),
joinedload(OrderLine.output_type),
)
).scalar_one_or_none()
if line is None:
emit(order_line_id, "Order line not found", "error")
logger.error(f"OrderLine {order_line_id} not found")
return {"backend": "none", "job_ref": "", "error": "not_found"}
product_name = line.product.name or line.product.pim_id or "unknown"
output_name = line.output_type.name if line.output_type else "default"
emit(order_line_id, f"Product: {product_name} | Output: {output_name}")
if line.product.cad_file_id is None:
emit(order_line_id, "Product has no CAD file — marking as failed", "error")
logger.warning(f"OrderLine {order_line_id}: product has no CAD file")
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="failed")
)
session.commit()
return {"backend": "none", "job_ref": "", "error": "no_cad_file"}
cad_name = line.product.cad_file.original_name if line.product.cad_file else "?"
emit(order_line_id, f"CAD file: {cad_name}")
emit(order_line_id, "Dispatching to Celery render worker")
now = datetime.utcnow()
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(
render_status="processing",
render_backend_used="celery",
render_started_at=now,
)
)
session.commit()
engine_db.dispose()
return _dispatch_celery(order_line_id)
def _dispatch_celery(order_line_id: str) -> dict:
"""Dispatch to the Celery render task."""
from app.tasks.step_tasks import render_order_line_task
result = render_order_line_task.delay(order_line_id)
return {"backend": "celery", "job_ref": result.id}
+3 -102
View File
@@ -1,102 +1,3 @@
"""Render template resolution service. # Compat shim — use app.domains.rendering.service instead
from app.domains.rendering.service import resolve_template, get_material_library_path
Used from Celery tasks (sync context) to find the best matching .blend template __all__ = ["resolve_template", "get_material_library_path"]
for a given category + output type combination.
Cascade priority (first active match wins):
1. Exact: category_key + output_type_id
2. Category only: category_key + output_type_id IS NULL
3. OT only: category_key IS NULL + output_type_id
4. Global: both NULL
5. No template → caller falls back to factory-settings behavior
"""
import logging
from sqlalchemy import create_engine, select, and_
from sqlalchemy.orm import Session
from app.models.render_template import RenderTemplate
from app.models.system_setting import SystemSetting
logger = logging.getLogger(__name__)
_engine = None
def _get_engine():
global _engine
if _engine is None:
from app.config import settings as app_settings
_engine = create_engine(app_settings.database_url_sync)
return _engine
def resolve_template(
category_key: str | None = None,
output_type_id: str | None = None,
) -> RenderTemplate | None:
"""Find the best matching active render template.
Uses sync SQLAlchemy — safe for Celery tasks.
"""
engine = _get_engine()
with Session(engine) as session:
active = RenderTemplate.is_active == True # noqa: E712
# 1. Exact match
if category_key and output_type_id:
row = session.execute(
select(RenderTemplate).where(and_(
active,
RenderTemplate.category_key == category_key,
RenderTemplate.output_type_id == output_type_id,
))
).scalar_one_or_none()
if row:
return row
# 2. Category only
if category_key:
row = session.execute(
select(RenderTemplate).where(and_(
active,
RenderTemplate.category_key == category_key,
RenderTemplate.output_type_id.is_(None),
))
).scalar_one_or_none()
if row:
return row
# 3. OT only
if output_type_id:
row = session.execute(
select(RenderTemplate).where(and_(
active,
RenderTemplate.category_key.is_(None),
RenderTemplate.output_type_id == output_type_id,
))
).scalar_one_or_none()
if row:
return row
# 4. Global fallback (both NULL)
row = session.execute(
select(RenderTemplate).where(and_(
active,
RenderTemplate.category_key.is_(None),
RenderTemplate.output_type_id.is_(None),
))
).scalar_one_or_none()
return row
def get_material_library_path() -> str | None:
"""Read material_library_path from system_settings. Returns None if empty."""
engine = _get_engine()
with Session(engine) as session:
row = session.execute(
select(SystemSetting).where(SystemSetting.key == "material_library_path")
).scalar_one_or_none()
if row and row.value and row.value.strip():
return row.value.strip()
return None