[fix]:[FL-43][清理遗留需求协同待办日程JWT生成器模块]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user