project.py 21 KB

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