feat(phase5.1+6): fallback material cleanup + notification batch refactor

Phase 5.1 — MATERIAL_PALETTE removal:
- Remove MATERIAL_PALETTE + _material_to_color() from step_processor.py
- build_part_colors() now returns {part→material_name} for Blender resolver

Phase 6 — Notification Center Refactor:
- Migration 051: add channel (activity|notification|alert) to audit_log,
  add frequency (immediate|daily|never) to notification_configs
- Three notification channels: activity (per-render), notification (batch
  order summaries), alert (admin infrastructure)
- Per-render emit_notification_sync calls demoted to channel=activity
- New emit_batch_render_notification_sync(): single summary notification
  when all order lines reach terminal state ("47/50 succeeded, 3 failed")
- Beat task batch_render_notifications every 60s: safety-net for missed
  batch notifications after order completion
- GET /notifications: defaults to channel IN (notification, alert);
  accepts ?channel=activity for activity feed
- Unread count badge counts only notification+alert channels
- Notifications.tsx: three tabs (Notifications | Activity | Alerts)
- NotificationSettings.tsx: frequency dropdown per event type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:20:07 +01:00
parent 10d05bd2e7
commit 89c44b846f
14 changed files with 640 additions and 56 deletions
@@ -26,6 +26,7 @@ class AuditLog(Base):
)
read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
notification: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
channel: Mapped[str] = mapped_column(String(20), nullable=False, default="notification")
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
@@ -55,4 +56,5 @@ class NotificationConfig(Base):
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
channel: Mapped[str] = mapped_column(String(20), nullable=False) # "in_app" | "email"
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
frequency: Mapped[str] = mapped_column(String(20), nullable=False, default="immediate") # "immediate" | "daily" | "never"
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
@@ -2,6 +2,7 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
@@ -11,6 +12,7 @@ class NotificationConfigOut(BaseModel):
event_type: str
channel: str
enabled: bool
frequency: str = "immediate"
created_at: datetime
model_config = {"from_attributes": True}
@@ -18,3 +20,4 @@ class NotificationConfigOut(BaseModel):
class NotificationConfigUpdate(BaseModel):
enabled: bool
frequency: Optional[str] = None # "immediate" | "daily" | "never"
+87 -1
View File
@@ -15,6 +15,11 @@ from app.domains.notifications.models import AuditLog
logger = logging.getLogger(__name__)
# ── Channel constants ────────────────────────────────────────────────────────
CHANNEL_ACTIVITY = "activity" # per-action events, not shown in bell dropdown
CHANNEL_NOTIFICATION = "notification" # batch summaries, shown in bell
CHANNEL_ALERT = "alert" # admin-only infrastructure issues
_engine = None
@@ -35,6 +40,7 @@ async def emit_notification(
entity_type: str | None = None,
entity_id: str | None = None,
details: dict | None = None,
channel: str = CHANNEL_NOTIFICATION,
) -> None:
"""Create a notification (async — for use inside FastAPI routers)."""
try:
@@ -46,6 +52,7 @@ async def emit_notification(
entity_id=str(entity_id) if entity_id else None,
details=details,
notification=True,
channel=channel,
timestamp=datetime.utcnow(),
)
db.add(entry)
@@ -63,6 +70,7 @@ def emit_notification_sync(
entity_type: str | None = None,
entity_id: str | None = None,
details: dict | None = None,
channel: str = CHANNEL_NOTIFICATION,
) -> None:
"""Create a notification (sync — for use inside Celery tasks)."""
engine = _get_engine()
@@ -76,6 +84,7 @@ def emit_notification_sync(
entity_id=str(entity_id) if entity_id else None,
details=details,
notification=True,
channel=channel,
timestamp=datetime.utcnow(),
)
session.add(entry)
@@ -84,6 +93,74 @@ def emit_notification_sync(
logger.exception("Failed to emit notification (sync)")
def emit_batch_render_notification_sync(order_id: str) -> None:
"""Emit a single batch notification summarising all render results for an order.
Queries all order_lines for the order, counts terminal statuses, and emits
a single channel=notification row targeting the order creator.
Should be called after all lines reach a terminal state.
"""
engine = _get_engine()
try:
from app.domains.orders.models import Order, OrderLine
with Session(engine) as session:
order = session.get(Order, order_id)
if order is None:
logger.warning("emit_batch_render_notification_sync: order %s not found", order_id)
return
lines = session.execute(
select(OrderLine).where(OrderLine.order_id == order_id)
).scalars().all()
if not lines:
return
total = len(lines)
completed = sum(1 for l in lines if l.render_status == "completed")
failed = sum(1 for l in lines if l.render_status == "failed")
cancelled = sum(1 for l in lines if l.render_status == "cancelled")
if completed == total:
action = "order.completed"
message = f"All {total} render(s) completed successfully"
elif failed > 0 and (completed + failed + cancelled) == total:
action = "order.completed"
message = f"Rendering complete: {completed}/{total} succeeded, {failed} failed"
if cancelled:
message += f", {cancelled} cancelled"
else:
action = "order.completed"
message = f"Order rendering complete: {completed}/{total} succeeded"
entry = AuditLog(
user_id=None,
target_user_id=str(order.created_by),
action=action,
entity_type="order",
entity_id=str(order.id),
details={
"order_number": order.order_number,
"total": total,
"completed": completed,
"failed": failed,
"cancelled": cancelled,
"message": message,
},
notification=True,
channel=CHANNEL_NOTIFICATION,
timestamp=datetime.utcnow(),
)
session.add(entry)
session.commit()
logger.info(
"emit_batch_render_notification_sync: emitted batch notification for order %s (%s)",
order_id, message,
)
except Exception:
logger.exception("emit_batch_render_notification_sync failed for order %s", order_id)
# ── Notification config helpers ─────────────────────────────────────────────
def _is_channel_enabled_sync(user_id: str | None, event_type: str, channel: str) -> bool:
@@ -188,6 +265,7 @@ async def upsert_notification_config(
event_type: str,
channel: str,
enabled: bool,
frequency: str | None = None,
) -> object:
from app.domains.notifications.models import NotificationConfig
from sqlalchemy import select as sa_select
@@ -200,10 +278,18 @@ async def upsert_notification_config(
)
cfg = result.scalar_one_or_none()
if cfg is None:
cfg = NotificationConfig(user_id=user_id, event_type=event_type, channel=channel, enabled=enabled)
cfg = NotificationConfig(
user_id=user_id,
event_type=event_type,
channel=channel,
enabled=enabled,
frequency=frequency or "immediate",
)
db.add(cfg)
else:
cfg.enabled = enabled
if frequency is not None:
cfg.frequency = frequency
await db.commit()
await db.refresh(cfg)
return cfg
+4 -11
View File
@@ -81,19 +81,12 @@ def check_order_completion(order_id: str) -> bool:
session.commit()
logger.info(f"Order {order_id} auto-advanced to completed (all {len(lines)} lines done)")
# Notify order creator
# Emit a single batch notification summarising all render results
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},
)
from app.domains.notifications.service import emit_batch_render_notification_sync
emit_batch_render_notification_sync(order_id)
except Exception:
logger.exception("Failed to emit order.completed notification")
logger.exception("Failed to emit batch render notification for order %s", order_id)
return True
+16 -2
View File
@@ -1,3 +1,17 @@
# Compat shim — use app.domains.notifications.service instead
from app.domains.notifications.service import emit_notification, emit_notification_sync
__all__ = ["emit_notification", "emit_notification_sync"]
from app.domains.notifications.service import (
emit_notification,
emit_notification_sync,
emit_batch_render_notification_sync,
CHANNEL_ACTIVITY,
CHANNEL_NOTIFICATION,
CHANNEL_ALERT,
)
__all__ = [
"emit_notification",
"emit_notification_sync",
"emit_batch_render_notification_sync",
"CHANNEL_ACTIVITY",
"CHANNEL_NOTIFICATION",
"CHANNEL_ALERT",
]
+12 -23
View File
@@ -16,40 +16,29 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
MATERIAL_PALETTE = [
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]
def _material_to_color(material_name: str | None, index: int) -> str:
"""Return a deterministic hex color: hash material name, or use palette by index."""
if material_name and material_name.strip():
i = abs(hash(material_name.strip().lower())) % len(MATERIAL_PALETTE)
return MATERIAL_PALETTE[i]
return MATERIAL_PALETTE[index % len(MATERIAL_PALETTE)]
def build_part_colors(
cad_parsed_objects: list[str],
cad_part_materials: list[dict],
) -> dict[str, str]:
"""
Build {part_name: hex_color} for thumbnail rendering.
Build {part_name: material_name} for Blender rendering.
Returns a mapping of part name → Schaeffler material name (e.g. SCHAEFFLER_010101_Steel-Bare).
Parts with no material assignment are omitted; Blender will use the fallback material
(SCHAEFFLER_059999_FailedMaterial) for unrecognised parts.
Args:
cad_parsed_objects: List of part names from cad_file.parsed_objects["objects"].
cad_part_materials: List of {part_name, material} dicts from order_item.cad_part_materials.
"""
mat_map = {
m["part_name"].lower(): m.get("material")
for m in cad_part_materials
if m.get("part_name")
}
return {
name: _material_to_color(mat_map.get(name.lower()), i)
for i, name in enumerate(cad_parsed_objects)
}
result = {}
for m in cad_part_materials:
part = m.get("part_name", "").strip()
material = m.get("material", "").strip()
if part and material:
result[part] = material
return result
def _normalize_stem(name: str) -> str:
+71
View File
@@ -80,3 +80,74 @@ def recover_stuck_cad_files() -> None:
logger.debug("recover_stuck_cad_files: no stuck files found")
except Exception as exc:
logger.error("recover_stuck_cad_files failed: %s", exc)
@shared_task(name="app.tasks.beat_tasks.batch_render_notifications", queue="step_processing")
def batch_render_notifications() -> None:
"""Check for orders where all lines are terminal but no batch notification emitted yet.
This acts as a safety net for batch notifications that may have been missed
if the order completion hook failed or was skipped. Runs every 60 seconds.
"""
try:
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.config import settings
from app.domains.orders.models import Order, OrderStatus
from app.domains.notifications.models import AuditLog
from app.domains.notifications.service import (
emit_batch_render_notification_sync,
CHANNEL_NOTIFICATION,
)
sync_url = settings.database_url.replace("+asyncpg", "")
engine = create_engine(sync_url)
with Session(engine) as session:
# Find all completed orders
completed_order_ids: list[str] = [
str(oid) for oid in session.execute(
select(Order.id).where(Order.status == OrderStatus.completed)
).scalars().all()
]
if not completed_order_ids:
engine.dispose()
logger.debug("batch_render_notifications: no completed orders found")
return
# Find which of those already have a batch notification
notified_ids_raw = session.execute(
select(AuditLog.entity_id).where(
AuditLog.entity_type == "order",
AuditLog.action == "order.completed",
AuditLog.channel == CHANNEL_NOTIFICATION,
AuditLog.entity_id.in_(completed_order_ids),
)
).scalars().all()
notified_ids = set(notified_ids_raw)
engine.dispose()
orders_needing_notification = [
oid for oid in completed_order_ids if oid not in notified_ids
]
if not orders_needing_notification:
logger.debug("batch_render_notifications: no orders need batch notification")
return
logger.info(
"batch_render_notifications: emitting batch notifications for %d order(s)",
len(orders_needing_notification),
)
for order_id in orders_needing_notification:
try:
emit_batch_render_notification_sync(str(order_id))
except Exception as exc:
logger.error(
"batch_render_notifications: failed for order %s: %s", order_id, exc
)
except Exception as exc:
logger.error("batch_render_notifications failed: %s", exc)
+4
View File
@@ -38,5 +38,9 @@ celery_app.conf.update(
"task": "app.tasks.beat_tasks.recover_stuck_cad_files",
"schedule": 300.0, # every 5 minutes
},
"batch-render-notifications-every-60s": {
"task": "app.tasks.beat_tasks.batch_render_notifications",
"schedule": 60.0, # every 60 seconds
},
},
)
+7 -5
View File
@@ -1065,7 +1065,7 @@ def render_order_line_task(self, order_line_id: str):
except Exception:
logger.debug("WebSocket publish skipped (non-fatal)")
# Notify order creator about render result
# Emit per-render activity event (channel=activity, not shown in bell dropdown)
try:
from app.models.order import Order as OrderModel
order_row = session.execute(
@@ -1073,7 +1073,7 @@ def render_order_line_task(self, order_line_id: str):
.where(OrderModel.id == line.order_id)
).one_or_none()
if order_row:
from app.services.notification_service import emit_notification_sync
from app.services.notification_service import emit_notification_sync, CHANNEL_ACTIVITY
details: dict = {
"order_number": order_row[1],
"product_name": product_name,
@@ -1090,9 +1090,10 @@ def render_order_line_task(self, order_line_id: str):
entity_type="order",
entity_id=str(line.order_id),
details=details,
channel=CHANNEL_ACTIVITY,
)
except Exception:
logger.exception("Failed to emit render notification")
logger.exception("Failed to emit render activity event")
# Check if all lines for this order are done → auto-advance
order_id_str = str(line.order_id)
@@ -1148,7 +1149,7 @@ def render_order_line_task(self, order_line_id: str):
).one_or_none()
eng4.dispose()
if order_row2:
from app.services.notification_service import emit_notification_sync
from app.services.notification_service import emit_notification_sync, CHANNEL_ACTIVITY
emit_notification_sync(
actor_user_id=None,
target_user_id=str(order_row2[0]),
@@ -1161,9 +1162,10 @@ def render_order_line_task(self, order_line_id: str):
"output_type": "unknown",
"error": str(exc)[:300],
},
channel=CHANNEL_ACTIVITY,
)
except Exception:
logger.exception("Failed to emit render failure notification")
logger.exception("Failed to emit render failure activity event")
except Exception:
logger.exception(f"Failed to mark {order_line_id} as failed in DB")
raise