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