[fix]:[FL-43][清理遗留需求协同待办日程JWT生成器模块]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-08 12:32:11 +08:00
parent 2263a0d45c
commit dd8dd9244d
19 changed files with 69 additions and 2512 deletions
-35
View File
@@ -1,35 +0,0 @@
from fastapi import APIRouter, Depends, Query
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...schemas.jwt_generator import (
JwtGenerateRequest,
JwtGenerateResponse,
JwtGeneratorUserListResponse,
)
from ...services.jwt_generator_service import generate_jwt_for_user, list_jwt_generator_users
router = APIRouter(prefix="/admin/jwt-generator", tags=["admin-jwt-generator"])
@router.get("/users", response_model=JwtGeneratorUserListResponse)
def list_users_for_jwt_generator(
keyword: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
limit: int = Query(default=20, ge=1, le=200),
offset: int = Query(default=0, ge=0),
_: CurrentUser = Depends(require_any_permission("jwt_generator.read", "jwt_generator.manage")),
) -> JwtGeneratorUserListResponse:
return list_jwt_generator_users(
keyword=keyword,
status_filter=status_filter,
limit=limit,
offset=offset,
)
@router.post("/generate", response_model=JwtGenerateResponse)
def generate_jwt_endpoint(
payload: JwtGenerateRequest,
_: CurrentUser = Depends(require_permission("jwt_generator.manage")),
) -> JwtGenerateResponse:
return generate_jwt_for_user(payload)
-5
View File
@@ -11,7 +11,6 @@ celery_app = Celery(
broker=settings.resolved_celery_broker_url,
backend=settings.resolved_celery_result_backend,
include=[
"app.tasks.schedule_tasks",
"app.tasks.elevation_tasks",
"app.tasks.fl_analysis_tasks",
"app.tasks.worker_registry_tasks",
@@ -21,10 +20,6 @@ celery_app = Celery(
celery_app.conf.update(
accept_content=["json"],
beat_schedule={
"expire-overdue-schedule-items": {
"task": "app.tasks.schedule_tasks.expire_overdue_schedule_items",
"schedule": settings.scheduler_expire_interval_seconds,
},
"sweep-worker-registry-offline": {
"task": "app.tasks.worker_registry_tasks.sweep_worker_registry_offline",
"schedule": 30.0,
-3
View File
@@ -381,7 +381,6 @@ def init_db() -> None:
atp_model,
audit_log,
auth_session,
calendar_event,
elevation,
file_storage,
fl_analysis,
@@ -395,9 +394,7 @@ def init_db() -> None:
object_group,
question_bank,
rbac,
requirement,
system_param,
todo,
tower_model,
tower_profile,
user,
+1 -4
View File
@@ -4,13 +4,12 @@ Import all model modules during package initialization so SQLAlchemy can
resolve string-based relationships regardless of route/service import order.
"""
from . import atp_model, audit_log, auth_session, calendar_event, elevation, file_storage, fl_analysis, hot_search, lightning_event, lightning_sample, line, line_tower, menu, model_registry, object_group, question_bank, rbac, requirement, system_param, todo, tower_model, tower_profile, user, worker_registry
from . import atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, hot_search, lightning_event, lightning_sample, line, line_tower, menu, model_registry, object_group, question_bank, rbac, system_param, tower_model, tower_profile, user, worker_registry
__all__ = [
"atp_model",
"audit_log",
"auth_session",
"calendar_event",
"elevation",
"file_storage",
"fl_analysis",
@@ -24,9 +23,7 @@ __all__ = [
"object_group",
"question_bank",
"rbac",
"requirement",
"system_param",
"todo",
"tower_model",
"tower_profile",
"user",
-42
View File
@@ -1,42 +0,0 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from ..core.database import Base
from .base import utcnow
class CalendarEvent(Base):
__tablename__ = "calendar_event"
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
title: Mapped[str] = mapped_column(String(256), index=True)
descr: Mapped[str] = mapped_column(Text(), default="")
status: Mapped[str] = mapped_column(String(20), default="SCHEDULED", index=True)
priority: Mapped[str] = mapped_column(String(20), default="MEDIUM", index=True)
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
end_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
expire_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
todo_id: Mapped[str | None] = mapped_column(String(32), index=True)
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
create_user: Mapped[str] = mapped_column(String(64), index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
-113
View File
@@ -1,113 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.database import Base
from .base import utcnow
if TYPE_CHECKING:
from .user import User
class Requirement(Base):
__tablename__ = "project_requirement"
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
title: Mapped[str] = mapped_column(String(256), index=True)
project_name: Mapped[str | None] = mapped_column(String(128), index=True)
git_url: Mapped[str | None] = mapped_column(String(512))
branch: Mapped[str | None] = mapped_column(String(128), default="main")
descr: Mapped[str] = mapped_column(Text(), default="")
result_msg: Mapped[str | None] = mapped_column(Text())
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(30), default="PENDING_ANALYSIS", index=True)
priority: Mapped[str] = mapped_column(String(20), default="MEDIUM", index=True)
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
comments: Mapped[list[RequirementComment]] = relationship(
"RequirementComment",
back_populates="requirement",
lazy="selectin",
cascade="all, delete-orphan",
order_by="RequirementComment.created_at.desc()",
)
events: Mapped[list[RequirementEvent]] = relationship(
"RequirementEvent",
back_populates="requirement",
lazy="selectin",
cascade="all, delete-orphan",
order_by="RequirementEvent.create_date.desc()",
)
class RequirementComment(Base):
__tablename__ = "requirement_comments"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
requirement_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("project_requirement.id", ondelete="CASCADE"),
index=True,
)
author_user_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("users.user_id", ondelete="SET NULL"),
index=True,
)
content: Mapped[str] = mapped_column(Text())
kind: Mapped[str] = mapped_column(String(32), default="comment", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
requirement: Mapped[Requirement] = relationship("Requirement", back_populates="comments")
author: Mapped[User | None] = relationship(
"User",
foreign_keys=[author_user_id],
lazy="selectin",
)
class RequirementEvent(Base):
__tablename__ = "project_requirement_log"
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
requirement_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("project_requirement.id", ondelete="CASCADE"),
index=True,
)
event_type: Mapped[str] = mapped_column(String(30), index=True)
from_status: Mapped[str | None] = mapped_column(String(30), index=True)
to_status: Mapped[str | None] = mapped_column(String(30), index=True)
before_descr: Mapped[str | None] = mapped_column(Text())
after_descr: Mapped[str | None] = mapped_column(Text())
remark: Mapped[str | None] = mapped_column(Text())
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
requirement: Mapped[Requirement] = relationship("Requirement", back_populates="events")
-42
View File
@@ -1,42 +0,0 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from ..core.database import Base
from .base import utcnow
class Todo(Base):
__tablename__ = "todo"
__table_args__ = (
Index("idx_todo_status", "status"),
Index("idx_todo_priority", "priority"),
Index("idx_todo_due_date", "due_date"),
Index("idx_todo_expire_time", "expire_time"),
)
id: Mapped[str] = mapped_column(
String(32),
primary_key=True,
default=lambda: uuid4().hex,
)
title: Mapped[str] = mapped_column(String(256), nullable=False)
descr: Mapped[str | None] = mapped_column(Text(), default="")
status: Mapped[str] = mapped_column(String(20), default="SCHEDULED", nullable=False)
priority: Mapped[str] = mapped_column(String(20), default="MEDIUM", nullable=False)
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expire_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
calendar_event_id: Mapped[str | None] = mapped_column(String(32))
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
update_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
-75
View File
@@ -1,75 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
ScheduleStatus = Literal["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED", "EXPIRED"]
SchedulePriority = Literal["LOW", "MEDIUM", "HIGH"]
class CalendarEventSummary(BaseModel):
id: str
title: str
descr: str
status: ScheduleStatus
priority: SchedulePriority
start_time: datetime
end_time: datetime
expire_time: datetime | None = None
all_day: bool = False
completed_at: datetime | None = None
todo_id: str | None = None
create_date: datetime
create_user: str
update_date: datetime
update_user: str | None = None
class CalendarEventPageResponse(BaseModel):
items: list[CalendarEventSummary]
total: int
page_num: int
page_size: int
class CalendarEventCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=256)
descr: str = Field(default="", max_length=100000)
status: ScheduleStatus = "SCHEDULED"
priority: SchedulePriority = "MEDIUM"
start_time: datetime
end_time: datetime
expire_time: datetime | None = None
all_day: bool = False
# Internal sync flags (used by services, not by UI directly).
is_sync: bool = False
todo_id: str | None = Field(default=None, max_length=32)
class CalendarEventUpdateRequest(BaseModel):
id: str = Field(min_length=1, max_length=32)
title: str | None = Field(default=None, min_length=1, max_length=256)
descr: str | None = Field(default=None, max_length=100000)
status: ScheduleStatus | None = None
priority: SchedulePriority | None = None
start_time: datetime | None = None
end_time: datetime | None = None
expire_time: datetime | None = None
all_day: bool | None = None
completed_at: datetime | None = None
# Internal sync flag.
is_sync: bool = False
class CalendarEventQueryRequest(BaseModel):
title: str | None = Field(default=None, max_length=256)
status: ScheduleStatus | None = None
priority: SchedulePriority | None = None
start_time_from: datetime | None = None
start_time_to: datetime | None = None
page_num: int = Field(default=0, ge=0)
page_size: int = Field(default=50, ge=1, le=500)
-43
View File
@@ -1,43 +0,0 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class JwtGeneratorUserItem(BaseModel):
id: str
email: str
username: str
status: str
role_codes: list[str]
class JwtGeneratorUserListResponse(BaseModel):
items: list[JwtGeneratorUserItem]
total: int
limit: int
offset: int
class JwtGenerateRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=64)
expires_minutes: int | None = Field(default=None, ge=1, le=7 * 24 * 60)
@field_validator("user_id")
@classmethod
def validate_user_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("user_id cannot be empty")
return normalized
class JwtGenerateResponse(BaseModel):
token_type: str = "bearer"
access_token: str
expires_in: int
expires_at: datetime
user_id: str
role_codes: list[str]
permission_codes: list[str]
-111
View File
@@ -1,111 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from .user import UserPublic
RequirementStatus = Literal[
"PENDING_ANALYSIS",
"PENDING_REVIEW",
"PENDING_REVISION",
"OPEN",
"IN_PROGRESS",
"COMPLETED",
"CLOSED",
"CANCELLED",
]
RequirementPriority = Literal["low", "medium", "high", "urgent", "LOW", "MEDIUM", "HIGH"]
RequirementCommentKind = Literal["comment", "analysis", "revision", "system"]
class RequirementSummary(BaseModel):
id: str
code: str
title: str
description: str
status: RequirementStatus
priority: RequirementPriority
project_name: str | None = None
module_name: str | None = None
source: str | None = None
creator_user_id: str | None = None
assignee_user_id: str | None = None
reviewer_user_id: str | None = None
due_at: datetime | None = None
closed_at: datetime | None = None
created_at: datetime
updated_at: datetime
result_msg: str | None = None
progress_percent: int = 0
git_url: str | None = None
branch: str | None = None
creator: UserPublic | None = None
assignee: UserPublic | None = None
reviewer: UserPublic | None = None
class RequirementListResponse(BaseModel):
items: list[RequirementSummary]
total: int
class RequirementCreateRequest(BaseModel):
title: str = Field(min_length=2, max_length=200)
description: str = Field(default="", max_length=20000)
status: RequirementStatus = "PENDING_ANALYSIS"
priority: RequirementPriority = "medium"
project_name: str | None = Field(default=None, max_length=128)
module_name: str | None = Field(default=None, max_length=128)
source: str | None = Field(default=None, max_length=128)
assignee_user_id: str | None = Field(default=None, max_length=36)
due_at: datetime | None = None
class RequirementUpdateRequest(BaseModel):
title: str | None = Field(default=None, min_length=2, max_length=200)
description: str | None = Field(default=None, max_length=20000)
priority: RequirementPriority | None = None
project_name: str | None = Field(default=None, max_length=128)
module_name: str | None = Field(default=None, max_length=128)
source: str | None = Field(default=None, max_length=128)
assignee_user_id: str | None = Field(default=None, max_length=36)
due_at: datetime | None = None
class RequirementAssignRequest(BaseModel):
assignee_user_id: str | None = Field(default=None, max_length=36)
class RequirementTransitionRequest(BaseModel):
status: RequirementStatus
note: str | None = Field(default=None, max_length=2000)
class RequirementCommentCreateRequest(BaseModel):
content: str = Field(min_length=1, max_length=20000)
kind: RequirementCommentKind = "comment"
class RequirementCommentPublic(BaseModel):
id: int
requirement_id: str
author_user_id: str | None = None
content: str
kind: RequirementCommentKind
created_at: datetime
author: UserPublic | None = None
class RequirementEventPublic(BaseModel):
id: str
requirement_id: str
actor_user_id: str | None = None
event_type: str
from_status: str | None = None
to_status: str | None = None
payload_json: dict | None = None
created_at: datetime
actor: UserPublic | None = None
-60
View File
@@ -1,60 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
TodoStatus = Literal["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED", "EXPIRED"]
TodoPriority = Literal["LOW", "MEDIUM", "HIGH"]
class TodoSummary(BaseModel):
id: str
title: str
descr: str | None = None
status: TodoStatus
priority: TodoPriority
start_time: datetime | None = None
due_date: datetime | None = None
expire_time: datetime | None = None
calendar_event_id: str | None = None
create_date: datetime
create_user: str | None = None
update_date: datetime
update_user: str | None = None
class TodoListResponse(BaseModel):
items: list[TodoSummary]
total: int
class TodoCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=256)
descr: str = Field(default="", max_length=20000)
status: TodoStatus = "SCHEDULED"
priority: TodoPriority = "MEDIUM"
start_time: datetime | None = None
due_date: datetime | None = None
expire_time: datetime | None = None
is_sync: bool = False
calendar_event_id: str | None = Field(default=None, max_length=32)
class TodoUpdateRequest(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=256)
descr: str | None = Field(default=None, max_length=20000)
status: TodoStatus | None = None
priority: TodoPriority | None = None
start_time: datetime | None = None
due_date: datetime | None = None
expire_time: datetime | None = None
calendar_event_id: str | None = Field(default=None, max_length=32)
is_sync: bool = False
class TodoTransitionRequest(BaseModel):
status: TodoStatus
note: str | None = Field(default=None, max_length=2000)
is_sync: bool = False
-602
View File
@@ -1,602 +0,0 @@
from __future__ import annotations
import json
import logging
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta
from fastapi import HTTPException, status
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from ..models.base import utcnow
from ..models.calendar_event import CalendarEvent
from ..models.todo import Todo
from ..models.user import User
from ..schemas.calendar_event import (
CalendarEventCreateRequest,
CalendarEventPageResponse,
CalendarEventQueryRequest,
CalendarEventSummary,
CalendarEventUpdateRequest,
)
from .llm_gateway import create_assistant_reply
logger = logging.getLogger(__name__)
SCHEDULE_ACTIVE_STATUSES = {"SCHEDULED", "IN_PROGRESS"}
SCHEDULE_GENERATION_PROMPT = """你是日程生成助手。\n请根据用户输入,输出一个 JSON 对象,不要输出额外文本。\n字段要求:\n- title: string,日程标题\n- descr: string,日程描述\n- status: 固定返回 SCHEDULED\n- priority: LOW | MEDIUM | HIGH\n- start_time: ISO-8601 日期时间字符串\n- end_time: ISO-8601 日期时间字符串\n- expire_time: ISO-8601 日期时间字符串或 null\n- all_day: boolean\n\n如果用户没有提供明确时间,start_time 使用当前时间后 1 小时,end_time 为 start_time 后 1 小时。\n"""
def search_calendar_events(
db: Session,
payload: CalendarEventQueryRequest,
*,
actor: User,
) -> CalendarEventPageResponse:
expire_overdue_events(db)
filters = [CalendarEvent.create_user == actor.username]
if payload.title:
keyword = f"%{payload.title.strip()}%"
filters.append(CalendarEvent.title.ilike(keyword))
if payload.status:
filters.append(CalendarEvent.status == payload.status)
if payload.priority:
filters.append(CalendarEvent.priority == payload.priority)
if payload.start_time_from:
filters.append(CalendarEvent.start_time >= payload.start_time_from)
if payload.start_time_to:
filters.append(CalendarEvent.start_time <= payload.start_time_to)
where_clause = and_(*filters)
total = int(
db.scalar(
select(func.count())
.select_from(CalendarEvent)
.where(where_clause)
)
or 0
)
events = db.execute(
select(CalendarEvent)
.where(where_clause)
.order_by(CalendarEvent.create_date.desc())
.offset(payload.page_num * payload.page_size)
.limit(payload.page_size)
).scalars().all()
return CalendarEventPageResponse(
items=[serialize_calendar_event(item) for item in events],
total=total,
page_num=payload.page_num,
page_size=payload.page_size,
)
def get_calendar_event_by_id(
db: Session,
event_id: str,
*,
actor: User | None = None,
) -> CalendarEvent | None:
event = db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)).scalar_one_or_none()
if not event:
return None
if actor and event.create_user != actor.username:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to access calendar event")
return event
def get_calendar_event_by_todo_id(db: Session, todo_id: str) -> CalendarEvent | None:
return db.execute(
select(CalendarEvent).where(CalendarEvent.todo_id == todo_id)
).scalar_one_or_none()
def create_calendar_event(
db: Session,
payload: CalendarEventCreateRequest,
*,
actor: User,
syncing: bool = False,
) -> CalendarEventSummary:
if payload.end_time <= payload.start_time:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="end_time must be later than start_time")
now = utcnow()
event = CalendarEvent(
title=payload.title.strip(),
descr=payload.descr.strip(),
status=payload.status,
priority=payload.priority,
start_time=payload.start_time,
end_time=payload.end_time,
expire_time=payload.expire_time,
all_day=payload.all_day,
completed_at=now if payload.status == "COMPLETED" else None,
todo_id=payload.todo_id,
create_user=actor.username,
update_user=actor.username,
create_date=now,
update_date=now,
)
db.add(event)
db.commit()
db.refresh(event)
if not (payload.is_sync or syncing):
_sync_create_todo_for_event(db, event=event, actor=actor)
saved = get_calendar_event_by_id(db, event.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Calendar event save failed")
return serialize_calendar_event(saved)
def update_calendar_event(
db: Session,
payload: CalendarEventUpdateRequest,
*,
actor: User,
syncing: bool = False,
) -> CalendarEventSummary:
event = get_calendar_event_by_id(db, payload.id, actor=actor)
if not event:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Calendar event not found")
update_data = payload.model_dump(exclude_unset=True)
next_start = update_data.get("start_time", event.start_time)
next_end = update_data.get("end_time", event.end_time)
if next_end <= next_start:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="end_time must be later than start_time")
for field in [
"title",
"descr",
"status",
"priority",
"start_time",
"end_time",
"expire_time",
"all_day",
"completed_at",
]:
if field in update_data:
value = update_data[field]
if isinstance(value, str):
value = value.strip()
setattr(event, field, value)
if "status" in update_data:
if event.status == "COMPLETED" and event.completed_at is None:
event.completed_at = utcnow()
if event.status != "COMPLETED" and "completed_at" not in update_data:
event.completed_at = None
event.update_user = actor.username
event.update_date = utcnow()
db.commit()
db.refresh(event)
if not (payload.is_sync or syncing):
_sync_update_todo_for_event(db, event=event, actor=actor)
saved = get_calendar_event_by_id(db, event.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Calendar event load failed")
return serialize_calendar_event(saved)
def delete_calendar_event(
db: Session,
event_id: str,
*,
actor: User,
syncing: bool = False,
) -> dict[str, bool]:
event = get_calendar_event_by_id(db, event_id, actor=actor)
if not event:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Calendar event not found")
linked_todo_id = event.todo_id
db.delete(event)
db.commit()
if linked_todo_id and not syncing:
from .todo_service import delete_todo
try:
delete_todo(db, linked_todo_id, actor=actor, syncing=True)
except HTTPException as exc:
if exc.status_code != status.HTTP_404_NOT_FOUND:
raise
return {"success": True}
def complete_calendar_event(
db: Session,
event_id: str,
*,
actor: User,
syncing: bool = False,
) -> CalendarEventSummary:
event = get_calendar_event_by_id(db, event_id, actor=actor)
if not event:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Calendar event not found")
event.status = "COMPLETED"
event.completed_at = utcnow()
event.update_user = actor.username
event.update_date = utcnow()
db.commit()
db.refresh(event)
if event.todo_id and not syncing:
from ..schemas.todo import TodoTransitionRequest
from .todo_service import transition_todo
try:
transition_todo(
db,
event.todo_id,
TodoTransitionRequest(status="COMPLETED", is_sync=True),
actor=actor,
syncing=True,
)
except HTTPException as exc:
if exc.status_code != status.HTTP_404_NOT_FOUND:
raise
saved = get_calendar_event_by_id(db, event.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Calendar event load failed")
return serialize_calendar_event(saved)
async def stream_generate_calendar_event(
db: Session,
*,
descr: str,
) -> AsyncGenerator[str, None]:
text = descr.strip()
if not text:
yield "[ERROR]日程描述不能为空"
return
yield "connected"
try:
result = create_assistant_reply(
db,
user_message=text,
context_messages=[],
system_prompt=SCHEDULE_GENERATION_PROMPT,
)
content = result.content.strip()
except HTTPException as exc:
yield f"[ERROR]{exc.detail}"
return
except Exception as exc: # pragma: no cover - defensive fallback
yield f"[ERROR]服务异常: {exc}"
return
for chunk in _chunk_text(content, chunk_size=120):
yield chunk
try:
generated = _coerce_generated_event(content)
yield "[PARSE_RESULT]"
yield f"[EVENT]{json.dumps(generated, ensure_ascii=False)}"
except Exception as exc:
yield f"[ERROR]解析JSON失败: {exc}"
def serialize_calendar_event(event: CalendarEvent) -> CalendarEventSummary:
return CalendarEventSummary(
id=event.id,
title=event.title,
descr=event.descr,
status=event.status,
priority=event.priority,
start_time=event.start_time,
end_time=event.end_time,
expire_time=event.expire_time,
all_day=bool(event.all_day),
completed_at=event.completed_at,
todo_id=event.todo_id,
create_date=event.create_date,
create_user=event.create_user,
update_date=event.update_date,
update_user=event.update_user,
)
def sync_from_todo_create(db: Session, *, todo: Todo, actor: User) -> None:
if todo.calendar_event_id:
return
start_time = todo.start_time or todo.due_date or utcnow()
end_time = todo.due_date or (start_time + timedelta(hours=1))
try:
event = create_calendar_event(
db,
CalendarEventCreateRequest(
title=todo.title,
descr=todo.descr or "",
status=todo.status,
priority=todo.priority,
start_time=start_time,
end_time=end_time,
expire_time=todo.expire_time,
all_day=False,
is_sync=True,
todo_id=todo.id,
),
actor=actor,
syncing=True,
)
except Exception as exc: # pragma: no cover - best effort sync
logger.warning("Failed to sync todo->schedule create: %s", exc)
return
todo.calendar_event_id = event.id
todo.update_user = actor.username
todo.update_date = utcnow()
db.commit()
def sync_from_todo_update(db: Session, *, todo: Todo, actor: User) -> None:
event_id = todo.calendar_event_id
if not event_id:
sync_from_todo_create(db, todo=todo, actor=actor)
return
event = get_calendar_event_by_id(db, event_id)
if not event:
sync_from_todo_create(db, todo=todo, actor=actor)
return
next_start = todo.start_time or event.start_time
next_end = todo.due_date or event.end_time
if next_end <= next_start:
next_end = next_start + timedelta(hours=1)
try:
update_calendar_event(
db,
CalendarEventUpdateRequest(
id=event.id,
title=todo.title,
descr=todo.descr or "",
status=todo.status,
priority=todo.priority,
start_time=next_start,
end_time=next_end,
expire_time=todo.expire_time,
is_sync=True,
),
actor=actor,
syncing=True,
)
except Exception as exc: # pragma: no cover - best effort sync
logger.warning("Failed to sync todo->schedule update: %s", exc)
def sync_from_todo_delete(db: Session, *, todo: Todo, actor: User) -> None:
event_id = todo.calendar_event_id
if not event_id:
event = get_calendar_event_by_todo_id(db, todo.id)
event_id = event.id if event else None
if not event_id:
return
try:
delete_calendar_event(db, event_id, actor=actor, syncing=True)
except Exception as exc: # pragma: no cover - best effort sync
logger.warning("Failed to sync todo->schedule delete: %s", exc)
def sync_from_todo_transition(db: Session, *, todo: Todo, actor: User) -> None:
if not todo.calendar_event_id:
return
event = get_calendar_event_by_id(db, todo.calendar_event_id)
if not event:
return
try:
payload = CalendarEventUpdateRequest(
id=event.id,
status=todo.status,
is_sync=True,
)
if todo.status == "COMPLETED":
payload.completed_at = utcnow()
update_calendar_event(db, payload, actor=actor, syncing=True)
except Exception as exc: # pragma: no cover - best effort sync
logger.warning("Failed to sync todo->schedule transition: %s", exc)
def _sync_create_todo_for_event(db: Session, *, event: CalendarEvent, actor: User) -> None:
from ..schemas.todo import TodoCreateRequest
from .todo_service import create_todo
try:
todo = create_todo(
db,
TodoCreateRequest(
title=event.title,
descr=event.descr,
status=event.status,
priority=event.priority,
start_time=event.start_time,
due_date=event.end_time,
expire_time=event.expire_time,
is_sync=True,
calendar_event_id=event.id,
),
actor=actor,
syncing=True,
)
except Exception as exc: # pragma: no cover - best effort sync
logger.warning("Failed to sync schedule->todo create: %s", exc)
return
event.todo_id = todo.id
event.update_user = actor.username
event.update_date = utcnow()
db.commit()
def _sync_update_todo_for_event(db: Session, *, event: CalendarEvent, actor: User) -> None:
if not event.todo_id:
return
from ..schemas.todo import TodoTransitionRequest, TodoUpdateRequest
from .todo_service import get_todo_by_id, transition_todo, update_todo
todo = get_todo_by_id(db, event.todo_id)
if not todo:
return
try:
update_todo(
db,
event.todo_id,
TodoUpdateRequest(
title=event.title,
descr=event.descr,
priority=event.priority,
start_time=event.start_time,
due_date=event.end_time,
expire_time=event.expire_time,
calendar_event_id=event.id,
is_sync=True,
),
actor=actor,
syncing=True,
)
if todo.status != event.status:
transition_todo(
db,
event.todo_id,
TodoTransitionRequest(status=event.status, is_sync=True),
actor=actor,
syncing=True,
)
except Exception as exc: # pragma: no cover - best effort sync
logger.warning("Failed to sync schedule->todo update: %s", exc)
def expire_overdue_events(db: Session) -> int:
now = utcnow()
events = db.execute(
select(CalendarEvent).where(
CalendarEvent.expire_time.is_not(None),
CalendarEvent.expire_time <= now,
CalendarEvent.status.in_(sorted(SCHEDULE_ACTIVE_STATUSES)),
)
).scalars().all()
if not events:
return 0
for event in events:
event.status = "EXPIRED"
event.update_user = "system"
event.update_date = now
db.commit()
return len(events)
def _chunk_text(text: str, *, chunk_size: int) -> list[str]:
if not text:
return []
return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)]
def _coerce_generated_event(content: str) -> dict[str, object]:
payload = _extract_json_object(content)
title = str(payload.get("title") or "新日程").strip() or "新日程"
descr = str(payload.get("descr") or payload.get("description") or "").strip()
start_value = payload.get("start_time") or payload.get("startTime")
end_value = payload.get("end_time") or payload.get("endTime")
expire_value = payload.get("expire_time") or payload.get("expireTime")
start_dt = _parse_datetime(start_value) or (utcnow() + timedelta(hours=1))
end_dt = _parse_datetime(end_value) or (start_dt + timedelta(hours=1))
if end_dt <= start_dt:
end_dt = start_dt + timedelta(hours=1)
expire_dt = _parse_datetime(expire_value)
status_value = str(payload.get("status") or "SCHEDULED").upper()
if status_value not in {"SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED", "EXPIRED"}:
status_value = "SCHEDULED"
priority_value = str(payload.get("priority") or "MEDIUM").upper()
if priority_value not in {"LOW", "MEDIUM", "HIGH"}:
priority_value = "MEDIUM"
all_day = bool(payload.get("all_day") if "all_day" in payload else payload.get("allDay", False))
return {
"title": title,
"descr": descr,
"status": status_value,
"priority": priority_value,
"start_time": start_dt.isoformat(),
"end_time": end_dt.isoformat(),
"expire_time": expire_dt.isoformat() if expire_dt else None,
"all_day": all_day,
}
def _extract_json_object(content: str) -> dict[str, object]:
text = content.strip()
try:
value = json.loads(text)
if isinstance(value, dict):
return value
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError("no json object found")
candidate = text[start:end + 1]
value = json.loads(candidate)
if not isinstance(value, dict):
raise ValueError("json payload must be object")
return value
def _parse_datetime(value: object) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, str):
text = value.strip()
if not text:
return None
normalized = text.replace("Z", "+00:00")
try:
return datetime.fromisoformat(normalized)
except ValueError:
return None
return None
-122
View File
@@ -1,122 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..core.database import SessionLocal
from ..core.security import create_access_token
from ..models.user import User
from .legacy_authz_service import get_user_authorization, is_user_enabled, normalize_user_status
from ..schemas.jwt_generator import (
JwtGenerateRequest,
JwtGenerateResponse,
JwtGeneratorUserItem,
JwtGeneratorUserListResponse,
)
from .user_service import _user_with_rbac_stmt, get_user_by_id
def list_jwt_generator_users(
*,
keyword: str | None,
status_filter: str | None,
limit: int,
offset: int,
) -> JwtGeneratorUserListResponse:
with SessionLocal() as db:
stmt = _user_with_rbac_stmt()
if keyword:
normalized = keyword.strip()
if normalized:
like = f"%{normalized}%"
stmt = stmt.where(
User.id.ilike(like)
| User.email.ilike(like)
| User.username.ilike(like)
)
if status_filter in {"active", "disabled"}:
if status_filter == "active":
stmt = stmt.where(User.status.in_(["active", "ACTIVE", "ENABLED"]))
else:
stmt = stmt.where(User.status.in_(["disabled", "DISABLED", "INACTIVE"]))
total_stmt = select(func.count()).select_from(User)
if keyword:
normalized = keyword.strip()
if normalized:
like = f"%{normalized}%"
total_stmt = total_stmt.where(
User.id.ilike(like)
| User.email.ilike(like)
| User.username.ilike(like)
)
if status_filter in {"active", "disabled"}:
if status_filter == "active":
total_stmt = total_stmt.where(User.status.in_(["active", "ACTIVE", "ENABLED"]))
else:
total_stmt = total_stmt.where(User.status.in_(["disabled", "DISABLED", "INACTIVE"]))
total = db.scalar(total_stmt) or 0
users = (
db.execute(
stmt.order_by(User.created_at.desc(), User.id.asc())
.offset(offset)
.limit(limit)
)
.unique()
.scalars()
.all()
)
items = []
for user in users:
authz = get_user_authorization(db, user.id)
items.append(
JwtGeneratorUserItem(
id=user.id,
email=user.email or "",
username=user.username,
status=normalize_user_status(user.status),
role_codes=sorted(authz.role_codes),
)
)
return JwtGeneratorUserListResponse(items=items, total=total, limit=limit, offset=offset)
def generate_jwt_for_user(payload: JwtGenerateRequest) -> JwtGenerateResponse:
normalized_user_id = payload.user_id.strip()
with SessionLocal() as db:
user = get_user_by_id(db, normalized_user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if not is_user_enabled(user.status):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
authz = get_user_authorization(db, user.id)
role_codes = sorted(authz.role_codes)
permission_codes = sorted(authz.permission_codes)
access_token, expires_in = create_access_token(
user_id=normalized_user_id,
role_codes=role_codes,
permission_codes=permission_codes,
expires_minutes=payload.expires_minutes,
)
expires_at = datetime.now(UTC) + timedelta(seconds=expires_in)
return JwtGenerateResponse(
access_token=access_token,
expires_in=expires_in,
expires_at=expires_at,
user_id=normalized_user_id,
role_codes=role_codes,
permission_codes=permission_codes,
)
@@ -87,13 +87,11 @@ PROTECTED_MENU_CODES = {
"admin.data_query",
"admin.hot_search",
"admin.cron_task_mgr",
"admin.todos",
"admin.mdresolve",
"admin.tag",
"admin.knowledge_point_mgr",
"admin.job_mgr",
"admin.syslog",
"admin.jwt_generator",
"admin.wine_runner",
# quiz legacy defaults
"sys_mgr",
-910
View File
@@ -1,910 +0,0 @@
from __future__ import annotations
import asyncio
import math
from fastapi import HTTPException, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from ..models.requirement import Requirement, RequirementComment, RequirementEvent
from ..models.rbac import Role
from ..models.user import User
from ..schemas.requirement import (
RequirementAssignRequest,
RequirementCommentCreateRequest,
RequirementCommentPublic,
RequirementCreateRequest,
RequirementEventPublic,
RequirementListResponse,
RequirementSummary,
RequirementTransitionRequest,
RequirementUpdateRequest,
)
from .push_service import publish_topic
from .user_service import serialize_user
TOPIC_NAME = "requirements"
ZERO_PROGRESS_STATUSES = {
"PENDING_ANALYSIS",
"PENDING_REVIEW",
"PENDING_REVISION",
"OPEN",
}
VALID_STATUSES = {
"PENDING_ANALYSIS",
"PENDING_REVIEW",
"PENDING_REVISION",
"OPEN",
"IN_PROGRESS",
"COMPLETED",
"CLOSED",
}
STATUS_ALIASES = {"CANCELLED": "CLOSED"}
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
"PENDING_ANALYSIS": {"PENDING_REVIEW", "PENDING_REVISION", "OPEN", "CLOSED"},
"PENDING_REVIEW": {"PENDING_REVISION", "OPEN", "CLOSED"},
"PENDING_REVISION": {"OPEN", "CLOSED"},
"OPEN": {"IN_PROGRESS", "CLOSED"},
"IN_PROGRESS": {"COMPLETED", "PENDING_REVISION", "CLOSED"},
"COMPLETED": {"CLOSED"},
"CLOSED": set(),
}
VALID_PRIORITIES = {"LOW", "MEDIUM", "HIGH"}
PRIORITY_ALIASES = {"URGENT": "HIGH"}
COMMENT_LOAD_OPTIONS = (selectinload(RequirementComment.author).selectinload(User.roles),)
def list_requirements(
db: Session,
*,
keyword: str | None,
status: str | None,
priority: str | None,
assignee_user_id: str | None,
project_name: str | None,
) -> RequirementListResponse:
stmt = select(Requirement)
if keyword:
like = f"%{keyword.strip()}%"
stmt = stmt.where(
or_(
Requirement.title.ilike(like),
Requirement.id.ilike(like),
Requirement.project_name.ilike(like),
)
)
if status:
stmt = stmt.where(Requirement.status == _normalize_status(status))
if priority:
stmt = stmt.where(Requirement.priority == _normalize_priority(priority))
if assignee_user_id:
# 兼容筛选参数:老表中无 assignee 字段,使用 create_user 近似过滤。
stmt = stmt.where(Requirement.create_user == assignee_user_id)
if project_name:
stmt = stmt.where(Requirement.project_name == project_name)
requirements = db.execute(stmt.order_by(Requirement.update_date.desc())).scalars().all()
user_map = _load_users_for_requirements(db, requirements)
return RequirementListResponse(
items=[serialize_requirement(item, user_map=user_map) for item in requirements],
total=len(requirements),
)
def get_requirement_by_id(db: Session, requirement_id: str) -> Requirement | None:
return db.execute(select(Requirement).where(Requirement.id == requirement_id)).scalar_one_or_none()
def create_requirement(
db: Session,
payload: RequirementCreateRequest,
*,
actor: User,
) -> RequirementSummary:
requirement = Requirement(
title=payload.title.strip(),
descr=payload.description.strip(),
status=_normalize_status(payload.status),
priority=_normalize_priority(payload.priority),
project_name=_normalize_str(payload.project_name),
git_url=_normalize_str(payload.source),
branch=_normalize_str(payload.module_name) or "main",
create_user=actor.id,
update_user=actor.id,
)
_apply_progress_by_status(requirement, None)
db.add(requirement)
db.flush()
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor.id,
event_type="CREATE",
from_status=None,
to_status=requirement.status,
before_descr=None,
after_descr=requirement.descr,
remark="创建需求",
)
db.commit()
saved = get_requirement_by_id(db, requirement.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Requirement save failed")
_publish_requirement_change("requirements.changed", saved, action="created")
return serialize_requirement(saved, user_map={actor.id: actor})
def update_requirement(
db: Session,
requirement_id: str,
payload: RequirementUpdateRequest,
*,
actor: User,
) -> RequirementSummary:
requirement = get_requirement_by_id(db, requirement_id)
if not requirement:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requirement not found")
before_descr = requirement.descr
update_data = payload.model_dump(exclude_unset=True)
if "title" in update_data and update_data["title"] is not None:
requirement.title = update_data["title"].strip()
if "description" in update_data and update_data["description"] is not None:
requirement.descr = update_data["description"].strip()
if "priority" in update_data and update_data["priority"] is not None:
requirement.priority = _normalize_priority(update_data["priority"])
if "project_name" in update_data:
requirement.project_name = _normalize_str(update_data["project_name"])
if "module_name" in update_data:
requirement.branch = _normalize_str(update_data["module_name"]) or "main"
if "source" in update_data:
requirement.git_url = _normalize_str(update_data["source"])
requirement.update_user = actor.id
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor.id,
event_type="EDIT",
from_status=requirement.status,
to_status=requirement.status,
before_descr=before_descr,
after_descr=requirement.descr,
remark="更新需求",
)
db.commit()
saved = get_requirement_by_id(db, requirement.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Requirement load failed")
_publish_requirement_change("requirements.changed", saved, action="updated")
return serialize_requirement(saved, user_map={actor.id: actor})
def assign_requirement(
db: Session,
requirement_id: str,
payload: RequirementAssignRequest,
*,
actor: User,
) -> RequirementSummary:
requirement = get_requirement_by_id(db, requirement_id)
if not requirement:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requirement not found")
assignee_user_id = _normalize_str(payload.assignee_user_id)
if assignee_user_id:
assignee = _load_user_if_exists(db, assignee_user_id)
if not assignee:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Assignee not found")
requirement.update_user = actor.id
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor.id,
event_type="EDIT",
from_status=requirement.status,
to_status=requirement.status,
before_descr=requirement.descr,
after_descr=requirement.descr,
remark=f"指派: {assignee_user_id or 'UNASSIGNED'}",
)
db.commit()
saved = get_requirement_by_id(db, requirement.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Requirement load failed")
_publish_requirement_change("requirements.changed", saved, action="assigned")
user_map = _load_users_for_requirements(db, [saved])
if actor.id not in user_map:
user_map[actor.id] = actor
return serialize_requirement(saved, user_map=user_map)
def claim_requirement(db: Session, requirement_id: str, *, actor: User) -> RequirementSummary:
return assign_requirement(
db,
requirement_id,
RequirementAssignRequest(assignee_user_id=actor.id),
actor=actor,
)
def transition_requirement(
db: Session,
requirement_id: str,
payload: RequirementTransitionRequest,
*,
actor: User,
) -> RequirementSummary:
requirement = get_requirement_by_id(db, requirement_id)
if not requirement:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requirement not found")
current_status = _normalize_status(requirement.status)
target_status = _normalize_status(payload.status)
if current_status == target_status:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Status is unchanged")
if target_status not in ALLOWED_TRANSITIONS.get(current_status, set()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Transition not allowed: {current_status} -> {target_status}",
)
requirement.status = target_status
requirement.update_user = actor.id
if payload.note is not None:
requirement.result_msg = _normalize_str(payload.note)
_apply_progress_by_status(requirement, None)
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor.id,
event_type="STATUS_CHANGE",
from_status=current_status,
to_status=target_status,
before_descr=requirement.descr,
after_descr=requirement.descr,
remark=_normalize_str(payload.note),
)
db.commit()
saved = get_requirement_by_id(db, requirement.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Requirement load failed")
_publish_requirement_change("requirements.transitioned", saved, action="transitioned")
return serialize_requirement(saved, user_map={actor.id: actor})
def delete_requirement(db: Session, requirement_id: str, *, actor: User) -> bool:
requirement = get_requirement_by_id(db, requirement_id)
if not requirement:
return False
deleted_id = requirement.id
db.delete(requirement)
db.commit()
_fire_and_forget(
publish_topic(
TOPIC_NAME,
name="requirements.deleted",
payload={
"action": "deleted",
"requirement_id": deleted_id,
"code": deleted_id,
"actor_user_id": actor.id,
},
requires_refetch=[],
dedupe_key=f"requirements:deleted:{deleted_id}",
)
)
return True
def list_requirement_comments(db: Session, requirement_id: str) -> list[RequirementCommentPublic]:
_require_requirement_exists(db, requirement_id)
comments = db.execute(
select(RequirementComment)
.options(*COMMENT_LOAD_OPTIONS)
.where(RequirementComment.requirement_id == requirement_id)
.order_by(RequirementComment.created_at.desc())
).scalars().all()
return [serialize_comment(item) for item in comments]
def add_requirement_comment(
db: Session,
requirement_id: str,
payload: RequirementCommentCreateRequest,
*,
actor: User,
) -> RequirementCommentPublic:
requirement = _require_requirement_exists(db, requirement_id)
comment = RequirementComment(
requirement_id=requirement.id,
author_user_id=actor.id,
content=payload.content.strip(),
kind=payload.kind,
)
db.add(comment)
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor.id,
event_type="EDIT",
from_status=requirement.status,
to_status=requirement.status,
before_descr=requirement.descr,
after_descr=requirement.descr,
remark=f"comment:{payload.kind}",
)
db.commit()
saved = db.execute(
select(RequirementComment)
.options(*COMMENT_LOAD_OPTIONS)
.where(RequirementComment.id == comment.id)
).scalar_one()
latest_requirement = get_requirement_by_id(db, requirement.id)
if latest_requirement:
_publish_requirement_change(
"requirements.commented",
latest_requirement,
action="commented",
extra_payload={"comment_id": saved.id, "kind": saved.kind},
)
return serialize_comment(saved)
def list_requirement_events(db: Session, requirement_id: str) -> list[RequirementEventPublic]:
_require_requirement_exists(db, requirement_id)
events = db.execute(
select(RequirementEvent)
.where(RequirementEvent.requirement_id == requirement_id)
.order_by(RequirementEvent.create_date.desc(), RequirementEvent.id.desc())
).scalars().all()
user_ids = [event.create_user for event in events if event.create_user]
user_map = _load_users_by_ids(db, user_ids)
return [serialize_event(item, user_map=user_map) for item in events]
def serialize_requirement(
requirement: Requirement,
*,
user_map: dict[str, User] | None = None,
) -> RequirementSummary:
creator = None
if requirement.create_user:
if user_map and requirement.create_user in user_map:
creator = user_map[requirement.create_user]
return RequirementSummary(
id=requirement.id,
code=requirement.id,
title=requirement.title,
description=requirement.descr or "",
status=_to_api_status(requirement.status),
priority=_to_api_priority(requirement.priority),
project_name=requirement.project_name,
module_name=requirement.branch,
source=requirement.git_url,
creator_user_id=requirement.create_user,
assignee_user_id=None,
reviewer_user_id=None,
due_at=None,
closed_at=requirement.update_date if requirement.status in {"COMPLETED", "CLOSED"} else None,
created_at=requirement.create_date,
updated_at=requirement.update_date,
result_msg=requirement.result_msg,
progress_percent=_normalize_progress(requirement.progress_percent),
git_url=requirement.git_url,
branch=requirement.branch,
creator=serialize_user(creator) if creator else None,
assignee=None,
reviewer=None,
)
def serialize_comment(comment: RequirementComment) -> RequirementCommentPublic:
return RequirementCommentPublic(
id=comment.id,
requirement_id=comment.requirement_id,
author_user_id=comment.author_user_id,
content=comment.content,
kind=comment.kind,
created_at=comment.created_at,
author=serialize_user(comment.author) if comment.author else None,
)
def serialize_event(
event: RequirementEvent,
*,
user_map: dict[str, User] | None = None,
) -> RequirementEventPublic:
actor = None
if event.create_user and user_map and event.create_user in user_map:
actor = user_map[event.create_user]
return RequirementEventPublic(
id=event.id,
requirement_id=event.requirement_id,
actor_user_id=event.create_user,
event_type=event.event_type,
from_status=event.from_status,
to_status=event.to_status,
payload_json={
"before_descr": event.before_descr,
"after_descr": event.after_descr,
"remark": event.remark,
},
created_at=event.create_date,
actor=serialize_user(actor) if actor else None,
)
def _append_event(
db: Session,
*,
requirement_id: str,
actor_user_id: str | None,
event_type: str,
from_status: str | None,
to_status: str | None,
before_descr: str | None,
after_descr: str | None,
remark: str | None,
) -> None:
db.add(
RequirementEvent(
requirement_id=requirement_id,
event_type=event_type,
from_status=from_status,
to_status=to_status,
before_descr=before_descr,
after_descr=after_descr,
remark=remark,
create_user=actor_user_id,
update_user=actor_user_id,
)
)
def _publish_requirement_change(
event_name: str,
requirement: Requirement,
*,
action: str,
extra_payload: dict | None = None,
) -> None:
payload = {
"action": action,
"requirement_id": requirement.id,
"code": requirement.id,
"status": requirement.status,
"assignee_user_id": None,
}
if extra_payload:
payload.update(extra_payload)
_fire_and_forget(
publish_topic(
TOPIC_NAME,
name=event_name,
payload=payload,
requires_refetch=[],
dedupe_key=f"requirements:{action}:{requirement.id}",
)
)
def _load_user_if_exists(db: Session, user_id: str | None) -> User | None:
if not user_id:
return None
stmt = (
select(User)
.options(selectinload(User.roles).selectinload(Role.permissions))
.where(User.id == user_id)
)
return db.execute(stmt).unique().scalar_one_or_none()
def _load_users_by_ids(db: Session, user_ids: list[str]) -> dict[str, User]:
normalized = sorted({user_id for user_id in user_ids if user_id})
if not normalized:
return {}
stmt = (
select(User)
.options(selectinload(User.roles).selectinload(Role.permissions))
.where(User.id.in_(normalized))
)
users = db.execute(stmt).unique().scalars().all()
return {user.id: user for user in users}
def _load_users_for_requirements(db: Session, requirements: list[Requirement]) -> dict[str, User]:
user_ids = [item.create_user for item in requirements if item.create_user]
return _load_users_by_ids(db, user_ids)
def _require_requirement_exists(db: Session, requirement_id: str) -> Requirement:
requirement = get_requirement_by_id(db, requirement_id)
if not requirement:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requirement not found")
return requirement
def _normalize_str(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
def _normalize_status(value: str) -> str:
normalized = (value or "").strip().upper()
if normalized in STATUS_ALIASES:
normalized = STATUS_ALIASES[normalized]
if normalized not in VALID_STATUSES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid status: {value}")
return normalized
def _to_api_status(value: str | None) -> str:
if not value:
return "PENDING_ANALYSIS"
normalized = value.strip().upper()
if normalized in STATUS_ALIASES:
normalized = STATUS_ALIASES[normalized]
if normalized in VALID_STATUSES:
return normalized
return "PENDING_ANALYSIS"
def _normalize_priority(value: str) -> str:
normalized = (value or "").strip().upper()
if normalized in PRIORITY_ALIASES:
normalized = PRIORITY_ALIASES[normalized]
if normalized not in VALID_PRIORITIES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid priority: {value}")
return normalized
def _to_api_priority(value: str | None) -> str:
normalized = (value or "").strip().upper()
if normalized == "LOW":
return "low"
if normalized == "HIGH":
return "high"
return "medium"
def _normalize_progress(progress_percent: int | None) -> int:
if progress_percent is None:
return 0
return max(0, min(100, int(progress_percent)))
def _apply_progress_by_status(requirement: Requirement, progress_percent: int | None) -> None:
if progress_percent is not None:
requirement.progress_percent = _normalize_progress(progress_percent)
return
if requirement.status == "COMPLETED":
requirement.progress_percent = 100
return
if requirement.status in ZERO_PROGRESS_STATUSES:
requirement.progress_percent = 0
return
requirement.progress_percent = _normalize_progress(requirement.progress_percent)
def _fire_and_forget(coro: object) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(coro)
def get_pending_requirement_legacy(db: Session) -> dict | None:
requirement = db.execute(
select(Requirement)
.where(Requirement.status == "OPEN")
.order_by(Requirement.create_date.asc())
.limit(1)
).scalar_one_or_none()
if not requirement:
return None
return serialize_requirement_legacy(requirement)
def search_requirements_legacy(
db: Session,
*,
page_num: int,
page_size: int,
project_name: str | None,
status_value: str | None,
priority_value: str | None,
title: str | None,
) -> dict:
normalized_page_num = max(page_num, 1)
normalized_page_size = max(page_size, 1)
stmt = select(Requirement)
total_stmt = select(func.count()).select_from(Requirement)
if project_name:
stmt = stmt.where(Requirement.project_name.ilike(f"%{project_name.strip()}%"))
total_stmt = total_stmt.where(Requirement.project_name.ilike(f"%{project_name.strip()}%"))
if title:
stmt = stmt.where(Requirement.title.ilike(f"%{title.strip()}%"))
total_stmt = total_stmt.where(Requirement.title.ilike(f"%{title.strip()}%"))
if status_value:
db_status = _normalize_status(status_value)
stmt = stmt.where(Requirement.status == db_status)
total_stmt = total_stmt.where(Requirement.status == db_status)
if priority_value:
db_priority = _normalize_priority(priority_value)
stmt = stmt.where(Requirement.priority == db_priority)
total_stmt = total_stmt.where(Requirement.priority == db_priority)
total = int(db.scalar(total_stmt) or 0)
rows = db.execute(
stmt.order_by(Requirement.create_date.desc())
.offset((normalized_page_num - 1) * normalized_page_size)
.limit(normalized_page_size)
).scalars().all()
total_pages = math.ceil(total / normalized_page_size) if total > 0 else 0
return {
"content": [serialize_requirement_legacy(item) for item in rows],
"totalElements": total,
"totalPages": total_pages,
"size": normalized_page_size,
"number": normalized_page_num - 1,
}
def get_requirement_legacy(db: Session, requirement_id: str) -> dict:
requirement = _require_requirement_exists(db, requirement_id)
return serialize_requirement_legacy(requirement)
def update_status_legacy(
db: Session,
*,
requirement_id: str,
status_value: str,
result_msg: str | None,
progress_percent: int | None,
actor_user_id: str | None,
) -> None:
requirement = _require_requirement_exists(db, requirement_id)
from_status = requirement.status
before_descr = requirement.descr
requirement.status = _normalize_status(status_value)
if _normalize_str(result_msg):
requirement.result_msg = _normalize_str(result_msg)
_apply_progress_by_status(requirement, progress_percent)
if actor_user_id:
requirement.update_user = actor_user_id
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor_user_id,
event_type="STATUS_CHANGE",
from_status=from_status,
to_status=requirement.status,
before_descr=before_descr,
after_descr=requirement.descr,
remark=_normalize_str(result_msg),
)
db.commit()
def analyze_requirement_legacy(
db: Session,
*,
requirement_id: str,
descr: str | None,
progress_percent: int | None,
actor_user_id: str | None,
) -> dict:
requirement = _require_requirement_exists(db, requirement_id)
if requirement.status != "PENDING_ANALYSIS":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only PENDING_ANALYSIS can be analyzed")
from_status = requirement.status
before_descr = requirement.descr
if descr is not None:
requirement.descr = descr
requirement.status = "PENDING_REVIEW"
requirement.progress_percent = _normalize_progress(progress_percent)
if actor_user_id:
requirement.update_user = actor_user_id
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor_user_id,
event_type="ANALYZE",
from_status=from_status,
to_status=requirement.status,
before_descr=before_descr,
after_descr=requirement.descr,
remark=None,
)
db.commit()
return serialize_requirement_legacy(requirement)
def design_requirement_legacy(
db: Session,
*,
requirement_id: str,
descr: str | None,
actor_user_id: str | None,
) -> dict:
requirement = _require_requirement_exists(db, requirement_id)
if requirement.status != "PENDING_ANALYSIS":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only PENDING_ANALYSIS can be designed")
from_status = requirement.status
before_descr = requirement.descr
if descr is not None:
requirement.descr = descr
requirement.status = "PENDING_ANALYSIS"
requirement.progress_percent = 0
if actor_user_id:
requirement.update_user = actor_user_id
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor_user_id,
event_type="DESIGN",
from_status=from_status,
to_status=requirement.status,
before_descr=before_descr,
after_descr=requirement.descr,
remark=None,
)
db.commit()
return serialize_requirement_legacy(requirement)
def review_requirement_legacy(
db: Session,
*,
requirement_id: str,
decision: str,
descr: str | None,
comment: str | None,
actor_user_id: str | None,
) -> dict:
requirement = _require_requirement_exists(db, requirement_id)
normalized_decision = (decision or "").strip().upper()
if normalized_decision not in {"TO_REVISION", "TO_OPEN"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid review decision")
from_status = requirement.status
before_descr = requirement.descr
if descr is not None:
requirement.descr = descr
requirement.status = "PENDING_REVISION" if normalized_decision == "TO_REVISION" else "OPEN"
requirement.progress_percent = 0
if actor_user_id:
requirement.update_user = actor_user_id
_append_event(
db,
requirement_id=requirement.id,
actor_user_id=actor_user_id,
event_type="REVIEW",
from_status=from_status,
to_status=requirement.status,
before_descr=before_descr,
after_descr=requirement.descr,
remark=_normalize_str(comment),
)
db.commit()
return serialize_requirement_legacy(requirement)
def list_lifecycle_legacy(db: Session, requirement_id: str) -> list[dict]:
_require_requirement_exists(db, requirement_id)
logs = db.execute(
select(RequirementEvent)
.where(RequirementEvent.requirement_id == requirement_id)
.order_by(RequirementEvent.create_date.asc(), RequirementEvent.id.asc())
).scalars().all()
return [serialize_lifecycle_log_legacy(item) for item in logs]
def get_history_options_legacy(db: Session) -> dict:
requirements = db.execute(
select(Requirement)
.order_by(Requirement.create_date.desc())
.limit(200)
).scalars().all()
project_names = []
git_urls = []
branches = []
seen_project = set()
seen_git = set()
seen_branch = set()
for item in requirements:
if item.project_name:
value = item.project_name.strip()
if value and value not in seen_project:
seen_project.add(value)
project_names.append(value)
if item.git_url:
value = item.git_url.strip()
if value and value not in seen_git:
seen_git.add(value)
git_urls.append(value)
if item.branch:
value = item.branch.strip()
if value and value not in seen_branch:
seen_branch.add(value)
branches.append(value)
return {
"projectNames": project_names,
"gitUrls": git_urls,
"branches": branches,
}
def serialize_requirement_legacy(requirement: Requirement) -> dict:
try:
status_value = _normalize_status(requirement.status or "PENDING_ANALYSIS")
except HTTPException:
status_value = "PENDING_ANALYSIS"
try:
priority_value = _normalize_priority(requirement.priority or "MEDIUM")
except HTTPException:
priority_value = "MEDIUM"
return {
"id": requirement.id,
"title": requirement.title,
"projectName": requirement.project_name,
"gitUrl": requirement.git_url,
"branch": requirement.branch,
"descr": requirement.descr,
"resultMsg": requirement.result_msg,
"progressPercent": _normalize_progress(requirement.progress_percent),
"status": status_value,
"priority": priority_value,
"createDate": requirement.create_date.isoformat() if requirement.create_date else None,
"createUser": requirement.create_user,
"updateDate": requirement.update_date.isoformat() if requirement.update_date else None,
"updateUser": requirement.update_user,
}
def serialize_lifecycle_log_legacy(log: RequirementEvent) -> dict:
return {
"id": log.id,
"requirementId": log.requirement_id,
"eventType": log.event_type,
"fromStatus": log.from_status,
"toStatus": log.to_status,
"beforeDescr": log.before_descr,
"afterDescr": log.after_descr,
"remark": log.remark,
"createDate": log.create_date.isoformat() if log.create_date else None,
"createUser": log.create_user,
"updateDate": log.update_date.isoformat() if log.update_date else None,
"updateUser": log.update_user,
}
-315
View File
@@ -1,315 +0,0 @@
from __future__ import annotations
import asyncio
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from ..models.base import utcnow
from ..models.todo import Todo
from ..models.user import User
from ..schemas.todo import (
TodoCreateRequest,
TodoListResponse,
TodoSummary,
TodoTransitionRequest,
TodoUpdateRequest,
)
from .push_service import publish_topic
TOPIC_NAME = "todos"
TODO_ACTIVE_STATUSES = {"SCHEDULED", "IN_PROGRESS"}
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
"SCHEDULED": {"IN_PROGRESS", "COMPLETED", "CANCELLED", "EXPIRED"},
"IN_PROGRESS": {"SCHEDULED", "COMPLETED", "CANCELLED", "EXPIRED"},
"COMPLETED": {"SCHEDULED", "IN_PROGRESS", "CANCELLED"},
"CANCELLED": {"SCHEDULED", "IN_PROGRESS"},
"EXPIRED": {"SCHEDULED", "IN_PROGRESS", "CANCELLED", "COMPLETED"},
}
def list_todos(
db: Session,
*,
title: str | None,
status_filter: str | None,
priority: str | None,
page_num: int,
page_size: int,
actor: User,
) -> TodoListResponse:
filters = [Todo.create_user == actor.username]
if title:
filters.append(Todo.title.ilike(f"%{title.strip()}%"))
if status_filter:
filters.append(Todo.status == status_filter)
if priority:
filters.append(Todo.priority == priority)
total_stmt = select(func.count()).select_from(Todo).where(*filters)
total = db.execute(total_stmt).scalar_one()
stmt = (
select(Todo)
.where(*filters)
.order_by(Todo.create_date.desc())
.offset(page_num * page_size)
.limit(page_size)
)
items = db.execute(stmt).scalars().all()
return TodoListResponse(items=[serialize_todo(item) for item in items], total=total)
def get_todo_by_id(db: Session, todo_id: str, *, actor: User | None = None) -> Todo | None:
todo = db.execute(select(Todo).where(Todo.id == todo_id)).scalar_one_or_none()
if not todo:
return None
if actor and todo.create_user != actor.username:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to access todo")
return todo
def create_todo(
db: Session,
payload: TodoCreateRequest,
*,
actor: User,
syncing: bool = False,
) -> TodoSummary:
now = utcnow()
todo = Todo(
title=payload.title.strip(),
descr=_normalize_str(payload.descr) or "",
status=payload.status,
priority=payload.priority,
start_time=payload.start_time,
due_date=payload.due_date,
expire_time=payload.expire_time,
calendar_event_id=_normalize_str(payload.calendar_event_id),
create_user=actor.username,
update_user=actor.username,
create_date=now,
update_date=now,
)
db.add(todo)
db.commit()
saved = get_todo_by_id(db, todo.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo save failed")
if not (payload.is_sync or syncing):
from .calendar_event_service import sync_from_todo_create
sync_from_todo_create(db, todo=saved, actor=actor)
saved = get_todo_by_id(db, todo.id) or saved
_publish_todo_change("todos.created", saved, action="created")
return serialize_todo(saved)
def update_todo(
db: Session,
todo_id: str,
payload: TodoUpdateRequest,
*,
actor: User,
syncing: bool = False,
) -> TodoSummary:
todo = get_todo_by_id(db, todo_id, actor=actor)
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
update_data = payload.model_dump(exclude_unset=True)
for field in ["title", "descr", "status", "priority", "start_time", "due_date", "expire_time", "calendar_event_id"]:
if field in update_data:
value = update_data[field]
setattr(todo, field, _normalize_str(value) if isinstance(value, str) else value)
todo.update_user = actor.username
todo.update_date = utcnow()
db.commit()
saved = get_todo_by_id(db, todo.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo load failed")
if not (payload.is_sync or syncing):
from .calendar_event_service import sync_from_todo_update
sync_from_todo_update(db, todo=saved, actor=actor)
saved = get_todo_by_id(db, todo.id) or saved
_publish_todo_change("todos.updated", saved, action="updated")
return serialize_todo(saved)
def transition_todo(
db: Session,
todo_id: str,
payload: TodoTransitionRequest,
*,
actor: User,
syncing: bool = False,
) -> TodoSummary:
todo = get_todo_by_id(db, todo_id, actor=actor)
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
current_status = todo.status
target_status = payload.status
if current_status == target_status:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Status is unchanged")
if target_status not in ALLOWED_TRANSITIONS.get(current_status, set()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Transition not allowed: {current_status} -> {target_status}",
)
todo.status = target_status
todo.update_user = actor.username
todo.update_date = utcnow()
db.commit()
saved = get_todo_by_id(db, todo.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo load failed")
if not (payload.is_sync or syncing):
from .calendar_event_service import sync_from_todo_transition
sync_from_todo_transition(db, todo=saved, actor=actor)
saved = get_todo_by_id(db, todo.id) or saved
_publish_todo_change("todos.transitioned", saved, action="transitioned")
return serialize_todo(saved)
def complete_todo(
db: Session,
todo_id: str,
*,
actor: User,
syncing: bool = False,
) -> TodoSummary:
todo = get_todo_by_id(db, todo_id, actor=actor)
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
todo.status = "COMPLETED"
todo.update_user = actor.username
todo.update_date = utcnow()
db.commit()
saved = get_todo_by_id(db, todo.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo load failed")
if not syncing:
from .calendar_event_service import sync_from_todo_transition
sync_from_todo_transition(db, todo=saved, actor=actor)
saved = get_todo_by_id(db, todo.id) or saved
_publish_todo_change("todos.completed", saved, action="completed")
return serialize_todo(saved)
def expire_overdue_todos(db: Session) -> int:
now = utcnow()
todos = db.execute(
select(Todo).where(
Todo.expire_time.is_not(None),
Todo.expire_time <= now,
Todo.status.in_(sorted(TODO_ACTIVE_STATUSES)),
)
).scalars().all()
if not todos:
return 0
for todo in todos:
todo.status = "EXPIRED"
todo.update_user = "system"
todo.update_date = now
db.commit()
return len(todos)
def delete_todo(db: Session, todo_id: str, *, actor: User, syncing: bool = False) -> dict[str, bool]:
todo = get_todo_by_id(db, todo_id, actor=actor)
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
deleted_id = todo.id
db.delete(todo)
db.commit()
if not syncing:
from .calendar_event_service import sync_from_todo_delete
sync_from_todo_delete(db, todo=todo, actor=actor)
_fire_and_forget(
publish_topic(
TOPIC_NAME,
name="todos.deleted",
payload={"action": "deleted", "todo_id": deleted_id, "actor_user": actor.username},
requires_refetch=[],
dedupe_key=f"todos:deleted:{deleted_id}",
)
)
return {"success": True}
def serialize_todo(todo: Todo) -> TodoSummary:
return TodoSummary(
id=todo.id,
title=todo.title,
descr=todo.descr,
status=todo.status,
priority=todo.priority,
start_time=todo.start_time,
due_date=todo.due_date,
expire_time=todo.expire_time,
calendar_event_id=todo.calendar_event_id,
create_date=todo.create_date,
create_user=todo.create_user,
update_date=todo.update_date,
update_user=todo.update_user,
)
def _publish_todo_change(event_name: str, todo: Todo, *, action: str) -> None:
payload = {
"action": action,
"todo_id": todo.id,
"status": todo.status,
"priority": todo.priority,
}
_fire_and_forget(
publish_topic(
TOPIC_NAME,
name=event_name,
payload=payload,
requires_refetch=[],
dedupe_key=f"todos:{action}:{todo.id}",
)
)
def _normalize_str(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
def _fire_and_forget(coro: object) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(coro)
-27
View File
@@ -1,27 +0,0 @@
from __future__ import annotations
import logging
from ..core.celery_app import celery_app
from ..core.database import SessionLocal
from ..services.calendar_event_service import expire_overdue_events
from ..services.todo_service import expire_overdue_todos
logger = logging.getLogger(__name__)
@celery_app.task(name="app.tasks.schedule_tasks.expire_overdue_schedule_items")
def expire_overdue_schedule_items() -> dict[str, int]:
with SessionLocal() as db:
expired_events = expire_overdue_events(db)
expired_todos = expire_overdue_todos(db)
logger.info(
"Expired schedule items: calendar_events=%s todos=%s",
expired_events,
expired_todos,
)
return {
"expired_calendar_events": expired_events,
"expired_todos": expired_todos,
}
@@ -0,0 +1,68 @@
from __future__ import annotations
import os
import unittest
from importlib.util import find_spec
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("MINIO_ENABLED", "false")
from api.app import models # noqa: F401
from api.app.core.celery_app import celery_app
from api.app.core.database import Base
from api.app.services.legacy_admin_rbac_service import PROTECTED_MENU_CODES
REMOVED_MODULES = (
"api.app.api.v1.jwt_generator",
"api.app.models.calendar_event",
"api.app.models.requirement",
"api.app.models.todo",
"api.app.schemas.calendar_event",
"api.app.schemas.jwt_generator",
"api.app.schemas.requirement",
"api.app.schemas.todo",
"api.app.services.calendar_event_service",
"api.app.services.jwt_generator_service",
"api.app.services.requirement_service",
"api.app.services.todo_service",
"api.app.tasks.schedule_tasks",
)
REMOVED_TABLES = {
"calendar_event",
"project_requirement",
"project_requirement_log",
"requirement_comments",
"todo",
}
class LegacyModuleCleanupContractTest(unittest.TestCase):
def test_removed_modules_are_not_importable(self) -> None:
for module_name in REMOVED_MODULES:
self.assertIsNone(find_spec(module_name), module_name)
def test_removed_tables_are_not_registered(self) -> None:
self.assertTrue(REMOVED_TABLES.isdisjoint(Base.metadata.tables))
def test_removed_schedule_task_is_not_registered(self) -> None:
include = set(celery_app.conf.include or [])
self.assertNotIn("app.tasks.schedule_tasks", include)
beat_schedule = celery_app.conf.beat_schedule or {}
self.assertNotIn("expire-overdue-schedule-items", beat_schedule)
scheduled_tasks = {
str(entry.get("task"))
for entry in beat_schedule.values()
if isinstance(entry, dict)
}
self.assertNotIn("app.tasks.schedule_tasks.expire_overdue_schedule_items", scheduled_tasks)
def test_removed_modules_are_not_protected_admin_menus(self) -> None:
self.assertNotIn("admin.todos", PROTECTED_MENU_CODES)
self.assertNotIn("admin.jwt_generator", PROTECTED_MENU_CODES)
if __name__ == "__main__":
unittest.main()