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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user