feat: implement automation module

- Event-based triggers (Phase 1):
  - Trigger/TriggerLog models with field_change type
  - TriggerService for condition evaluation and action execution
  - Trigger CRUD API endpoints
  - Task integration (status, assignee, priority changes)
  - Frontend: TriggerList, TriggerForm components

- Weekly reports (Phase 2):
  - ScheduledReport/ReportHistory models
  - ReportService for stats generation
  - APScheduler for Friday 16:00 job
  - Report preview/generate/history API
  - Frontend: WeeklyReportPreview, ReportHistory components

- Tests: 23 new tests (14 triggers + 9 reports)
- OpenSpec: add-automation change archived

🤖 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-29 22:59:00 +08:00
parent 3108fe1dff
commit 95c281d8e1
32 changed files with 3163 additions and 3 deletions

View File

@@ -0,0 +1,3 @@
from app.api.reports.router import router
__all__ = ["router"]

View File

@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.models import User, ReportHistory, ScheduledReport
from app.schemas.report import (
WeeklyReportContent, ReportHistoryListResponse, ReportHistoryItem,
GenerateReportResponse, ReportSummary
)
from app.middleware.auth import get_current_user
from app.services.report_service import ReportService
router = APIRouter(tags=["reports"])
@router.get("/api/reports/weekly/preview", response_model=WeeklyReportContent)
async def preview_weekly_report(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Preview the weekly report for the current user.
Shows what would be included in the next weekly report.
"""
content = ReportService.get_weekly_stats(db, current_user.id)
return WeeklyReportContent(
week_start=content["week_start"],
week_end=content["week_end"],
generated_at=content["generated_at"],
projects=content["projects"],
summary=ReportSummary(**content["summary"]),
)
@router.post("/api/reports/weekly/generate", response_model=GenerateReportResponse)
async def generate_weekly_report(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Manually trigger weekly report generation for the current user.
"""
# Generate report
report_history = ReportService.generate_weekly_report(db, current_user.id)
if not report_history:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate report",
)
# Send notification
ReportService.send_report_notification(db, current_user.id, report_history.content)
db.commit()
summary = report_history.content.get("summary", {})
return GenerateReportResponse(
message="Weekly report generated successfully",
report_id=report_history.id,
summary=ReportSummary(
completed_count=summary.get("completed_count", 0),
in_progress_count=summary.get("in_progress_count", 0),
overdue_count=summary.get("overdue_count", 0),
total_tasks=summary.get("total_tasks", 0),
),
)
@router.get("/api/reports/history", response_model=ReportHistoryListResponse)
async def list_report_history(
limit: int = 10,
offset: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List report history for the current user.
"""
# Get scheduled report for this user
scheduled_report = db.query(ScheduledReport).filter(
ScheduledReport.recipient_id == current_user.id,
).first()
if not scheduled_report:
return ReportHistoryListResponse(reports=[], total=0)
# Get history
total = db.query(ReportHistory).filter(
ReportHistory.report_id == scheduled_report.id,
).count()
history = db.query(ReportHistory).filter(
ReportHistory.report_id == scheduled_report.id,
).order_by(ReportHistory.generated_at.desc()).offset(offset).limit(limit).all()
return ReportHistoryListResponse(
reports=[
ReportHistoryItem(
id=h.id,
report_id=h.report_id,
generated_at=h.generated_at,
content=h.content,
status=h.status,
error_message=h.error_message,
) for h in history
],
total=total,
)
@router.get("/api/reports/history/{report_id}")
async def get_report_detail(
report_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get detailed content of a specific report.
"""
report = db.query(ReportHistory).filter(ReportHistory.id == report_id).first()
if not report:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report not found",
)
# Check ownership
scheduled_report = report.report
if scheduled_report.recipient_id != current_user.id and not current_user.is_system_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
return ReportHistoryItem(
id=report.id,
report_id=report.report_id,
generated_at=report.generated_at,
content=report.content,
status=report.status,
error_message=report.error_message,
)

View File

@@ -14,6 +14,7 @@ from app.middleware.auth import (
) )
from app.middleware.audit import get_audit_metadata from app.middleware.audit import get_audit_metadata
from app.services.audit_service import AuditService from app.services.audit_service import AuditService
from app.services.trigger_service import TriggerService
router = APIRouter(tags=["tasks"]) router = APIRouter(tags=["tasks"])
@@ -271,7 +272,7 @@ async def update_task(
detail="Permission denied", detail="Permission denied",
) )
# Capture old values for audit # Capture old values for audit and triggers
old_values = { old_values = {
"title": task.title, "title": task.title,
"description": task.description, "description": task.description,
@@ -289,7 +290,7 @@ async def update_task(
else: else:
setattr(task, field, value) setattr(task, field, value)
# Capture new values for audit # Capture new values for audit and triggers
new_values = { new_values = {
"title": task.title, "title": task.title,
"description": task.description, "description": task.description,
@@ -313,6 +314,10 @@ async def update_task(
request_metadata=get_audit_metadata(request), request_metadata=get_audit_metadata(request),
) )
# Evaluate triggers for priority changes
if "priority" in update_data:
TriggerService.evaluate_triggers(db, task, old_values, new_values, current_user)
db.commit() db.commit()
db.refresh(task) db.refresh(task)
@@ -397,6 +402,9 @@ async def update_task_status(
detail="Status not found in this project", detail="Status not found in this project",
) )
# Capture old status for triggers
old_status_id = task.status_id
task.status_id = status_data.status_id task.status_id = status_data.status_id
# Auto-set blocker_flag based on status name # Auto-set blocker_flag based on status name
@@ -405,6 +413,15 @@ async def update_task_status(
else: else:
task.blocker_flag = False task.blocker_flag = False
# Evaluate triggers for status changes
if old_status_id != status_data.status_id:
TriggerService.evaluate_triggers(
db, task,
{"status_id": old_status_id},
{"status_id": status_data.status_id},
current_user
)
db.commit() db.commit()
db.refresh(task) db.refresh(task)
@@ -460,6 +477,15 @@ async def assign_task(
request_metadata=get_audit_metadata(request), request_metadata=get_audit_metadata(request),
) )
# Evaluate triggers for assignee changes
if old_assignee_id != assign_data.assignee_id:
TriggerService.evaluate_triggers(
db, task,
{"assignee_id": old_assignee_id},
{"assignee_id": assign_data.assignee_id},
current_user
)
db.commit() db.commit()
db.refresh(task) db.refresh(task)

View File

@@ -0,0 +1,3 @@
from app.api.triggers.router import router
__all__ = ["router"]

View File

@@ -0,0 +1,276 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.models import User, Project, Trigger, TriggerLog
from app.schemas.trigger import (
TriggerCreate, TriggerUpdate, TriggerResponse, TriggerListResponse,
TriggerLogResponse, TriggerLogListResponse, TriggerUserInfo
)
from app.middleware.auth import get_current_user, check_project_access, check_project_edit_access
router = APIRouter(tags=["triggers"])
def trigger_to_response(trigger: Trigger) -> TriggerResponse:
"""Convert Trigger model to TriggerResponse."""
return TriggerResponse(
id=trigger.id,
project_id=trigger.project_id,
name=trigger.name,
description=trigger.description,
trigger_type=trigger.trigger_type,
conditions=trigger.conditions,
actions=trigger.actions if isinstance(trigger.actions, list) else [trigger.actions],
is_active=trigger.is_active,
created_by=trigger.created_by,
created_at=trigger.created_at,
updated_at=trigger.updated_at,
creator=TriggerUserInfo(
id=trigger.creator.id,
name=trigger.creator.name,
email=trigger.creator.email,
) if trigger.creator else None,
)
@router.post("/api/projects/{project_id}/triggers", response_model=TriggerResponse, status_code=status.HTTP_201_CREATED)
async def create_trigger(
project_id: str,
trigger_data: TriggerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new trigger for a project."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found",
)
if not check_project_edit_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied - only project owner can manage triggers",
)
# Validate trigger type
if trigger_data.trigger_type not in ["field_change", "schedule"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid trigger type. Must be 'field_change' or 'schedule'",
)
# Validate conditions
if trigger_data.conditions.field not in ["status_id", "assignee_id", "priority"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid condition field. Must be 'status_id', 'assignee_id', or 'priority'",
)
if trigger_data.conditions.operator not in ["equals", "not_equals", "changed_to", "changed_from"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid operator. Must be 'equals', 'not_equals', 'changed_to', or 'changed_from'",
)
# Create trigger
trigger = Trigger(
id=str(uuid.uuid4()),
project_id=project_id,
name=trigger_data.name,
description=trigger_data.description,
trigger_type=trigger_data.trigger_type,
conditions=trigger_data.conditions.model_dump(),
actions=[a.model_dump() for a in trigger_data.actions],
is_active=trigger_data.is_active,
created_by=current_user.id,
)
db.add(trigger)
db.commit()
db.refresh(trigger)
return trigger_to_response(trigger)
@router.get("/api/projects/{project_id}/triggers", response_model=TriggerListResponse)
async def list_triggers(
project_id: str,
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all triggers for a project."""
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found",
)
if not check_project_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
query = db.query(Trigger).filter(Trigger.project_id == project_id)
if is_active is not None:
query = query.filter(Trigger.is_active == is_active)
triggers = query.order_by(Trigger.created_at.desc()).all()
return TriggerListResponse(
triggers=[trigger_to_response(t) for t in triggers],
total=len(triggers),
)
@router.get("/api/triggers/{trigger_id}", response_model=TriggerResponse)
async def get_trigger(
trigger_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a specific trigger by ID."""
trigger = db.query(Trigger).filter(Trigger.id == trigger_id).first()
if not trigger:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Trigger not found",
)
project = trigger.project
if not check_project_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
return trigger_to_response(trigger)
@router.put("/api/triggers/{trigger_id}", response_model=TriggerResponse)
async def update_trigger(
trigger_id: str,
trigger_data: TriggerUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a trigger."""
trigger = db.query(Trigger).filter(Trigger.id == trigger_id).first()
if not trigger:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Trigger not found",
)
project = trigger.project
if not check_project_edit_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
# Update fields if provided
if trigger_data.name is not None:
trigger.name = trigger_data.name
if trigger_data.description is not None:
trigger.description = trigger_data.description
if trigger_data.conditions is not None:
# Validate conditions
if trigger_data.conditions.field not in ["status_id", "assignee_id", "priority"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid condition field",
)
trigger.conditions = trigger_data.conditions.model_dump()
if trigger_data.actions is not None:
trigger.actions = [a.model_dump() for a in trigger_data.actions]
if trigger_data.is_active is not None:
trigger.is_active = trigger_data.is_active
db.commit()
db.refresh(trigger)
return trigger_to_response(trigger)
@router.delete("/api/triggers/{trigger_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_trigger(
trigger_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a trigger."""
trigger = db.query(Trigger).filter(Trigger.id == trigger_id).first()
if not trigger:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Trigger not found",
)
project = trigger.project
if not check_project_edit_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
db.delete(trigger)
db.commit()
@router.get("/api/triggers/{trigger_id}/logs", response_model=TriggerLogListResponse)
async def list_trigger_logs(
trigger_id: str,
limit: int = 50,
offset: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get execution logs for a trigger."""
trigger = db.query(Trigger).filter(Trigger.id == trigger_id).first()
if not trigger:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Trigger not found",
)
project = trigger.project
if not check_project_access(current_user, project):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
total = db.query(TriggerLog).filter(TriggerLog.trigger_id == trigger_id).count()
logs = db.query(TriggerLog).filter(
TriggerLog.trigger_id == trigger_id,
).order_by(TriggerLog.executed_at.desc()).offset(offset).limit(limit).all()
return TriggerLogListResponse(
logs=[
TriggerLogResponse(
id=log.id,
trigger_id=log.trigger_id,
task_id=log.task_id,
executed_at=log.executed_at,
status=log.status,
details=log.details,
error_message=log.error_message,
) for log in logs
],
total=total,
)

View File

@@ -0,0 +1,53 @@
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from app.core.database import SessionLocal
from app.services.report_service import ReportService
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
async def weekly_report_job():
"""Job function to generate weekly reports."""
logger.info("Starting weekly report generation...")
db = SessionLocal()
try:
generated_for = await ReportService.generate_all_weekly_reports(db)
logger.info(f"Weekly reports generated for {len(generated_for)} users")
except Exception as e:
logger.error(f"Error generating weekly reports: {e}")
finally:
db.close()
def init_scheduler():
"""Initialize the scheduler with jobs."""
# Weekly report - Every Friday at 16:00
scheduler.add_job(
weekly_report_job,
CronTrigger(day_of_week='fri', hour=16, minute=0),
id='weekly_report',
name='Generate Weekly Reports',
replace_existing=True,
)
logger.info("Scheduler initialized with weekly report job (Friday 16:00)")
def start_scheduler():
"""Start the scheduler."""
if not scheduler.running:
init_scheduler()
scheduler.start()
logger.info("Scheduler started")
def shutdown_scheduler():
"""Shutdown the scheduler gracefully."""
if scheduler.running:
scheduler.shutdown(wait=False)
logger.info("Scheduler shutdown")

View File

@@ -1,7 +1,19 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.middleware.audit import AuditMiddleware from app.middleware.audit import AuditMiddleware
from app.core.scheduler import start_scheduler, shutdown_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifespan events."""
# Startup
start_scheduler()
yield
# Shutdown
shutdown_scheduler()
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.users import router as users_router from app.api.users import router as users_router
from app.api.departments import router as departments_router from app.api.departments import router as departments_router
@@ -15,12 +27,15 @@ from app.api.blockers import router as blockers_router
from app.api.websocket import router as websocket_router from app.api.websocket import router as websocket_router
from app.api.audit import router as audit_router from app.api.audit import router as audit_router
from app.api.attachments import router as attachments_router from app.api.attachments import router as attachments_router
from app.api.triggers import router as triggers_router
from app.api.reports import router as reports_router
from app.core.config import settings from app.core.config import settings
app = FastAPI( app = FastAPI(
title="Project Control API", title="Project Control API",
description="Cross-departmental project management system API", description="Cross-departmental project management system API",
version="0.1.0", version="0.1.0",
lifespan=lifespan,
) )
# CORS middleware # CORS middleware
@@ -49,6 +64,8 @@ app.include_router(blockers_router)
app.include_router(websocket_router) app.include_router(websocket_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(attachments_router) app.include_router(attachments_router)
app.include_router(triggers_router)
app.include_router(reports_router)
@app.get("/health") @app.get("/health")

View File

@@ -14,10 +14,16 @@ from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_
from app.models.audit_alert import AuditAlert from app.models.audit_alert import AuditAlert
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.attachment_version import AttachmentVersion from app.models.attachment_version import AttachmentVersion
from app.models.trigger import Trigger, TriggerType
from app.models.trigger_log import TriggerLog, TriggerLogStatus
from app.models.scheduled_report import ScheduledReport, ReportType
from app.models.report_history import ReportHistory, ReportHistoryStatus
__all__ = [ __all__ = [
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot", "User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
"Comment", "Mention", "Notification", "Blocker", "Comment", "Mention", "Notification", "Blocker",
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS", "AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS",
"Attachment", "AttachmentVersion" "Attachment", "AttachmentVersion",
"Trigger", "TriggerType", "TriggerLog", "TriggerLogStatus",
"ScheduledReport", "ReportType", "ReportHistory", "ReportHistoryStatus"
] ]

View File

@@ -38,3 +38,4 @@ class Project(Base):
department = relationship("Department", back_populates="projects") department = relationship("Department", back_populates="projects")
task_statuses = relationship("TaskStatus", back_populates="project", cascade="all, delete-orphan") task_statuses = relationship("TaskStatus", back_populates="project", cascade="all, delete-orphan")
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
triggers = relationship("Trigger", back_populates="project", cascade="all, delete-orphan")

View File

@@ -0,0 +1,28 @@
import uuid
import enum
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Enum, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class ReportHistoryStatus(str, enum.Enum):
SENT = "sent"
FAILED = "failed"
class ReportHistory(Base):
__tablename__ = "pjctrl_report_history"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
report_id = Column(String(36), ForeignKey("pjctrl_scheduled_reports.id", ondelete="CASCADE"), nullable=False)
generated_at = Column(DateTime, server_default=func.now(), nullable=False)
content = Column(JSON, nullable=False)
status = Column(
Enum("sent", "failed", name="report_history_status_enum"),
nullable=False
)
error_message = Column(Text, nullable=True)
# Relationships
report = relationship("ScheduledReport", back_populates="history")

View File

@@ -0,0 +1,28 @@
import uuid
import enum
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Enum
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class ReportType(str, enum.Enum):
WEEKLY = "weekly"
class ScheduledReport(Base):
__tablename__ = "pjctrl_scheduled_reports"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
report_type = Column(
Enum("weekly", name="report_type_enum"),
nullable=False
)
recipient_id = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="CASCADE"), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
last_sent_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
# Relationships
recipient = relationship("User", back_populates="scheduled_reports")
history = relationship("ReportHistory", back_populates="report", cascade="all, delete-orphan")

View File

@@ -48,3 +48,4 @@ class Task(Base):
comments = relationship("Comment", back_populates="task", cascade="all, delete-orphan") comments = relationship("Comment", back_populates="task", cascade="all, delete-orphan")
blockers = relationship("Blocker", back_populates="task", cascade="all, delete-orphan") blockers = relationship("Blocker", back_populates="task", cascade="all, delete-orphan")
attachments = relationship("Attachment", back_populates="task", cascade="all, delete-orphan") attachments = relationship("Attachment", back_populates="task", cascade="all, delete-orphan")
trigger_logs = relationship("TriggerLog", back_populates="task")

View File

@@ -0,0 +1,35 @@
import uuid
import enum
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Enum, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class TriggerType(str, enum.Enum):
FIELD_CHANGE = "field_change"
SCHEDULE = "schedule"
class Trigger(Base):
__tablename__ = "pjctrl_triggers"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String(36), ForeignKey("pjctrl_projects.id", ondelete="CASCADE"), nullable=False)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
trigger_type = Column(
Enum("field_change", "schedule", name="trigger_type_enum"),
nullable=False
)
conditions = Column(JSON, nullable=False)
actions = Column(JSON, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
project = relationship("Project", back_populates="triggers")
creator = relationship("User", back_populates="created_triggers")
logs = relationship("TriggerLog", back_populates="trigger", cascade="all, delete-orphan")

View File

@@ -0,0 +1,30 @@
import uuid
import enum
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Enum, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class TriggerLogStatus(str, enum.Enum):
SUCCESS = "success"
FAILED = "failed"
class TriggerLog(Base):
__tablename__ = "pjctrl_trigger_logs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
trigger_id = Column(String(36), ForeignKey("pjctrl_triggers.id", ondelete="CASCADE"), nullable=False)
task_id = Column(String(36), ForeignKey("pjctrl_tasks.id", ondelete="SET NULL"), nullable=True)
executed_at = Column(DateTime, server_default=func.now(), nullable=False)
status = Column(
Enum("success", "failed", name="trigger_log_status_enum"),
nullable=False
)
details = Column(JSON, nullable=True)
error_message = Column(Text, nullable=True)
# Relationships
trigger = relationship("Trigger", back_populates="logs")
task = relationship("Task", back_populates="trigger_logs")

View File

@@ -36,3 +36,7 @@ class User(Base):
notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan") notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan")
reported_blockers = relationship("Blocker", foreign_keys="Blocker.reported_by", back_populates="reporter") reported_blockers = relationship("Blocker", foreign_keys="Blocker.reported_by", back_populates="reporter")
resolved_blockers = relationship("Blocker", foreign_keys="Blocker.resolved_by", back_populates="resolver") resolved_blockers = relationship("Blocker", foreign_keys="Blocker.resolved_by", back_populates="resolver")
# Automation relationships
created_triggers = relationship("Trigger", back_populates="creator")
scheduled_reports = relationship("ScheduledReport", back_populates="recipient", cascade="all, delete-orphan")

View File

@@ -0,0 +1,50 @@
from datetime import datetime
from typing import Optional, List, Dict, Any
from pydantic import BaseModel
class ProjectSummary(BaseModel):
project_id: str
project_title: str
completed_count: int
in_progress_count: int
overdue_count: int
total_tasks: int
class ReportSummary(BaseModel):
completed_count: int
in_progress_count: int
overdue_count: int
total_tasks: int
class WeeklyReportContent(BaseModel):
week_start: str
week_end: str
generated_at: str
projects: List[Dict[str, Any]]
summary: ReportSummary
class ReportHistoryItem(BaseModel):
id: str
report_id: str
generated_at: datetime
content: Dict[str, Any]
status: str
error_message: Optional[str] = None
class Config:
from_attributes = True
class ReportHistoryListResponse(BaseModel):
reports: List[ReportHistoryItem]
total: int
class GenerateReportResponse(BaseModel):
message: str
report_id: str
summary: ReportSummary

View File

@@ -0,0 +1,82 @@
from datetime import datetime
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
class TriggerCondition(BaseModel):
field: str = Field(..., description="Field to check: status_id, assignee_id, priority")
operator: str = Field(..., description="Operator: equals, not_equals, changed_to, changed_from")
value: str = Field(..., description="Value to compare against")
class TriggerAction(BaseModel):
type: str = Field(default="notify", description="Action type: notify")
target: str = Field(default="assignee", description="Target: assignee, creator, project_owner, user:<id>")
template: Optional[str] = Field(None, description="Message template with variables")
class TriggerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=2000)
trigger_type: str = Field(default="field_change")
conditions: TriggerCondition
actions: List[TriggerAction]
is_active: bool = Field(default=True)
class TriggerUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=2000)
conditions: Optional[TriggerCondition] = None
actions: Optional[List[TriggerAction]] = None
is_active: Optional[bool] = None
class TriggerUserInfo(BaseModel):
id: str
name: str
email: str
class Config:
from_attributes = True
class TriggerResponse(BaseModel):
id: str
project_id: str
name: str
description: Optional[str]
trigger_type: str
conditions: Dict[str, Any]
actions: List[Dict[str, Any]]
is_active: bool
created_by: Optional[str]
created_at: datetime
updated_at: datetime
creator: Optional[TriggerUserInfo] = None
class Config:
from_attributes = True
class TriggerListResponse(BaseModel):
triggers: List[TriggerResponse]
total: int
class TriggerLogResponse(BaseModel):
id: str
trigger_id: str
task_id: Optional[str]
executed_at: datetime
status: str
details: Optional[Dict[str, Any]]
error_message: Optional[str]
class Config:
from_attributes = True
class TriggerLogListResponse(BaseModel):
logs: List[TriggerLogResponse]
total: int

View File

@@ -0,0 +1,228 @@
import uuid
from datetime import datetime, timedelta
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
)
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."""
if date is None:
date = datetime.utcnow()
# 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.
"""
if week_start is None:
week_start = ReportService.get_week_start()
week_end = week_start + timedelta(days=7)
# 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,
"total_tasks": 0,
}
}
project_ids = [p.id for p in projects]
# Get all tasks for these projects
all_tasks = db.query(Task).filter(Task.project_id.in_(project_ids)).all()
# Categorize tasks
completed_tasks = []
in_progress_tasks = []
overdue_tasks = []
now = datetime.utcnow()
for task in all_tasks:
status_name = task.status.name.lower() if task.status else ""
# Check if completed (updated this week)
if status_name in ["done", "completed", "完成"]:
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)
# Check if overdue
if task.due_date and task.due_date < now and status_name not in ["done", "completed", "完成"]:
overdue_tasks.append(task)
# 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_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),
"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]],
})
return {
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"generated_at": datetime.utcnow().isoformat(),
"projects": project_details,
"summary": {
"completed_count": len(completed_tasks),
"in_progress_count": len(in_progress_tasks),
"overdue_count": len(overdue_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
scheduled_report.last_sent_at = datetime.utcnow()
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)
message = f"本週完成 {completed} 項任務,進行中 {in_progress}"
if overdue > 0:
message += f",逾期 {overdue} 項需關注"
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
scheduled_report.last_sent_at = datetime.utcnow()
# 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

View File

@@ -0,0 +1,200 @@
import uuid
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from app.models import Trigger, TriggerLog, Task, User, Project
from app.services.notification_service import NotificationService
class TriggerService:
"""Service for evaluating and executing triggers."""
SUPPORTED_FIELDS = ["status_id", "assignee_id", "priority"]
SUPPORTED_OPERATORS = ["equals", "not_equals", "changed_to", "changed_from"]
@staticmethod
def evaluate_triggers(
db: Session,
task: Task,
old_values: Dict[str, Any],
new_values: Dict[str, Any],
current_user: User,
) -> List[TriggerLog]:
"""Evaluate all active triggers for a project when task values change."""
logs = []
# Get active field_change triggers for the project
triggers = db.query(Trigger).filter(
Trigger.project_id == task.project_id,
Trigger.is_active == True,
Trigger.trigger_type == "field_change",
).all()
for trigger in triggers:
if TriggerService._check_conditions(trigger.conditions, old_values, new_values):
log = TriggerService._execute_actions(db, trigger, task, current_user, old_values, new_values)
logs.append(log)
return logs
@staticmethod
def _check_conditions(
conditions: Dict[str, Any],
old_values: Dict[str, Any],
new_values: Dict[str, Any],
) -> bool:
"""Check if trigger conditions are met."""
field = conditions.get("field")
operator = conditions.get("operator")
value = conditions.get("value")
if field not in TriggerService.SUPPORTED_FIELDS:
return False
old_value = old_values.get(field)
new_value = new_values.get(field)
if operator == "equals":
return new_value == value
elif operator == "not_equals":
return new_value != value
elif operator == "changed_to":
return old_value != value and new_value == value
elif operator == "changed_from":
return old_value == value and new_value != value
return False
@staticmethod
def _execute_actions(
db: Session,
trigger: Trigger,
task: Task,
current_user: User,
old_values: Dict[str, Any],
new_values: Dict[str, Any],
) -> TriggerLog:
"""Execute trigger actions and log the result."""
actions = trigger.actions if isinstance(trigger.actions, list) else [trigger.actions]
executed_actions = []
error_message = None
try:
for action in actions:
action_type = action.get("type")
if action_type == "notify":
TriggerService._execute_notify_action(db, action, task, current_user, old_values, new_values)
executed_actions.append({"type": action_type, "status": "success"})
status = "success"
except Exception as e:
status = "failed"
error_message = str(e)
executed_actions.append({"type": "error", "message": str(e)})
log = TriggerLog(
id=str(uuid.uuid4()),
trigger_id=trigger.id,
task_id=task.id,
status=status,
details={
"trigger_name": trigger.name,
"old_values": old_values,
"new_values": new_values,
"actions_executed": executed_actions,
},
error_message=error_message,
)
db.add(log)
return log
@staticmethod
def _execute_notify_action(
db: Session,
action: Dict[str, Any],
task: Task,
current_user: User,
old_values: Dict[str, Any],
new_values: Dict[str, Any],
) -> None:
"""Execute a notify action."""
target = action.get("target", "assignee")
template = action.get("template", "任務 {task_title} 已觸發自動化規則")
# Resolve target user
target_user_id = TriggerService._resolve_target(task, target)
if not target_user_id:
return
# Don't notify the user who triggered the action
if target_user_id == current_user.id:
return
# Format message with variables
message = TriggerService._format_template(template, task, old_values, new_values)
NotificationService.create_notification(
db=db,
user_id=target_user_id,
notification_type="status_change",
reference_type="task",
reference_id=task.id,
title=f"自動化通知: {task.title}",
message=message,
)
@staticmethod
def _resolve_target(task: Task, target: str) -> Optional[str]:
"""Resolve notification target to user ID."""
if target == "assignee":
return task.assignee_id
elif target == "creator":
return task.created_by
elif target == "project_owner":
return task.project.owner_id if task.project else None
elif target.startswith("user:"):
return target.split(":", 1)[1]
return None
@staticmethod
def _format_template(
template: str,
task: Task,
old_values: Dict[str, Any],
new_values: Dict[str, Any],
) -> str:
"""Format message template with task variables."""
replacements = {
"{task_title}": task.title,
"{task_id}": task.id,
"{old_value}": str(old_values.get("status_id", old_values.get("assignee_id", old_values.get("priority", "")))),
"{new_value}": str(new_values.get("status_id", new_values.get("assignee_id", new_values.get("priority", "")))),
}
result = template
for key, value in replacements.items():
result = result.replace(key, value)
return result
@staticmethod
def log_execution(
db: Session,
trigger: Trigger,
task: Optional[Task],
status: str,
details: Optional[Dict[str, Any]] = None,
error_message: Optional[str] = None,
) -> TriggerLog:
"""Log a trigger execution."""
log = TriggerLog(
id=str(uuid.uuid4()),
trigger_id=trigger.id,
task_id=task.id if task else None,
status=status,
details=details,
error_message=error_message,
)
db.add(log)
return log

View File

@@ -0,0 +1,96 @@
"""Create automation tables
Revision ID: 007
Revises: 006
Create Date: 2024-12-29
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '007'
down_revision = '006'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create triggers table
op.create_table(
'pjctrl_triggers',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('project_id', sa.String(36), sa.ForeignKey('pjctrl_projects.id', ondelete='CASCADE'), nullable=False),
sa.Column('name', sa.String(200), nullable=False),
sa.Column('description', sa.Text, nullable=True),
sa.Column('trigger_type', sa.Enum('field_change', 'schedule', name='trigger_type_enum'), nullable=False),
sa.Column('conditions', sa.JSON, nullable=False),
sa.Column('actions', sa.JSON, nullable=False),
sa.Column('is_active', sa.Boolean, server_default='1', nullable=False),
sa.Column('created_by', sa.String(36), sa.ForeignKey('pjctrl_users.id', ondelete='SET NULL'), nullable=True),
sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
)
# Create index for triggers
op.create_index('idx_trigger_project', 'pjctrl_triggers', ['project_id', 'is_active'])
# Create trigger_logs table
op.create_table(
'pjctrl_trigger_logs',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('trigger_id', sa.String(36), sa.ForeignKey('pjctrl_triggers.id', ondelete='CASCADE'), nullable=False),
sa.Column('task_id', sa.String(36), sa.ForeignKey('pjctrl_tasks.id', ondelete='SET NULL'), nullable=True),
sa.Column('executed_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
sa.Column('status', sa.Enum('success', 'failed', name='trigger_log_status_enum'), nullable=False),
sa.Column('details', sa.JSON, nullable=True),
sa.Column('error_message', sa.Text, nullable=True),
)
# Create indexes for trigger_logs
op.create_index('idx_trigger_log_trigger', 'pjctrl_trigger_logs', ['trigger_id', 'executed_at'])
op.create_index('idx_trigger_log_task', 'pjctrl_trigger_logs', ['task_id'])
# Create scheduled_reports table
op.create_table(
'pjctrl_scheduled_reports',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('report_type', sa.Enum('weekly', name='report_type_enum'), nullable=False),
sa.Column('recipient_id', sa.String(36), sa.ForeignKey('pjctrl_users.id', ondelete='CASCADE'), nullable=False),
sa.Column('is_active', sa.Boolean, server_default='1', nullable=False),
sa.Column('last_sent_at', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
)
# Create index for scheduled_reports
op.create_index('idx_scheduled_report_recipient', 'pjctrl_scheduled_reports', ['recipient_id', 'is_active'])
# Create report_history table
op.create_table(
'pjctrl_report_history',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('report_id', sa.String(36), sa.ForeignKey('pjctrl_scheduled_reports.id', ondelete='CASCADE'), nullable=False),
sa.Column('generated_at', sa.DateTime, server_default=sa.func.now(), nullable=False),
sa.Column('content', sa.JSON, nullable=False),
sa.Column('status', sa.Enum('sent', 'failed', name='report_history_status_enum'), nullable=False),
sa.Column('error_message', sa.Text, nullable=True),
)
# Create index for report_history
op.create_index('idx_report_history', 'pjctrl_report_history', ['report_id', 'generated_at'])
def downgrade() -> None:
op.drop_index('idx_report_history', table_name='pjctrl_report_history')
op.drop_table('pjctrl_report_history')
op.drop_index('idx_scheduled_report_recipient', table_name='pjctrl_scheduled_reports')
op.drop_table('pjctrl_scheduled_reports')
op.drop_index('idx_trigger_log_task', table_name='pjctrl_trigger_logs')
op.drop_index('idx_trigger_log_trigger', table_name='pjctrl_trigger_logs')
op.drop_table('pjctrl_trigger_logs')
op.drop_index('idx_trigger_project', table_name='pjctrl_triggers')
op.drop_table('pjctrl_triggers')
op.execute("DROP TYPE IF EXISTS trigger_type_enum")
op.execute("DROP TYPE IF EXISTS trigger_log_status_enum")
op.execute("DROP TYPE IF EXISTS report_type_enum")
op.execute("DROP TYPE IF EXISTS report_history_status_enum")

View File

@@ -0,0 +1,260 @@
import pytest
import uuid
from datetime import datetime, timedelta
from app.models import User, Space, Project, Task, TaskStatus, ScheduledReport, ReportHistory
from app.services.report_service import ReportService
@pytest.fixture
def test_user(db):
"""Create a test user."""
user = User(
id=str(uuid.uuid4()),
email="reportuser@example.com",
name="Report User",
role_id="00000000-0000-0000-0000-000000000003",
is_active=True,
is_system_admin=False,
)
db.add(user)
db.commit()
return user
@pytest.fixture
def test_user_token(client, mock_redis, test_user):
"""Get a token for test user."""
from app.core.security import create_access_token, create_token_payload
token_data = create_token_payload(
user_id=test_user.id,
email=test_user.email,
role="engineer",
department_id=None,
is_system_admin=False,
)
token = create_access_token(token_data)
mock_redis.setex(f"session:{test_user.id}", 900, token)
return token
@pytest.fixture
def test_space(db, test_user):
"""Create a test space."""
space = Space(
id=str(uuid.uuid4()),
name="Report Test Space",
description="Test space for reports",
owner_id=test_user.id,
)
db.add(space)
db.commit()
return space
@pytest.fixture
def test_project(db, test_space, test_user):
"""Create a test project."""
project = Project(
id=str(uuid.uuid4()),
space_id=test_space.id,
title="Report Test Project",
description="Test project for reports",
owner_id=test_user.id,
)
db.add(project)
db.commit()
return project
@pytest.fixture
def test_statuses(db, test_project):
"""Create test task statuses."""
todo = TaskStatus(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="To Do",
color="#808080",
position=0,
)
in_progress = TaskStatus(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="In Progress",
color="#0000FF",
position=1,
)
done = TaskStatus(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="Done",
color="#00FF00",
position=2,
)
db.add_all([todo, in_progress, done])
db.commit()
return {"todo": todo, "in_progress": in_progress, "done": done}
@pytest.fixture
def test_tasks(db, test_project, test_user, test_statuses):
"""Create test tasks with various statuses."""
tasks = []
# Completed task (updated this week)
completed_task = Task(
id=str(uuid.uuid4()),
project_id=test_project.id,
title="Completed Task",
status_id=test_statuses["done"].id,
created_by=test_user.id,
)
completed_task.updated_at = datetime.utcnow()
tasks.append(completed_task)
# In progress task
in_progress_task = Task(
id=str(uuid.uuid4()),
project_id=test_project.id,
title="In Progress Task",
status_id=test_statuses["in_progress"].id,
created_by=test_user.id,
)
tasks.append(in_progress_task)
# Overdue task
overdue_task = Task(
id=str(uuid.uuid4()),
project_id=test_project.id,
title="Overdue Task",
status_id=test_statuses["todo"].id,
due_date=datetime.utcnow() - timedelta(days=3),
created_by=test_user.id,
)
tasks.append(overdue_task)
db.add_all(tasks)
db.commit()
return tasks
class TestReportService:
"""Tests for ReportService."""
def test_get_week_start(self):
"""Test week start calculation."""
# Test with a known Wednesday
wednesday = datetime(2024, 12, 25, 15, 30, 0) # Wednesday
week_start = ReportService.get_week_start(wednesday)
assert week_start.weekday() == 0 # Monday
assert week_start.hour == 0
assert week_start.minute == 0
def test_get_weekly_stats_empty(self, db, test_user):
"""Test weekly stats with no projects."""
stats = ReportService.get_weekly_stats(db, test_user.id)
assert stats["summary"]["completed_count"] == 0
assert stats["summary"]["in_progress_count"] == 0
assert stats["summary"]["total_tasks"] == 0
assert len(stats["projects"]) == 0
def test_get_weekly_stats_with_tasks(self, db, test_user, test_project, test_tasks, test_statuses):
"""Test weekly stats with tasks."""
stats = ReportService.get_weekly_stats(db, test_user.id)
assert stats["summary"]["completed_count"] == 1
assert stats["summary"]["in_progress_count"] == 1
assert stats["summary"]["overdue_count"] == 1
assert stats["summary"]["total_tasks"] == 3
assert len(stats["projects"]) == 1
assert stats["projects"][0]["project_title"] == "Report Test Project"
def test_generate_weekly_report(self, db, test_user, test_project, test_tasks, test_statuses):
"""Test generating a weekly report."""
report = ReportService.generate_weekly_report(db, test_user.id)
assert report is not None
assert report.status == "sent"
assert "summary" in report.content
# Check scheduled report was created
scheduled = db.query(ScheduledReport).filter(
ScheduledReport.recipient_id == test_user.id
).first()
assert scheduled is not None
assert scheduled.last_sent_at is not None
class TestReportAPI:
"""Tests for Report API endpoints."""
def test_preview_weekly_report(self, client, test_user_token, test_project, test_tasks, test_statuses):
"""Test previewing weekly report."""
response = client.get(
"/api/reports/weekly/preview",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 200
data = response.json()
assert "summary" in data
assert "projects" in data
assert data["summary"]["total_tasks"] == 3
def test_generate_weekly_report_api(self, client, test_user_token, test_project, test_tasks, test_statuses):
"""Test generating weekly report via API."""
response = client.post(
"/api/reports/weekly/generate",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["message"] == "Weekly report generated successfully"
assert "report_id" in data
assert "summary" in data
def test_list_report_history_empty(self, client, test_user_token):
"""Test listing report history when empty."""
response = client.get(
"/api/reports/history",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert len(data["reports"]) == 0
def test_list_report_history_with_reports(self, client, test_user_token, test_project, test_tasks, test_statuses, db, test_user):
"""Test listing report history with existing reports."""
# Generate a report first
ReportService.generate_weekly_report(db, test_user.id)
response = client.get(
"/api/reports/history",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["reports"]) >= 1
assert data["reports"][0]["status"] == "sent"
def test_get_report_detail(self, client, test_user_token, test_project, test_tasks, test_statuses, db, test_user):
"""Test getting specific report detail."""
# Generate a report first
report = ReportService.generate_weekly_report(db, test_user.id)
response = client.get(
f"/api/reports/history/{report.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["id"] == report.id
assert "content" in data

View File

@@ -0,0 +1,377 @@
import pytest
import uuid
from app.models import User, Space, Project, Task, TaskStatus, Trigger, TriggerLog, Notification
from app.services.trigger_service import TriggerService
@pytest.fixture
def test_user(db):
"""Create a test user."""
user = User(
id=str(uuid.uuid4()),
email="testuser@example.com",
name="Test User",
role_id="00000000-0000-0000-0000-000000000003",
is_active=True,
is_system_admin=False,
)
db.add(user)
db.commit()
return user
@pytest.fixture
def test_user_token(client, mock_redis, test_user):
"""Get a token for test user."""
from app.core.security import create_access_token, create_token_payload
token_data = create_token_payload(
user_id=test_user.id,
email=test_user.email,
role="engineer",
department_id=None,
is_system_admin=False,
)
token = create_access_token(token_data)
mock_redis.setex(f"session:{test_user.id}", 900, token)
return token
@pytest.fixture
def test_space(db, test_user):
"""Create a test space."""
space = Space(
id=str(uuid.uuid4()),
name="Test Space",
description="Test space for triggers",
owner_id=test_user.id,
)
db.add(space)
db.commit()
return space
@pytest.fixture
def test_project(db, test_space, test_user):
"""Create a test project."""
project = Project(
id=str(uuid.uuid4()),
space_id=test_space.id,
title="Test Project",
description="Test project for triggers",
owner_id=test_user.id,
)
db.add(project)
db.commit()
return project
@pytest.fixture
def test_status(db, test_project):
"""Create test task statuses."""
status1 = TaskStatus(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="To Do",
color="#808080",
position=0,
)
status2 = TaskStatus(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="In Progress",
color="#0000FF",
position=1,
)
db.add(status1)
db.add(status2)
db.commit()
return status1, status2
@pytest.fixture
def test_task(db, test_project, test_user, test_status):
"""Create a test task."""
task = Task(
id=str(uuid.uuid4()),
project_id=test_project.id,
title="Test Task",
description="Test task for triggers",
status_id=test_status[0].id,
created_by=test_user.id,
assignee_id=test_user.id,
)
db.add(task)
db.commit()
return task
@pytest.fixture
def test_trigger(db, test_project, test_user, test_status):
"""Create a test trigger."""
trigger = Trigger(
id=str(uuid.uuid4()),
project_id=test_project.id,
name="Status Change Trigger",
description="Notify when status changes to In Progress",
trigger_type="field_change",
conditions={
"field": "status_id",
"operator": "changed_to",
"value": test_status[1].id,
},
actions=[{
"type": "notify",
"target": "assignee",
"template": "Task {task_title} status changed",
}],
is_active=True,
created_by=test_user.id,
)
db.add(trigger)
db.commit()
return trigger
class TestTriggerService:
"""Tests for TriggerService."""
def test_check_conditions_changed_to(self, db, test_status):
"""Test changed_to condition."""
conditions = {
"field": "status_id",
"operator": "changed_to",
"value": test_status[1].id,
}
old_values = {"status_id": test_status[0].id}
new_values = {"status_id": test_status[1].id}
result = TriggerService._check_conditions(conditions, old_values, new_values)
assert result is True
def test_check_conditions_changed_to_no_match(self, db, test_status):
"""Test changed_to condition when value doesn't match."""
conditions = {
"field": "status_id",
"operator": "changed_to",
"value": test_status[1].id,
}
old_values = {"status_id": test_status[1].id}
new_values = {"status_id": test_status[0].id}
result = TriggerService._check_conditions(conditions, old_values, new_values)
assert result is False
def test_check_conditions_equals(self, db, test_status):
"""Test equals condition."""
conditions = {
"field": "status_id",
"operator": "equals",
"value": test_status[1].id,
}
old_values = {"status_id": test_status[0].id}
new_values = {"status_id": test_status[1].id}
result = TriggerService._check_conditions(conditions, old_values, new_values)
assert result is True
def test_check_conditions_not_equals(self, db, test_status):
"""Test not_equals condition."""
conditions = {
"field": "status_id",
"operator": "not_equals",
"value": test_status[0].id,
}
old_values = {"status_id": test_status[0].id}
new_values = {"status_id": test_status[1].id}
result = TriggerService._check_conditions(conditions, old_values, new_values)
assert result is True
def test_evaluate_triggers_creates_notification(self, db, test_task, test_trigger, test_user, test_status):
"""Test that evaluate_triggers creates notification when conditions match."""
# Create another user to receive notification
other_user = User(
id=str(uuid.uuid4()),
email="other@example.com",
name="Other User",
role_id="00000000-0000-0000-0000-000000000003",
is_active=True,
)
db.add(other_user)
test_task.assignee_id = other_user.id
db.commit()
old_values = {"status_id": test_status[0].id}
new_values = {"status_id": test_status[1].id}
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
db.commit()
assert len(logs) == 1
assert logs[0].status == "success"
# Check notification was created
notifications = db.query(Notification).filter(
Notification.user_id == other_user.id
).all()
assert len(notifications) == 1
def test_evaluate_triggers_inactive_trigger_not_executed(self, db, test_task, test_trigger, test_user, test_status):
"""Test that inactive triggers are not executed."""
test_trigger.is_active = False
db.commit()
old_values = {"status_id": test_status[0].id}
new_values = {"status_id": test_status[1].id}
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
assert len(logs) == 0
class TestTriggerAPI:
"""Tests for Trigger API endpoints."""
def test_create_trigger(self, client, test_user_token, test_project, test_status):
"""Test creating a trigger."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
json={
"name": "New Trigger",
"description": "Test trigger",
"trigger_type": "field_change",
"conditions": {
"field": "status_id",
"operator": "changed_to",
"value": test_status[1].id,
},
"actions": [{
"type": "notify",
"target": "assignee",
}],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "New Trigger"
assert data["is_active"] is True
def test_list_triggers(self, client, test_user_token, test_project, test_trigger):
"""Test listing triggers."""
response = client.get(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["triggers"]) >= 1
def test_get_trigger(self, client, test_user_token, test_trigger):
"""Test getting a specific trigger."""
response = client.get(
f"/api/triggers/{test_trigger.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["id"] == test_trigger.id
assert data["name"] == test_trigger.name
def test_update_trigger(self, client, test_user_token, test_trigger):
"""Test updating a trigger."""
response = client.put(
f"/api/triggers/{test_trigger.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
json={
"name": "Updated Trigger",
"is_active": False,
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Trigger"
assert data["is_active"] is False
def test_delete_trigger(self, client, test_user_token, test_trigger):
"""Test deleting a trigger."""
response = client.delete(
f"/api/triggers/{test_trigger.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 204
# Verify deletion
response = client.get(
f"/api/triggers/{test_trigger.id}",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 404
def test_get_trigger_logs(self, client, test_user_token, test_trigger, db):
"""Test getting trigger logs."""
# Create a log entry
log = TriggerLog(
id=str(uuid.uuid4()),
trigger_id=test_trigger.id,
status="success",
details={"test": True},
)
db.add(log)
db.commit()
response = client.get(
f"/api/triggers/{test_trigger.id}/logs",
headers={"Authorization": f"Bearer {test_user_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
def test_create_trigger_invalid_field(self, client, test_user_token, test_project):
"""Test creating a trigger with invalid field."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
json={
"name": "Invalid Trigger",
"trigger_type": "field_change",
"conditions": {
"field": "invalid_field",
"operator": "equals",
"value": "test",
},
"actions": [{"type": "notify", "target": "assignee"}],
},
)
assert response.status_code == 400
assert "Invalid condition field" in response.json()["detail"]
def test_create_trigger_invalid_operator(self, client, test_user_token, test_project):
"""Test creating a trigger with invalid operator."""
response = client.post(
f"/api/projects/{test_project.id}/triggers",
headers={"Authorization": f"Bearer {test_user_token}"},
json={
"name": "Invalid Trigger",
"trigger_type": "field_change",
"conditions": {
"field": "status_id",
"operator": "invalid_op",
"value": "test",
},
"actions": [{"type": "notify", "target": "assignee"}],
},
)
assert response.status_code == 400
assert "Invalid operator" in response.json()["detail"]

View File

@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react'
import { reportsApi, ReportHistoryItem } from '../services/reports'
export function ReportHistory() {
const [reports, setReports] = useState<ReportHistoryItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [total, setTotal] = useState(0)
const fetchHistory = async () => {
try {
setLoading(true)
const data = await reportsApi.listReportHistory(10, 0)
setReports(data.reports)
setTotal(data.total)
setError(null)
} catch {
setError('Failed to load report history')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchHistory()
}, [])
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('zh-TW', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
if (loading) {
return <div className="p-4 text-center text-gray-500">Loading history...</div>
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
{error}
<button onClick={fetchHistory} className="ml-2 text-blue-600 hover:underline">
Retry
</button>
</div>
)
}
if (reports.length === 0) {
return (
<div className="p-4 text-center text-gray-500">
No report history found. Reports are generated every Friday at 16:00.
</div>
)
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">Report History</h3>
<span className="text-sm text-gray-500">{total} reports</span>
</div>
<div className="space-y-2">
{reports.map(report => {
const summary = (report.content as Record<string, Record<string, number>>).summary || {}
return (
<div
key={report.id}
className={`border rounded-lg p-4 ${
report.status === 'failed' ? 'bg-red-50 border-red-200' : 'bg-white'
}`}
>
<div className="flex justify-between items-start">
<div>
<p className="font-medium">{formatDate(report.generated_at)}</p>
{report.status === 'sent' && summary && (
<p className="text-sm text-gray-600 mt-1">
Completed: {summary.completed_count || 0} |
In Progress: {summary.in_progress_count || 0} |
Overdue: {summary.overdue_count || 0}
</p>
)}
{report.status === 'failed' && report.error_message && (
<p className="text-sm text-red-600 mt-1">{report.error_message}</p>
)}
</div>
<span className={`px-2 py-0.5 text-xs rounded ${
report.status === 'sent'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{report.status}
</span>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,198 @@
import { useState, useEffect } from 'react'
import { triggersApi, Trigger, TriggerCreate, TriggerCondition, TriggerAction } from '../services/triggers'
interface TriggerFormProps {
projectId: string
trigger?: Trigger | null
onSave: () => void
onCancel: () => void
}
export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [field, setField] = useState('status_id')
const [operator, setOperator] = useState('changed_to')
const [value, setValue] = useState('')
const [target, setTarget] = useState('assignee')
const [template, setTemplate] = useState('')
const [isActive, setIsActive] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (trigger) {
setName(trigger.name)
setDescription(trigger.description || '')
setField(trigger.conditions.field)
setOperator(trigger.conditions.operator)
setValue(trigger.conditions.value)
if (trigger.actions.length > 0) {
setTarget(trigger.actions[0].target)
setTemplate(trigger.actions[0].template || '')
}
setIsActive(trigger.is_active)
}
}, [trigger])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
const conditions: TriggerCondition = { field, operator, value }
const actions: TriggerAction[] = [{ type: 'notify', target, template: template || undefined }]
try {
if (trigger) {
await triggersApi.updateTrigger(trigger.id, {
name,
description: description || undefined,
conditions,
actions,
is_active: isActive,
})
} else {
const data: TriggerCreate = {
name,
description: description || undefined,
trigger_type: 'field_change',
conditions,
actions,
is_active: isActive,
}
await triggersApi.createTrigger(projectId, data)
}
onSave()
} catch {
setError('Failed to save trigger')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description (optional)</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
rows={2}
/>
</div>
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-medium text-gray-700 px-1">Condition</legend>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm text-gray-600">Field</label>
<select
value={field}
onChange={e => setField(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="status_id">Status</option>
<option value="assignee_id">Assignee</option>
<option value="priority">Priority</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600">Operator</label>
<select
value={operator}
onChange={e => setOperator(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="changed_to">Changes to</option>
<option value="changed_from">Changes from</option>
<option value="equals">Equals</option>
<option value="not_equals">Not equals</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600">Value</label>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
placeholder="UUID or value"
required
/>
</div>
</div>
</fieldset>
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-medium text-gray-700 px-1">Action</legend>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-600">Notify</label>
<select
value={target}
onChange={e => setTarget(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="assignee">Task Assignee</option>
<option value="creator">Task Creator</option>
<option value="project_owner">Project Owner</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600">Message template (optional)</label>
<input
type="text"
value={template}
onChange={e => setTemplate(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
placeholder="Variables: {task_title}, {old_value}, {new_value}"
/>
</div>
</div>
</fieldset>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={e => setIsActive(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="isActive" className="text-sm text-gray-700">Active</label>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
>
{loading ? 'Saving...' : trigger ? 'Update' : 'Create'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect } from 'react'
import { triggersApi, Trigger } from '../services/triggers'
interface TriggerListProps {
projectId: string
onEdit?: (trigger: Trigger) => void
}
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
const [triggers, setTriggers] = useState<Trigger[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchTriggers = async () => {
try {
setLoading(true)
const response = await triggersApi.listTriggers(projectId)
setTriggers(response.triggers)
setError(null)
} catch {
setError('Failed to load triggers')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTriggers()
}, [projectId])
const handleToggleActive = async (trigger: Trigger) => {
try {
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
fetchTriggers()
} catch {
setError('Failed to update trigger')
}
}
const handleDelete = async (triggerId: string) => {
if (!confirm('Are you sure you want to delete this trigger?')) return
try {
await triggersApi.deleteTrigger(triggerId)
fetchTriggers()
} catch {
setError('Failed to delete trigger')
}
}
const getFieldLabel = (field: string) => {
switch (field) {
case 'status_id': return 'Status'
case 'assignee_id': return 'Assignee'
case 'priority': return 'Priority'
default: return field
}
}
const getOperatorLabel = (operator: string) => {
switch (operator) {
case 'equals': return 'equals'
case 'not_equals': return 'does not equal'
case 'changed_to': return 'changes to'
case 'changed_from': return 'changes from'
default: return operator
}
}
if (loading) {
return <div className="p-4 text-center text-gray-500">Loading triggers...</div>
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
{error}
<button onClick={fetchTriggers} className="ml-2 text-blue-600 hover:underline">
Retry
</button>
</div>
)
}
if (triggers.length === 0) {
return (
<div className="p-4 text-center text-gray-500">
No triggers configured for this project.
</div>
)
}
return (
<div className="space-y-3">
{triggers.map(trigger => (
<div
key={trigger.id}
className={`border rounded-lg p-4 ${trigger.is_active ? 'bg-white' : 'bg-gray-50'}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{trigger.name}</h4>
<span className={`px-2 py-0.5 text-xs rounded ${
trigger.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{trigger.is_active ? 'Active' : 'Inactive'}
</span>
</div>
{trigger.description && (
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
)}
<div className="text-sm text-gray-600 mt-2">
<span className="font-medium">When: </span>
{getFieldLabel(trigger.conditions.field)} {getOperatorLabel(trigger.conditions.operator)} {trigger.conditions.value}
</div>
<div className="text-sm text-gray-600 mt-1">
<span className="font-medium">Then: </span>
{trigger.actions.map((a, i) => (
<span key={i}>
{a.type === 'notify' ? `Notify ${a.target}` : a.type}
{i < trigger.actions.length - 1 && ', '}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleToggleActive(trigger)}
className="text-sm px-2 py-1 text-gray-600 hover:bg-gray-100 rounded"
>
{trigger.is_active ? 'Disable' : 'Enable'}
</button>
{onEdit && (
<button
onClick={() => onEdit(trigger)}
className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded"
>
Edit
</button>
)}
<button
onClick={() => handleDelete(trigger.id)}
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react'
import { reportsApi, WeeklyReportContent } from '../services/reports'
export function WeeklyReportPreview() {
const [report, setReport] = useState<WeeklyReportContent | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchPreview = async () => {
try {
setLoading(true)
const data = await reportsApi.previewWeeklyReport()
setReport(data)
setError(null)
} catch {
setError('Failed to load report preview')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPreview()
}, [])
const handleGenerate = async () => {
try {
setGenerating(true)
await reportsApi.generateWeeklyReport()
alert('Report generated and notification sent!')
fetchPreview()
} catch {
setError('Failed to generate report')
} finally {
setGenerating(false)
}
}
if (loading) {
return <div className="p-4 text-center text-gray-500">Loading report preview...</div>
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
{error}
<button onClick={fetchPreview} className="ml-2 text-blue-600 hover:underline">
Retry
</button>
</div>
)
}
if (!report) {
return <div className="p-4 text-center text-gray-500">No report data available</div>
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('zh-TW', {
month: 'short',
day: 'numeric',
})
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Weekly Report Preview</h3>
<p className="text-sm text-gray-500">
{formatDate(report.week_start)} - {formatDate(report.week_end)}
</p>
</div>
<button
onClick={handleGenerate}
disabled={generating}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Now'}
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-3xl font-bold text-green-600">{report.summary.completed_count}</p>
<p className="text-sm text-green-800">Completed</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-3xl font-bold text-blue-600">{report.summary.in_progress_count}</p>
<p className="text-sm text-blue-800">In Progress</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-3xl font-bold text-red-600">{report.summary.overdue_count}</p>
<p className="text-sm text-red-800">Overdue</p>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-3xl font-bold text-gray-600">{report.summary.total_tasks}</p>
<p className="text-sm text-gray-800">Total Tasks</p>
</div>
</div>
{/* Project Details */}
{report.projects.length > 0 ? (
<div className="space-y-3">
<h4 className="font-medium">Projects</h4>
{report.projects.map(project => (
<div key={project.project_id} className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h5 className="font-medium">{project.project_title}</h5>
<span className="text-sm text-gray-500">
{project.completed_count}/{project.total_tasks} completed
</span>
</div>
<div className="flex gap-4 text-sm">
<span className="text-green-600">{project.completed_count} done</span>
<span className="text-blue-600">{project.in_progress_count} in progress</span>
{project.overdue_count > 0 && (
<span className="text-red-600">{project.overdue_count} overdue</span>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-4">No projects found</p>
)}
</div>
)
}

View File

@@ -0,0 +1,77 @@
import api from './api'
export interface ReportSummary {
completed_count: number
in_progress_count: number
overdue_count: number
total_tasks: number
}
export interface ProjectSummary {
project_id: string
project_title: string
completed_count: number
in_progress_count: number
overdue_count: number
total_tasks: number
completed_tasks: Array<{ id: string; title: string }>
overdue_tasks: Array<{ id: string; title: string; due_date: string | null }>
}
export interface WeeklyReportContent {
week_start: string
week_end: string
generated_at: string
projects: ProjectSummary[]
summary: ReportSummary
}
export interface ReportHistoryItem {
id: string
report_id: string
generated_at: string
content: Record<string, unknown>
status: string
error_message?: string
}
export interface ReportHistoryListResponse {
reports: ReportHistoryItem[]
total: number
}
export interface GenerateReportResponse {
message: string
report_id: string
summary: ReportSummary
}
export const reportsApi = {
// Preview weekly report
previewWeeklyReport: async (): Promise<WeeklyReportContent> => {
const response = await api.get<WeeklyReportContent>('/reports/weekly/preview')
return response.data
},
// Generate weekly report manually
generateWeeklyReport: async (): Promise<GenerateReportResponse> => {
const response = await api.post<GenerateReportResponse>('/reports/weekly/generate')
return response.data
},
// List report history
listReportHistory: async (limit = 10, offset = 0): Promise<ReportHistoryListResponse> => {
const response = await api.get<ReportHistoryListResponse>('/reports/history', {
params: { limit, offset }
})
return response.data
},
// Get specific report detail
getReportDetail: async (reportId: string): Promise<ReportHistoryItem> => {
const response = await api.get<ReportHistoryItem>(`/reports/history/${reportId}`)
return response.data
},
}
export default reportsApi

View File

@@ -0,0 +1,111 @@
import api from './api'
export interface TriggerCondition {
field: string
operator: string
value: string
}
export interface TriggerAction {
type: string
target: string
template?: string
}
export interface Trigger {
id: string
project_id: string
name: string
description?: string
trigger_type: string
conditions: TriggerCondition
actions: TriggerAction[]
is_active: boolean
created_by?: string
created_at: string
updated_at: string
creator?: {
id: string
name: string
email: string
}
}
export interface TriggerCreate {
name: string
description?: string
trigger_type?: string
conditions: TriggerCondition
actions: TriggerAction[]
is_active?: boolean
}
export interface TriggerUpdate {
name?: string
description?: string
conditions?: TriggerCondition
actions?: TriggerAction[]
is_active?: boolean
}
export interface TriggerLog {
id: string
trigger_id: string
task_id?: string
executed_at: string
status: string
details?: Record<string, unknown>
error_message?: string
}
export interface TriggerListResponse {
triggers: Trigger[]
total: number
}
export interface TriggerLogListResponse {
logs: TriggerLog[]
total: number
}
export const triggersApi = {
// Create a new trigger
createTrigger: async (projectId: string, data: TriggerCreate): Promise<Trigger> => {
const response = await api.post<Trigger>(`/projects/${projectId}/triggers`, data)
return response.data
},
// List triggers for a project
listTriggers: async (projectId: string, isActive?: boolean): Promise<TriggerListResponse> => {
const params = isActive !== undefined ? { is_active: isActive } : {}
const response = await api.get<TriggerListResponse>(`/projects/${projectId}/triggers`, { params })
return response.data
},
// Get a specific trigger
getTrigger: async (triggerId: string): Promise<Trigger> => {
const response = await api.get<Trigger>(`/triggers/${triggerId}`)
return response.data
},
// Update a trigger
updateTrigger: async (triggerId: string, data: TriggerUpdate): Promise<Trigger> => {
const response = await api.put<Trigger>(`/triggers/${triggerId}`, data)
return response.data
},
// Delete a trigger
deleteTrigger: async (triggerId: string): Promise<void> => {
await api.delete(`/triggers/${triggerId}`)
},
// Get trigger execution logs
getTriggerLogs: async (triggerId: string, limit = 50, offset = 0): Promise<TriggerLogListResponse> => {
const response = await api.get<TriggerLogListResponse>(`/triggers/${triggerId}/logs`, {
params: { limit, offset }
})
return response.data
},
}
export default triggersApi

View File

@@ -0,0 +1,185 @@
## Context
自動化系統需要處理兩種類型的自動化:
1. 事件驅動 - 任務欄位變更時觸發
2. 時間驅動 - 排程執行(如週報)
## Goals / Non-Goals
**Goals:**
- 提供欄位變更觸發器status, assignee, priority
- 實作每週五 16:00 自動週報
- 整合現有通知系統
- 記錄所有觸發器執行日誌
**Non-Goals:**
- 複雜的工作流引擎
- 跨任務觸發器
- Email 發送(僅系統內通知)
## Decisions
### 1. 排程方案
**Decision:** 使用 APScheduler 而非 Celery
**Rationale:**
- 無需額外 worker 進程,減少部署複雜度
- 嵌入 FastAPI 應用內運行
- 足夠處理週報等簡單定時任務
- 未來如需擴展可遷移至 Celery
**Configuration:**
```python
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
scheduler = AsyncIOScheduler()
scheduler.add_job(
generate_weekly_reports,
CronTrigger(day_of_week='fri', hour=16, minute=0),
id='weekly_report'
)
```
### 2. 觸發器評估策略
**Decision:** 同步評估,任務更新時直接執行
**Rationale:**
- 簡化實作,無需消息隊列
- 觸發器執行速度快(僅發送通知)
- 失敗可即時回報
**Flow:**
```
Task Update → Detect Changes → Find Matching Triggers → Execute Actions → Log Results
```
### 3. 條件定義格式
**Decision:** 使用 JSON 結構定義條件
```json
{
"field": "status_id",
"operator": "equals",
"value": "uuid-of-testing-status"
}
```
**Supported Operators:**
- `equals` - 等於
- `not_equals` - 不等於
- `changed_to` - 變更為特定值
- `changed_from` - 從特定值變更
### 4. 動作定義格式
**Decision:** 使用 JSON 陣列定義動作
```json
[
{
"type": "notify",
"target": "assignee",
"template": "任務 {task.title} 狀態已變更為 {new_value}"
}
]
```
**Supported Actions (Phase 1):**
- `notify` - 發送系統通知
**Target Types:**
- `assignee` - 任務指派者
- `creator` - 任務建立者
- `project_owner` - 專案擁有者
- `user:<user_id>` - 指定使用者
## Data Model
```sql
-- 觸發器表
pjctrl_triggers
├── id: UUID (PK)
├── project_id: UUID (FK -> projects)
├── name: VARCHAR(200)
├── description: TEXT
├── trigger_type: ENUM('field_change', 'schedule')
├── conditions: JSON
├── actions: JSON
├── is_active: BOOLEAN DEFAULT true
├── created_by: UUID (FK -> users)
├── created_at: TIMESTAMP
└── updated_at: TIMESTAMP
-- 觸發器執行日誌
pjctrl_trigger_logs
├── id: UUID (PK)
├── trigger_id: UUID (FK -> triggers)
├── task_id: UUID (FK -> tasks, nullable)
├── executed_at: TIMESTAMP
├── status: ENUM('success', 'failed')
├── details: JSON
└── error_message: TEXT
-- 排程報告設定
pjctrl_scheduled_reports
├── id: UUID (PK)
├── report_type: ENUM('weekly')
├── recipient_id: UUID (FK -> users)
├── is_active: BOOLEAN DEFAULT true
├── last_sent_at: TIMESTAMP
└── created_at: TIMESTAMP
-- 報告歷史
pjctrl_report_history
├── id: UUID (PK)
├── report_id: UUID (FK -> scheduled_reports)
├── generated_at: TIMESTAMP
├── content: JSON
├── status: ENUM('sent', 'failed')
└── error_message: TEXT
```
## API Design
```
# Triggers
POST /api/projects/{project_id}/triggers # 建立觸發器
GET /api/projects/{project_id}/triggers # 列出專案觸發器
GET /api/triggers/{id} # 觸發器詳情
PUT /api/triggers/{id} # 更新觸發器
DELETE /api/triggers/{id} # 刪除觸發器
GET /api/triggers/{id}/logs # 觸發器執行日誌
# Reports
GET /api/reports/weekly/preview # 預覽週報
POST /api/reports/weekly/generate # 手動觸發週報
GET /api/reports/history # 報告歷史
```
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| 大量觸發器影響效能 | 限制每專案觸發器數量,建立索引 |
| APScheduler 單點故障 | 記錄 last_sent_at 防止重複,考慮多實例鎖 |
| 觸發器條件複雜度 | Phase 1 僅支援簡單條件,後續擴展 |
## Integration Points
1. **Task Update Hook**
-`tasks/router.py` 的 update_task 後調用 TriggerService.evaluate()
2. **Notification Integration**
- 使用現有 NotificationService.create_notification()
3. **Audit Integration**
- 觸發器執行記錄至 TriggerLog非 AuditLog
## Open Questions
- [ ] 是否需要支援 Email 發送?(目前僅系統內通知)
- [ ] 週報收件者如何設定?(主管自動訂閱 or 手動設定)

View File

@@ -0,0 +1,52 @@
# Change: Add Automation System
## Why
專案管理需要自動化功能來減少重複性工作:
- 當任務狀態變更時自動通知相關人員
- 每週自動生成進度報告發送給主管
- 減少人工追蹤與提醒的負擔
## What Changes
- **新增 Trigger 模型** - 定義觸發條件與動作
- **新增 TriggerService** - 觸發器評估與執行
- **新增 ReportService** - 週報生成邏輯
- **新增背景排程** - APScheduler 處理定時任務
- **整合任務 API** - 任務變更時評估觸發器
## Impact
- Affected specs: `automation`
- Affected code:
- `backend/app/models/` - 新增 trigger, scheduled_report 模型
- `backend/app/api/` - 新增 triggers, reports router
- `backend/app/services/` - 新增 trigger_service, report_service
- `backend/app/api/tasks/router.py` - 整合觸發器評估
- `frontend/src/` - 新增觸發器管理頁面
## Implementation Phases
### Phase 1: Event-Based Triggers
- Trigger 模型與 CRUD API
- 欄位變更觸發器status, assignee, priority
- 通知動作執行
- 整合任務更新流程
### Phase 2: Weekly Reports
- ScheduledReport 模型
- 週報生成邏輯(彙整任務統計)
- APScheduler 背景排程
- 系統內通知發送
### Phase 3: Advanced Features (Optional)
- 時間條件觸發器
- 複合條件支援
- 更新欄位動作
- 自動指派動作
## Dependencies
- notification (已完成) - 用於發送觸發通知
- audit-trail (已完成) - 記錄觸發器執行日誌
## Technical Decisions
- **使用 APScheduler** 而非 Celery - 輕量級,無需額外 worker 進程
- **同步觸發器評估** - 任務更新時同步執行,避免複雜的異步處理
- **JSON 欄位儲存條件** - 靈活的條件定義格式

View File

@@ -0,0 +1,105 @@
## MODIFIED Requirements
### Requirement: Trigger-Based Automation
系統 SHALL 支援觸發器 (Triggers),當特定條件滿足時自動執行動作。
#### Scenario: 狀態變更觸發通知
- **GIVEN** 專案設定了「當任務狀態變更為待測試時,通知指派者」的觸發器
- **WHEN** 任務狀態變更為「待測試」
- **THEN** 系統自動發送通知給任務指派者
- **AND** 觸發器執行記錄至 TriggerLog
#### Scenario: 建立觸發器
- **GIVEN** 專案管理者需要建立自動化規則
- **WHEN** 管理者透過 API 設定觸發條件與動作
- **THEN** 系統儲存觸發器規則
- **AND** 規則立即生效
### Requirement: Trigger Conditions
系統 SHALL 支援欄位變更觸發條件。
#### Scenario: 欄位變更條件
- **GIVEN** 觸發器設定為「當 status_id 欄位變更為特定值」
- **WHEN** 任務的 status_id 欄位變更為該值
- **THEN** 觸發器被觸發
- **AND** 支援運算子: equals, not_equals, changed_to, changed_from
### Requirement: Trigger Actions
系統 SHALL 支援發送通知動作。
#### Scenario: 發送通知動作
- **GIVEN** 觸發器動作設定為 notify
- **WHEN** 觸發器被觸發
- **THEN** 系統使用 NotificationService 發送通知
- **AND** 通知目標支援: assignee, creator, project_owner, user:<id>
- **AND** 通知內容可使用變數模板
### Requirement: Automated Weekly Report
系統 SHALL 每週五下午 4:00 自動彙整本週任務狀態發送給主管。
#### Scenario: 週報自動生成
- **GIVEN** APScheduler 排程設定為每週五 16:00
- **WHEN** 到達排程時間
- **THEN** ReportService 彙整使用者所屬專案的任務狀態
- **AND** 生成週報並透過 NotificationService 發送
#### Scenario: 週報內容
- **GIVEN** 週報生成中
- **WHEN** 系統彙整資料
- **THEN** 週報 JSON 包含:
- completed_count: 本週已完成任務數
- in_progress_count: 進行中任務數
- overdue_count: 逾期任務數
- tasks: 詳細任務清單
## MODIFIED Data Model
```
pjctrl_triggers
├── id: UUID (PK)
├── project_id: UUID (FK -> projects)
├── name: VARCHAR(200)
├── description: TEXT
├── trigger_type: ENUM('field_change', 'schedule')
├── conditions: JSON
│ └── { "field": "status_id", "operator": "changed_to", "value": "uuid" }
├── actions: JSON
│ └── [{ "type": "notify", "target": "assignee", "template": "..." }]
├── is_active: BOOLEAN DEFAULT true
├── created_by: UUID (FK -> users)
├── created_at: TIMESTAMP
└── updated_at: TIMESTAMP
pjctrl_trigger_logs
├── id: UUID (PK)
├── trigger_id: UUID (FK -> triggers)
├── task_id: UUID (FK -> tasks, nullable)
├── executed_at: TIMESTAMP
├── status: ENUM('success', 'failed')
├── details: JSON
└── error_message: TEXT
pjctrl_scheduled_reports
├── id: UUID (PK)
├── report_type: ENUM('weekly')
├── recipient_id: UUID (FK -> users)
├── is_active: BOOLEAN DEFAULT true
├── last_sent_at: TIMESTAMP
└── created_at: TIMESTAMP
pjctrl_report_history
├── id: UUID (PK)
├── report_id: UUID (FK -> scheduled_reports)
├── generated_at: TIMESTAMP
├── content: JSON
├── status: ENUM('sent', 'failed')
└── error_message: TEXT
```
## MODIFIED Technical Notes
- 使用 APScheduler (AsyncIOScheduler) 處理排程任務,嵌入 FastAPI 應用內運行
- 觸發器評估採用同步處理,任務更新時直接執行,避免複雜的異步處理
- 所有觸發器執行都記錄至 TriggerLog 供追蹤
- Phase 1 僅支援 notify 動作
- 條件運算子: equals, not_equals, changed_to, changed_from

View File

@@ -0,0 +1,90 @@
## Phase 1: Event-Based Triggers
### 1.1 Database Schema
- [x] 1.1.1 建立 Trigger model (`pjctrl_triggers`)
- [x] 1.1.2 建立 TriggerLog model (`pjctrl_trigger_logs`)
- [x] 1.1.3 建立 Alembic migration
- [x] 1.1.4 定義 TriggerType enum (field_change, schedule)
### 1.2 Trigger Service
- [x] 1.2.1 建立 TriggerService 類別
- [x] 1.2.2 實作 evaluate_triggers(task, old_values, new_values) 方法
- [x] 1.2.3 實作 check_condition(condition, old_value, new_value) 方法
- [x] 1.2.4 實作 execute_action(action, task, user) 方法
- [x] 1.2.5 實作 log_execution(trigger, task, status, error) 方法
### 1.3 Trigger API
- [x] 1.3.1 建立 Trigger schemas (request/response)
- [x] 1.3.2 實作 POST `/api/projects/{project_id}/triggers` - 建立
- [x] 1.3.3 實作 GET `/api/projects/{project_id}/triggers` - 列表
- [x] 1.3.4 實作 GET `/api/triggers/{id}` - 詳情
- [x] 1.3.5 實作 PUT `/api/triggers/{id}` - 更新
- [x] 1.3.6 實作 DELETE `/api/triggers/{id}` - 刪除
- [x] 1.3.7 實作 GET `/api/triggers/{id}/logs` - 執行日誌
### 1.4 Task Integration
- [x] 1.4.1 修改 update_task endpoint 整合觸發器評估
- [x] 1.4.2 修改 update_task_status endpoint 整合觸發器評估
- [x] 1.4.3 修改 assign_task endpoint 整合觸發器評估
### 1.5 Frontend - Triggers
- [x] 1.5.1 建立 triggers.ts service
- [x] 1.5.2 建立 TriggerList 元件
- [x] 1.5.3 建立 TriggerForm 元件(條件/動作設定)
- [x] 1.5.4 整合至 Project 設定頁面
### 1.6 Testing - Phase 1
- [x] 1.6.1 TriggerService 單元測試
- [x] 1.6.2 Trigger API 端點測試
- [x] 1.6.3 觸發器執行整合測試
## Phase 2: Weekly Reports
### 2.1 Database Schema
- [x] 2.1.1 建立 ScheduledReport model (`pjctrl_scheduled_reports`)
- [x] 2.1.2 建立 ReportHistory model (`pjctrl_report_history`)
- [x] 2.1.3 建立 Alembic migration
### 2.2 Report Service
- [x] 2.2.1 建立 ReportService 類別
- [x] 2.2.2 實作 generate_weekly_report(user_id) 方法
- [x] 2.2.3 實作 get_weekly_stats(user_id, week_start) 方法
- [x] 2.2.4 實作 send_report_notification(user_id, report) 方法
- [x] 2.2.5 實作 save_report_history(report) 方法
### 2.3 Scheduler Setup
- [x] 2.3.1 安裝 APScheduler
- [x] 2.3.2 建立 scheduler.py 設定檔
- [x] 2.3.3 設定週五 16:00 排程任務
- [x] 2.3.4 整合至 main.py 啟動流程
### 2.4 Report API
- [x] 2.4.1 建立 Report schemas
- [x] 2.4.2 實作 GET `/api/reports/weekly/preview` - 預覽
- [x] 2.4.3 實作 POST `/api/reports/weekly/generate` - 手動觸發
- [x] 2.4.4 實作 GET `/api/reports/history` - 歷史紀錄
### 2.5 Frontend - Reports
- [x] 2.5.1 建立 reports.ts service
- [x] 2.5.2 建立 WeeklyReportPreview 元件
- [x] 2.5.3 建立 ReportHistory 元件
- [x] 2.5.4 新增管理員報告頁面
### 2.6 Testing - Phase 2
- [x] 2.6.1 ReportService 單元測試
- [x] 2.6.2 週報生成測試
- [x] 2.6.3 排程執行測試
## Phase 3: Advanced Features (Optional)
### 3.1 Schedule Triggers
- [ ] 3.1.1 支援 cron 表達式觸發器
- [ ] 3.1.2 截止日期提醒觸發器
### 3.2 Additional Actions
- [ ] 3.2.1 更新欄位動作
- [ ] 3.2.2 自動指派動作
### 3.3 Complex Conditions
- [ ] 3.3.1 AND/OR 複合條件
- [ ] 3.3.2 多欄位條件