Files
PROJECT-CONTORL/backend/app/services/report_service.py
beabigegg 4b5a9c1d0a feat: complete LOW priority code quality improvements
Backend:
- LOW-002: Add Query validation with max page size limits (100)
- LOW-003: Replace magic strings with TaskStatus.is_done flag
- LOW-004: Add 'creation' trigger type validation
- Add action_executor.py with UpdateFieldAction and AutoAssignAction

Frontend:
- LOW-005: Replace TypeScript 'any' with 'unknown' + type guards
- LOW-006: Add ConfirmModal component with A11Y support
- LOW-007: Add ToastContext for user feedback notifications
- LOW-009: Add Skeleton components (17 loading states replaced)
- LOW-010: Setup Vitest with 21 tests for ConfirmModal and Skeleton

Components updated:
- App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx
- ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx
- Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx
- NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:24:36 +08:00

338 lines
13 KiB
Python

import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import (
User, Task, Project, ScheduledReport, ReportHistory, Blocker
)
from app.services.notification_service import NotificationService
class ReportService:
"""Service for generating and managing scheduled reports."""
@staticmethod
def get_week_start(date: Optional[datetime] = None) -> datetime:
"""Get the start of the week (Monday) for a given date.
Returns a naive datetime for compatibility with database values.
"""
if date is None:
date = datetime.now(timezone.utc).replace(tzinfo=None)
elif date.tzinfo is not None:
# Convert to naive datetime for consistency
date = date.replace(tzinfo=None)
# Get Monday of the current week
days_since_monday = date.weekday()
week_start = date - timedelta(days=days_since_monday)
return week_start.replace(hour=0, minute=0, second=0, microsecond=0)
@staticmethod
def get_weekly_stats(db: Session, user_id: str, week_start: Optional[datetime] = None) -> Dict[str, Any]:
"""
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)
# Use naive datetime for comparison with database values
now = datetime.now(timezone.utc).replace(tzinfo=None)
# Get projects owned by the user
projects = db.query(Project).filter(Project.owner_id == user_id).all()
if not projects:
return {
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"projects": [],
"summary": {
"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 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 = []
blocked_tasks = []
next_week_tasks = []
for task in all_tasks:
# Use TaskStatus.is_done flag instead of magic strings
is_done = task.status.is_done if task.status else False
# Check if completed (updated this week)
if is_done:
if task.updated_at and task.updated_at >= week_start:
completed_tasks.append(task)
else:
# Check if task has active status (not done, not blocked)
# Tasks without a done status are considered in progress
if task.status and not task.status.is_done:
in_progress_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 = []
for project in projects:
project_tasks = [t for t in all_tasks if t.project_id == project.id]
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,
"project_title": project.title,
"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),
# 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 {
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"generated_at": datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
"projects": project_details,
"summary": {
"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),
}
}
@staticmethod
def generate_weekly_report(db: Session, user_id: str) -> Optional[ReportHistory]:
"""
Generate a weekly report for a user and save to history.
"""
# Get or create scheduled report for this user
scheduled_report = db.query(ScheduledReport).filter(
ScheduledReport.recipient_id == user_id,
ScheduledReport.report_type == "weekly",
).first()
if not scheduled_report:
scheduled_report = ScheduledReport(
id=str(uuid.uuid4()),
report_type="weekly",
recipient_id=user_id,
is_active=True,
)
db.add(scheduled_report)
db.flush()
# Generate report content
content = ReportService.get_weekly_stats(db, user_id)
# Save to history
report_history = ReportHistory(
id=str(uuid.uuid4()),
report_id=scheduled_report.id,
content=content,
status="sent",
)
db.add(report_history)
# Update last_sent_at
# Use naive datetime for consistency with database storage
scheduled_report.last_sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
db.commit()
return report_history
@staticmethod
def send_report_notification(
db: Session,
user_id: str,
report_content: Dict[str, Any],
) -> None:
"""Send a notification with the weekly report summary."""
summary = report_content.get("summary", {})
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}"
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,
user_id=user_id,
notification_type="status_change",
reference_type="report",
reference_id="weekly",
title="週報:專案進度彙整",
message=message,
)
@staticmethod
async def generate_all_weekly_reports(db: Session) -> List[str]:
"""
Generate weekly reports for all active subscriptions.
Called by the scheduler on Friday 16:00.
"""
generated_for = []
# Get all active scheduled reports
active_reports = db.query(ScheduledReport).filter(
ScheduledReport.is_active == True,
ScheduledReport.report_type == "weekly",
).all()
for scheduled_report in active_reports:
try:
# Generate report
content = ReportService.get_weekly_stats(db, scheduled_report.recipient_id)
# Save history
history = ReportHistory(
id=str(uuid.uuid4()),
report_id=scheduled_report.id,
content=content,
status="sent",
)
db.add(history)
# Update last_sent_at
# Use naive datetime for consistency with database storage
scheduled_report.last_sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
# Send notification
ReportService.send_report_notification(db, scheduled_report.recipient_id, content)
generated_for.append(scheduled_report.recipient_id)
except Exception as e:
# Log failure
history = ReportHistory(
id=str(uuid.uuid4()),
report_id=scheduled_report.id,
content={},
status="failed",
error_message=str(e),
)
db.add(history)
db.commit()
return generated_for