feat: enhance weekly report and realtime notifications

Weekly Report (fix-weekly-report):
- Remove 5-task limit, show all tasks per category
- Add blocked tasks with blocker_reason and blocked_since
- Add next week tasks (due in coming week)
- Add assignee_name, completed_at, days_overdue to task details
- Frontend collapsible sections for each task category
- 8 new tests for enhanced report content

Realtime Notifications (fix-realtime-notifications):
- SQLAlchemy event-based notification publishing
- Redis Pub/Sub for multi-process broadcast
- Fix soft rollback handler stacking issue
- Fix ping scheduling drift (send immediately when interval expires)
- Frontend NotificationContext with WebSocket reconnection

Spec Fixes:
- Add missing ## Purpose sections to 5 specs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-30 20:52:08 +08:00
parent 10db2c9d1f
commit 64874d5425
25 changed files with 1034 additions and 140 deletions

View File

@@ -1,9 +1,95 @@
import json
import uuid
import re
from typing import List, Optional
import asyncio
import logging
import threading
from datetime import datetime
from typing import List, Optional, Dict, Set
from sqlalchemy.orm import Session
from sqlalchemy import event
from app.models import User, Notification, Task, Comment, Mention
from app.core.redis_pubsub import publish_notification as redis_publish, get_channel_name
from app.core.redis import get_redis_sync
logger = logging.getLogger(__name__)
# Thread-safe lock for module-level state
_lock = threading.Lock()
# Module-level queue for notifications pending publish after commit
_pending_publish: Dict[int, List[dict]] = {}
# Track which sessions have handlers registered
_registered_sessions: Set[int] = set()
def _sync_publish(user_id: str, data: dict):
"""Sync fallback to publish notification via Redis when no event loop available."""
try:
redis_client = get_redis_sync()
channel = get_channel_name(user_id)
message = json.dumps(data, default=str)
redis_client.publish(channel, message)
logger.debug(f"Sync published notification to channel {channel}")
except Exception as e:
logger.error(f"Failed to sync publish notification to Redis: {e}")
def _cleanup_session(session_id: int, remove_registration: bool = True):
"""Clean up session state after commit/rollback. Thread-safe.
Args:
session_id: The session ID to clean up
remove_registration: If True, also remove from _registered_sessions.
Set to False for soft_rollback to avoid handler stacking.
"""
with _lock:
if remove_registration:
_registered_sessions.discard(session_id)
return _pending_publish.pop(session_id, [])
def _register_session_handlers(db: Session, session_id: int):
"""Register after_commit, after_rollback, and after_soft_rollback handlers for a session."""
with _lock:
if session_id in _registered_sessions:
return
_registered_sessions.add(session_id)
@event.listens_for(db, "after_commit", once=True)
def _after_commit(session):
notifications = _cleanup_session(session_id)
if notifications:
try:
loop = asyncio.get_running_loop()
for n in notifications:
loop.create_task(_async_publish(n["user_id"], n["data"]))
except RuntimeError:
# No running event loop - use sync fallback
logger.info(f"No event loop, using sync publish for {len(notifications)} notification(s)")
for n in notifications:
_sync_publish(n["user_id"], n["data"])
@event.listens_for(db, "after_rollback", once=True)
def _after_rollback(session):
cleared = _cleanup_session(session_id)
if cleared:
logger.debug(f"Cleared {len(cleared)} pending notification(s) after rollback")
@event.listens_for(db, "after_soft_rollback", once=True)
def _after_soft_rollback(session, previous_transaction):
# Only clear pending notifications, keep handler registration to avoid stacking
cleared = _cleanup_session(session_id, remove_registration=False)
if cleared:
logger.debug(f"Cleared {len(cleared)} pending notification(s) after soft rollback")
async def _async_publish(user_id: str, data: dict):
"""Async helper to publish notification to Redis."""
try:
await redis_publish(user_id, data)
except Exception as e:
logger.error(f"Failed to publish notification to Redis: {e}")
class NotificationService:
@@ -11,6 +97,56 @@ class NotificationService:
MAX_MENTIONS_PER_COMMENT = 10
@staticmethod
def notification_to_dict(notification: Notification) -> dict:
"""Convert a Notification to a dict for publishing."""
created_at = notification.created_at
if created_at is None:
created_at = datetime.utcnow()
return {
"id": notification.id,
"type": notification.type,
"reference_type": notification.reference_type,
"reference_id": notification.reference_id,
"title": notification.title,
"message": notification.message,
"is_read": notification.is_read,
"created_at": created_at.isoformat() if created_at else None,
}
@staticmethod
async def publish_notifications(notifications: List[Notification]) -> None:
"""Publish notifications to Redis for real-time WebSocket delivery."""
for notification in notifications:
if notification and notification.user_id:
data = NotificationService.notification_to_dict(notification)
await redis_publish(notification.user_id, data)
@staticmethod
async def publish_notification(notification: Optional[Notification]) -> None:
"""Publish a single notification to Redis."""
if notification:
await NotificationService.publish_notifications([notification])
@staticmethod
def _queue_for_publish(db: Session, notification: Notification):
"""Queue notification for auto-publish after commit. Thread-safe."""
session_id = id(db)
# Register handlers first (has its own lock)
_register_session_handlers(db, session_id)
# Store notification data (not object) for publishing
notification_data = {
"user_id": notification.user_id,
"data": NotificationService.notification_to_dict(notification),
}
with _lock:
if session_id not in _pending_publish:
_pending_publish[session_id] = []
_pending_publish[session_id].append(notification_data)
@staticmethod
def create_notification(
db: Session,
@@ -21,7 +157,7 @@ class NotificationService:
title: str,
message: Optional[str] = None,
) -> Notification:
"""Create a notification for a user."""
"""Create a notification for a user. Auto-publishes via Redis after commit."""
notification = Notification(
id=str(uuid.uuid4()),
user_id=user_id,
@@ -32,6 +168,10 @@ class NotificationService:
message=message,
)
db.add(notification)
# Queue for auto-publish after commit
NotificationService._queue_for_publish(db, notification)
return notification
@staticmethod

View File

@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import (
User, Task, Project, ScheduledReport, ReportHistory
User, Task, Project, ScheduledReport, ReportHistory, Blocker
)
from app.services.notification_service import NotificationService
@@ -29,11 +29,15 @@ class ReportService:
Get weekly task statistics for a user's projects.
Returns stats for all projects where the user is the owner.
Includes: completed, in_progress, overdue, blocked, and next_week tasks.
"""
if week_start is None:
week_start = ReportService.get_week_start()
week_end = week_start + timedelta(days=7)
next_week_start = week_end
next_week_end = next_week_start + timedelta(days=7)
now = datetime.utcnow()
# Get projects owned by the user
projects = db.query(Project).filter(Project.owner_id == user_id).all()
@@ -47,36 +51,71 @@ class ReportService:
"completed_count": 0,
"in_progress_count": 0,
"overdue_count": 0,
"blocked_count": 0,
"next_week_count": 0,
"total_tasks": 0,
}
}
project_ids = [p.id for p in projects]
# Get all tasks for these projects
# Get all tasks for these projects with assignee info
all_tasks = db.query(Task).filter(Task.project_id.in_(project_ids)).all()
# Get active blockers (unresolved) for these projects
active_blockers = db.query(Blocker).join(Task).filter(
Task.project_id.in_(project_ids),
Blocker.resolved_at.is_(None)
).all()
# Map task_id to blocker info
blocker_map: Dict[str, Blocker] = {b.task_id: b for b in active_blockers}
blocked_task_ids = set(blocker_map.keys())
# Categorize tasks
completed_tasks = []
in_progress_tasks = []
overdue_tasks = []
now = datetime.utcnow()
blocked_tasks = []
next_week_tasks = []
for task in all_tasks:
status_name = task.status.name.lower() if task.status else ""
is_done = status_name in ["done", "completed", "完成"]
# Check if completed (updated this week)
if status_name in ["done", "completed", "完成"]:
if is_done:
if task.updated_at and task.updated_at >= week_start:
completed_tasks.append(task)
# Check if in progress
elif status_name in ["in progress", "進行中", "doing"]:
in_progress_tasks.append(task)
else:
# Check if in progress
if status_name in ["in progress", "進行中", "doing"]:
in_progress_tasks.append(task)
# Check if overdue
if task.due_date and task.due_date < now and status_name not in ["done", "completed", "完成"]:
overdue_tasks.append(task)
# Check if overdue
if task.due_date and task.due_date < now:
overdue_tasks.append(task)
# Check if blocked
if task.id in blocked_task_ids:
blocked_tasks.append(task)
# Check if due next week
if task.due_date and next_week_start <= task.due_date < next_week_end:
next_week_tasks.append(task)
# Helper to get assignee name
def get_assignee_name(task: Task) -> Optional[str]:
if task.assignee:
return task.assignee.name
return None
# Helper to calculate days overdue
def get_days_overdue(task: Task) -> int:
if task.due_date:
delta = now - task.due_date
return max(0, delta.days)
return 0
# Build project details
project_details = []
@@ -85,6 +124,8 @@ class ReportService:
project_completed = [t for t in completed_tasks if t.project_id == project.id]
project_in_progress = [t for t in in_progress_tasks if t.project_id == project.id]
project_overdue = [t for t in overdue_tasks if t.project_id == project.id]
project_blocked = [t for t in blocked_tasks if t.project_id == project.id]
project_next_week = [t for t in next_week_tasks if t.project_id == project.id]
project_details.append({
"project_id": project.id,
@@ -92,9 +133,57 @@ class ReportService:
"completed_count": len(project_completed),
"in_progress_count": len(project_in_progress),
"overdue_count": len(project_overdue),
"blocked_count": len(project_blocked),
"next_week_count": len(project_next_week),
"total_tasks": len(project_tasks),
"completed_tasks": [{"id": t.id, "title": t.title} for t in project_completed[:5]],
"overdue_tasks": [{"id": t.id, "title": t.title, "due_date": t.due_date.isoformat() if t.due_date else None} for t in project_overdue[:5]],
# Full task lists with detailed fields
"completed_tasks": [
{
"id": t.id,
"title": t.title,
"completed_at": t.updated_at.isoformat() if t.updated_at else None,
"assignee_name": get_assignee_name(t),
}
for t in project_completed
],
"in_progress_tasks": [
{
"id": t.id,
"title": t.title,
"assignee_name": get_assignee_name(t),
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in project_in_progress
],
"overdue_tasks": [
{
"id": t.id,
"title": t.title,
"due_date": t.due_date.isoformat() if t.due_date else None,
"days_overdue": get_days_overdue(t),
"assignee_name": get_assignee_name(t),
}
for t in project_overdue
],
"blocked_tasks": [
{
"id": t.id,
"title": t.title,
"blocker_reason": blocker_map[t.id].reason if t.id in blocker_map else None,
"blocked_since": blocker_map[t.id].created_at.isoformat() if t.id in blocker_map else None,
"assignee_name": get_assignee_name(t),
}
for t in project_blocked
],
"next_week_tasks": [
{
"id": t.id,
"title": t.title,
"due_date": t.due_date.isoformat() if t.due_date else None,
"assignee_name": get_assignee_name(t),
}
for t in project_next_week
],
})
return {
@@ -106,6 +195,8 @@ class ReportService:
"completed_count": len(completed_tasks),
"in_progress_count": len(in_progress_tasks),
"overdue_count": len(overdue_tasks),
"blocked_count": len(blocked_tasks),
"next_week_count": len(next_week_tasks),
"total_tasks": len(all_tasks),
}
}
@@ -161,10 +252,18 @@ class ReportService:
completed = summary.get("completed_count", 0)
in_progress = summary.get("in_progress_count", 0)
overdue = summary.get("overdue_count", 0)
blocked = summary.get("blocked_count", 0)
next_week = summary.get("next_week_count", 0)
message = f"本週完成 {completed} 項任務,進行中 {in_progress}"
if overdue > 0:
message += f",逾期 {overdue}需關注"
message += f",逾期 {overdue}"
if blocked > 0:
message += f",阻礙 {blocked}"
if overdue > 0 or blocked > 0:
message += " 需關注"
if next_week > 0:
message += f"。下週預計 {next_week}"
NotificationService.create_notification(
db=db,