project.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. """
  2. Project API router.
  3. Provides CRUD endpoints for project management.
  4. """
  5. import uuid
  6. from datetime import datetime
  7. from typing import List, Optional
  8. from fastapi import APIRouter, HTTPException, status, Request, Query
  9. from database import get_db_connection
  10. from schemas.project import (
  11. ProjectCreate,
  12. ProjectUpdate,
  13. ProjectResponse,
  14. ProjectStatus,
  15. ProjectSource,
  16. ProjectStatusUpdate,
  17. ProjectConfigUpdate,
  18. ProjectResponseExtended,
  19. )
  20. from models import Project
  21. router = APIRouter(
  22. prefix="/api/projects",
  23. tags=["projects"]
  24. )
  25. # 定义合法的状态转换
  26. VALID_STATUS_TRANSITIONS = {
  27. ProjectStatus.DRAFT: [ProjectStatus.CONFIGURING],
  28. ProjectStatus.CONFIGURING: [ProjectStatus.READY, ProjectStatus.DRAFT],
  29. ProjectStatus.READY: [ProjectStatus.IN_PROGRESS, ProjectStatus.CONFIGURING],
  30. ProjectStatus.IN_PROGRESS: [ProjectStatus.COMPLETED, ProjectStatus.READY],
  31. ProjectStatus.COMPLETED: [ProjectStatus.IN_PROGRESS], # 允许重新开放
  32. }
  33. @router.get("", response_model=List[ProjectResponseExtended])
  34. async def list_projects(
  35. request: Request,
  36. status_filter: Optional[ProjectStatus] = Query(None, alias="status", description="按状态筛选"),
  37. source_filter: Optional[ProjectSource] = Query(None, alias="source", description="按来源筛选"),
  38. ):
  39. """
  40. List all projects with extended information.
  41. Returns a list of all projects with their task counts, status, and source.
  42. Query Parameters:
  43. status: Filter by project status (draft, configuring, ready, in_progress, completed)
  44. source: Filter by project source (internal, external)
  45. Requires authentication.
  46. """
  47. with get_db_connection() as conn:
  48. cursor = conn.cursor()
  49. # Build query with optional filters
  50. query = """
  51. SELECT
  52. p.id,
  53. p.name,
  54. p.description,
  55. p.config,
  56. p.task_type,
  57. p.status,
  58. p.source,
  59. p.external_id,
  60. p.created_at,
  61. p.updated_at,
  62. COUNT(t.id) as task_count,
  63. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  64. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  65. FROM projects p
  66. LEFT JOIN tasks t ON p.id = t.project_id
  67. """
  68. conditions = []
  69. params = []
  70. if status_filter:
  71. conditions.append("p.status = ?")
  72. params.append(status_filter.value)
  73. if source_filter:
  74. conditions.append("p.source = ?")
  75. params.append(source_filter.value)
  76. if conditions:
  77. query += " WHERE " + " AND ".join(conditions)
  78. query += " GROUP BY p.id ORDER BY p.created_at DESC"
  79. cursor.execute(query, params)
  80. rows = cursor.fetchall()
  81. projects = []
  82. for row in rows:
  83. projects.append(ProjectResponseExtended(
  84. id=row["id"],
  85. name=row["name"],
  86. description=row["description"] or "",
  87. config=row["config"],
  88. task_type=row["task_type"],
  89. status=ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT,
  90. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  91. external_id=row["external_id"],
  92. created_at=row["created_at"],
  93. updated_at=row["updated_at"],
  94. task_count=row["task_count"] or 0,
  95. completed_task_count=row["completed_task_count"] or 0,
  96. assigned_task_count=row["assigned_task_count"] or 0,
  97. ))
  98. return projects
  99. @router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
  100. async def create_project(request: Request, project: ProjectCreate):
  101. """
  102. Create a new project.
  103. Args:
  104. request: FastAPI Request object (contains user info)
  105. project: Project creation data
  106. Returns:
  107. Created project with generated ID
  108. Requires authentication.
  109. """
  110. # Generate unique ID
  111. project_id = f"proj_{uuid.uuid4().hex[:12]}"
  112. with get_db_connection() as conn:
  113. cursor = conn.cursor()
  114. # Insert new project
  115. cursor.execute("""
  116. INSERT INTO projects (id, name, description, config)
  117. VALUES (?, ?, ?, ?)
  118. """, (
  119. project_id,
  120. project.name,
  121. project.description,
  122. project.config
  123. ))
  124. # Fetch the created project
  125. cursor.execute("""
  126. SELECT id, name, description, config, created_at
  127. FROM projects
  128. WHERE id = ?
  129. """, (project_id,))
  130. row = cursor.fetchone()
  131. return ProjectResponse(
  132. id=row["id"],
  133. name=row["name"],
  134. description=row["description"] or "",
  135. config=row["config"],
  136. created_at=row["created_at"],
  137. task_count=0
  138. )
  139. @router.get("/{project_id}", response_model=ProjectResponse)
  140. async def get_project(request: Request, project_id: str):
  141. """
  142. Get project by ID.
  143. Args:
  144. request: FastAPI Request object (contains user info)
  145. project_id: Project unique identifier
  146. Returns:
  147. Project details with task count
  148. Raises:
  149. HTTPException: 404 if project not found
  150. Requires authentication.
  151. """
  152. with get_db_connection() as conn:
  153. cursor = conn.cursor()
  154. # Get project with task count
  155. cursor.execute("""
  156. SELECT
  157. p.id,
  158. p.name,
  159. p.description,
  160. p.config,
  161. p.created_at,
  162. COUNT(t.id) as task_count
  163. FROM projects p
  164. LEFT JOIN tasks t ON p.id = t.project_id
  165. WHERE p.id = ?
  166. GROUP BY p.id
  167. """, (project_id,))
  168. row = cursor.fetchone()
  169. if not row:
  170. raise HTTPException(
  171. status_code=status.HTTP_404_NOT_FOUND,
  172. detail=f"Project with id '{project_id}' not found"
  173. )
  174. return ProjectResponse(
  175. id=row["id"],
  176. name=row["name"],
  177. description=row["description"] or "",
  178. config=row["config"],
  179. created_at=row["created_at"],
  180. task_count=row["task_count"]
  181. )
  182. @router.put("/{project_id}", response_model=ProjectResponse)
  183. async def update_project(request: Request, project_id: str, project: ProjectUpdate):
  184. """
  185. Update an existing project.
  186. Args:
  187. request: FastAPI Request object (contains user info)
  188. project_id: Project unique identifier
  189. project: Project update data
  190. Returns:
  191. Updated project details
  192. Raises:
  193. HTTPException: 404 if project not found
  194. Requires authentication.
  195. """
  196. with get_db_connection() as conn:
  197. cursor = conn.cursor()
  198. # Check if project exists
  199. cursor.execute("SELECT id FROM projects WHERE id = ?", (project_id,))
  200. if not cursor.fetchone():
  201. raise HTTPException(
  202. status_code=status.HTTP_404_NOT_FOUND,
  203. detail=f"Project with id '{project_id}' not found"
  204. )
  205. # Build update query dynamically based on provided fields
  206. update_fields = []
  207. update_values = []
  208. if project.name is not None:
  209. update_fields.append("name = ?")
  210. update_values.append(project.name)
  211. if project.description is not None:
  212. update_fields.append("description = ?")
  213. update_values.append(project.description)
  214. if project.config is not None:
  215. update_fields.append("config = ?")
  216. update_values.append(project.config)
  217. if not update_fields:
  218. # No fields to update, just return current project
  219. cursor.execute("""
  220. SELECT
  221. p.id,
  222. p.name,
  223. p.description,
  224. p.config,
  225. p.created_at,
  226. COUNT(t.id) as task_count
  227. FROM projects p
  228. LEFT JOIN tasks t ON p.id = t.project_id
  229. WHERE p.id = ?
  230. GROUP BY p.id
  231. """, (project_id,))
  232. row = cursor.fetchone()
  233. return ProjectResponse(
  234. id=row["id"],
  235. name=row["name"],
  236. description=row["description"] or "",
  237. config=row["config"],
  238. created_at=row["created_at"],
  239. task_count=row["task_count"]
  240. )
  241. # Execute update
  242. update_values.append(project_id)
  243. cursor.execute(f"""
  244. UPDATE projects
  245. SET {', '.join(update_fields)}
  246. WHERE id = ?
  247. """, update_values)
  248. # Fetch and return updated project
  249. cursor.execute("""
  250. SELECT
  251. p.id,
  252. p.name,
  253. p.description,
  254. p.config,
  255. p.created_at,
  256. COUNT(t.id) as task_count
  257. FROM projects p
  258. LEFT JOIN tasks t ON p.id = t.project_id
  259. WHERE p.id = ?
  260. GROUP BY p.id
  261. """, (project_id,))
  262. row = cursor.fetchone()
  263. return ProjectResponse(
  264. id=row["id"],
  265. name=row["name"],
  266. description=row["description"] or "",
  267. config=row["config"],
  268. created_at=row["created_at"],
  269. task_count=row["task_count"]
  270. )
  271. @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
  272. async def delete_project(request: Request, project_id: str):
  273. """
  274. Delete a project and all associated tasks.
  275. Args:
  276. request: FastAPI Request object (contains user info)
  277. project_id: Project unique identifier
  278. Raises:
  279. HTTPException: 404 if project not found
  280. HTTPException: 403 if user is not admin
  281. Requires authentication and admin role.
  282. """
  283. # Check if user has admin role
  284. user = request.state.user
  285. if user["role"] != "admin":
  286. raise HTTPException(
  287. status_code=status.HTTP_403_FORBIDDEN,
  288. detail="只有管理员可以删除项目"
  289. )
  290. with get_db_connection() as conn:
  291. cursor = conn.cursor()
  292. # Check if project exists
  293. cursor.execute("SELECT id FROM projects WHERE id = ?", (project_id,))
  294. if not cursor.fetchone():
  295. raise HTTPException(
  296. status_code=status.HTTP_404_NOT_FOUND,
  297. detail=f"Project with id '{project_id}' not found"
  298. )
  299. # Delete project (cascade will delete tasks and annotations)
  300. cursor.execute("DELETE FROM projects WHERE id = ?", (project_id,))
  301. return None
  302. @router.put("/{project_id}/status", response_model=ProjectResponseExtended)
  303. async def update_project_status(request: Request, project_id: str, status_update: ProjectStatusUpdate):
  304. """
  305. Update project status with validation.
  306. Only allows valid status transitions:
  307. - draft → configuring
  308. - configuring → ready, draft
  309. - ready → in_progress, configuring
  310. - in_progress → completed, ready
  311. - completed → in_progress (reopen)
  312. Args:
  313. request: FastAPI Request object (contains user info)
  314. project_id: Project unique identifier
  315. status_update: New status
  316. Returns:
  317. Updated project details
  318. Raises:
  319. HTTPException: 404 if project not found
  320. HTTPException: 400 if status transition is invalid
  321. HTTPException: 403 if user is not admin
  322. """
  323. # Check if user has admin role
  324. user = request.state.user
  325. if user["role"] != "admin":
  326. raise HTTPException(
  327. status_code=status.HTTP_403_FORBIDDEN,
  328. detail="只有管理员可以更新项目状态"
  329. )
  330. with get_db_connection() as conn:
  331. cursor = conn.cursor()
  332. # Get current project status
  333. cursor.execute("SELECT id, status FROM projects WHERE id = ?", (project_id,))
  334. row = cursor.fetchone()
  335. if not row:
  336. raise HTTPException(
  337. status_code=status.HTTP_404_NOT_FOUND,
  338. detail=f"项目 '{project_id}' 不存在"
  339. )
  340. current_status = ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT
  341. new_status = status_update.status
  342. # Validate status transition
  343. valid_transitions = VALID_STATUS_TRANSITIONS.get(current_status, [])
  344. if new_status not in valid_transitions:
  345. raise HTTPException(
  346. status_code=status.HTTP_400_BAD_REQUEST,
  347. detail=f"无效的状态转换: {current_status.value} → {new_status.value}。允许的转换: {[s.value for s in valid_transitions]}"
  348. )
  349. # Update status
  350. cursor.execute("""
  351. UPDATE projects
  352. SET status = ?, updated_at = ?
  353. WHERE id = ?
  354. """, (new_status.value, datetime.now(), project_id))
  355. # Fetch and return updated project
  356. cursor.execute("""
  357. SELECT
  358. p.id,
  359. p.name,
  360. p.description,
  361. p.config,
  362. p.task_type,
  363. p.status,
  364. p.source,
  365. p.external_id,
  366. p.created_at,
  367. p.updated_at,
  368. COUNT(t.id) as task_count,
  369. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  370. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  371. FROM projects p
  372. LEFT JOIN tasks t ON p.id = t.project_id
  373. WHERE p.id = ?
  374. GROUP BY p.id
  375. """, (project_id,))
  376. row = cursor.fetchone()
  377. return ProjectResponseExtended(
  378. id=row["id"],
  379. name=row["name"],
  380. description=row["description"] or "",
  381. config=row["config"],
  382. task_type=row["task_type"],
  383. status=ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT,
  384. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  385. external_id=row["external_id"],
  386. created_at=row["created_at"],
  387. updated_at=row["updated_at"],
  388. task_count=row["task_count"] or 0,
  389. completed_task_count=row["completed_task_count"] or 0,
  390. assigned_task_count=row["assigned_task_count"] or 0,
  391. )
  392. @router.put("/{project_id}/config", response_model=ProjectResponseExtended)
  393. async def update_project_config(request: Request, project_id: str, config_update: ProjectConfigUpdate):
  394. """
  395. Update project configuration (XML config and labels).
  396. This endpoint is used by admins to configure the labeling interface
  397. for projects created by external systems.
  398. Args:
  399. request: FastAPI Request object (contains user info)
  400. project_id: Project unique identifier
  401. config_update: New configuration
  402. Returns:
  403. Updated project details
  404. Raises:
  405. HTTPException: 404 if project not found
  406. HTTPException: 400 if config is invalid
  407. HTTPException: 403 if user is not admin
  408. """
  409. # Check if user has admin role
  410. user = request.state.user
  411. if user["role"] != "admin":
  412. raise HTTPException(
  413. status_code=status.HTTP_403_FORBIDDEN,
  414. detail="只有管理员可以更新项目配置"
  415. )
  416. with get_db_connection() as conn:
  417. cursor = conn.cursor()
  418. # Check if project exists
  419. cursor.execute("SELECT id, status FROM projects WHERE id = ?", (project_id,))
  420. row = cursor.fetchone()
  421. if not row:
  422. raise HTTPException(
  423. status_code=status.HTTP_404_NOT_FOUND,
  424. detail=f"项目 '{project_id}' 不存在"
  425. )
  426. current_status = ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT
  427. # Only allow config updates in draft or configuring status
  428. if current_status not in [ProjectStatus.DRAFT, ProjectStatus.CONFIGURING]:
  429. raise HTTPException(
  430. status_code=status.HTTP_400_BAD_REQUEST,
  431. detail=f"只能在 draft 或 configuring 状态下更新配置,当前状态: {current_status.value}"
  432. )
  433. # Validate XML config (basic check)
  434. config = config_update.config.strip()
  435. if not config.startswith("<") or not config.endswith(">"):
  436. raise HTTPException(
  437. status_code=status.HTTP_400_BAD_REQUEST,
  438. detail="无效的XML配置格式"
  439. )
  440. # Update config and set status to configuring if it was draft
  441. new_status = ProjectStatus.CONFIGURING if current_status == ProjectStatus.DRAFT else current_status
  442. cursor.execute("""
  443. UPDATE projects
  444. SET config = ?, status = ?, updated_at = ?
  445. WHERE id = ?
  446. """, (config, new_status.value, datetime.now(), project_id))
  447. # Fetch and return updated project
  448. cursor.execute("""
  449. SELECT
  450. p.id,
  451. p.name,
  452. p.description,
  453. p.config,
  454. p.task_type,
  455. p.status,
  456. p.source,
  457. p.external_id,
  458. p.created_at,
  459. p.updated_at,
  460. COUNT(t.id) as task_count,
  461. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  462. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  463. FROM projects p
  464. LEFT JOIN tasks t ON p.id = t.project_id
  465. WHERE p.id = ?
  466. GROUP BY p.id
  467. """, (project_id,))
  468. row = cursor.fetchone()
  469. return ProjectResponseExtended(
  470. id=row["id"],
  471. name=row["name"],
  472. description=row["description"] or "",
  473. config=row["config"],
  474. task_type=row["task_type"],
  475. status=ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT,
  476. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  477. external_id=row["external_id"],
  478. created_at=row["created_at"],
  479. updated_at=row["updated_at"],
  480. task_count=row["task_count"] or 0,
  481. completed_task_count=row["completed_task_count"] or 0,
  482. assigned_task_count=row["assigned_task_count"] or 0,
  483. )