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