project.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820
  1. """
  2. Project API router.
  3. Provides CRUD endpoints for project management.
  4. """
  5. from logging import Logger
  6. import logging
  7. import uuid
  8. from datetime import datetime
  9. from typing import List, Optional
  10. from fastapi import APIRouter, HTTPException, status, Request, Query
  11. from database import get_db_connection
  12. from schemas.project import (
  13. ProjectCreate,
  14. ProjectUpdate,
  15. ProjectResponse,
  16. ProjectStatus,
  17. ProjectSource,
  18. ProjectStatusUpdate,
  19. ProjectConfigUpdate,
  20. ProjectResponseExtended,
  21. ProjectListPaginationResponse,
  22. )
  23. from models import Project
  24. router = APIRouter(
  25. prefix="/api/projects",
  26. tags=["projects"]
  27. )
  28. logger: Logger = logging.getLogger(__name__)
  29. # 定义合法的状态转换
  30. VALID_STATUS_TRANSITIONS = {
  31. ProjectStatus.DRAFT: [ProjectStatus.CONFIGURING],
  32. ProjectStatus.CONFIGURING: [ProjectStatus.READY, ProjectStatus.DRAFT],
  33. ProjectStatus.READY: [ProjectStatus.IN_PROGRESS, ProjectStatus.CONFIGURING],
  34. ProjectStatus.IN_PROGRESS: [ProjectStatus.COMPLETED, ProjectStatus.READY],
  35. ProjectStatus.COMPLETED: [ProjectStatus.IN_PROGRESS], # 允许重新开放
  36. }
  37. @router.get("", response_model=ProjectListPaginationResponse)
  38. async def list_projects(
  39. request: Request,
  40. status_filter: Optional[ProjectStatus] = Query(None, alias="status", description="按状态筛选"),
  41. source_filter: Optional[ProjectSource] = Query(None, alias="source", description="按来源筛选"),
  42. page: int = Query(1, ge=1, description="页码"),
  43. page_size: int = Query(20, ge=1, le=100, description="每页数量"),
  44. ):
  45. """
  46. List projects with extended information.
  47. For admin users: Returns all projects with their total task counts.
  48. For annotator users: Returns only projects that have tasks assigned to them,
  49. with task counts reflecting only their assigned tasks.
  50. Query Parameters:
  51. status: Filter by project status (draft, configuring, ready, in_progress, completed)
  52. source: Filter by project source (internal, external)
  53. page: Page number (default: 1)
  54. page_size: Number of items per page (default: 20, max: 100)
  55. Requires authentication.
  56. """
  57. user = request.state.user
  58. user_id = user["id"]
  59. user_role = user["role"]
  60. with get_db_connection() as conn:
  61. cursor = conn.cursor()
  62. # Build base query
  63. base_from = """
  64. FROM projects p
  65. LEFT JOIN tasks t ON p.id = t.project_id
  66. """
  67. if user_role == "admin":
  68. # 管理员:返回所有项目及其全部任务统计
  69. select_cols = """
  70. p.id,
  71. p.name,
  72. p.description,
  73. p.config,
  74. p.task_type,
  75. p.status,
  76. p.source,
  77. p.external_id,
  78. p.created_at,
  79. p.updated_at,
  80. COUNT(t.id) as task_count,
  81. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  82. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  83. """
  84. conditions = []
  85. params = []
  86. if status_filter:
  87. conditions.append("p.status = ?")
  88. params.append(status_filter.value)
  89. if source_filter:
  90. conditions.append("p.source = ?")
  91. params.append(source_filter.value)
  92. where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
  93. # Count query for pagination
  94. count_query = f"""
  95. SELECT COUNT(DISTINCT p.id) as total
  96. {base_from}
  97. {where_clause}
  98. """
  99. cursor.execute(count_query, params)
  100. total = cursor.fetchone()["total"]
  101. # Calculate pagination
  102. total_pages = (total + page_size - 1) // page_size
  103. offset = (page - 1) * page_size
  104. # Main query with pagination
  105. query = f"""
  106. SELECT {select_cols}
  107. {base_from}
  108. {where_clause}
  109. GROUP BY p.id
  110. ORDER BY p.created_at DESC
  111. LIMIT ? OFFSET ?
  112. """
  113. params.extend([page_size, offset])
  114. else:
  115. # 标注员:只返回有分配给他们任务的项目,任务数量只统计分配给他们的任务
  116. select_cols = """
  117. p.id,
  118. p.name,
  119. p.description,
  120. p.config,
  121. p.task_type,
  122. p.status,
  123. p.source,
  124. p.external_id,
  125. p.created_at,
  126. p.updated_at,
  127. COUNT(t.id) as task_count,
  128. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  129. COUNT(t.id) as assigned_task_count
  130. """
  131. conditions = ["t.assigned_to = ?"]
  132. params = [user_id]
  133. if status_filter:
  134. conditions.append("p.status = ?")
  135. params.append(status_filter.value)
  136. if source_filter:
  137. conditions.append("p.source = ?")
  138. params.append(source_filter.value)
  139. # 为标注员构建 where 子句(用于主查询)
  140. # 从 conditions 中移除 t.assigned_to = ?,因为它已经在 JOIN 中了
  141. main_query_conditions = [cond for cond in conditions if cond != "t.assigned_to = ?"]
  142. where_clause = " WHERE " + " AND ".join(main_query_conditions) if main_query_conditions else ""
  143. # 为 count 查询构建 where 子句(移除 t.assigned_to = ? 条件)
  144. count_where_clause = where_clause
  145. # Count query for pagination
  146. count_query = f"""
  147. SELECT COUNT(DISTINCT p.id) as total
  148. FROM projects p
  149. INNER JOIN tasks t ON p.id = t.project_id AND t.assigned_to = ?
  150. {count_where_clause}
  151. """
  152. count_params = [user_id]
  153. if status_filter:
  154. count_params.append(status_filter.value)
  155. if source_filter:
  156. count_params.append(source_filter.value)
  157. cursor.execute(count_query, count_params)
  158. total = cursor.fetchone()["total"]
  159. # Calculate pagination
  160. total_pages = (total + page_size - 1) // page_size
  161. offset = (page - 1) * page_size
  162. # Main query with pagination
  163. query = f"""
  164. SELECT {select_cols}
  165. FROM projects p
  166. INNER JOIN tasks t ON p.id = t.project_id AND t.assigned_to = ?
  167. {where_clause}
  168. GROUP BY p.id
  169. HAVING COUNT(t.id) > 0
  170. ORDER BY p.created_at DESC
  171. LIMIT ? OFFSET ?
  172. """
  173. params.extend([page_size, offset])
  174. cursor.execute(query, params)
  175. rows = cursor.fetchall()
  176. projects = []
  177. for row in rows:
  178. projects.append(ProjectResponseExtended(
  179. id=row["id"],
  180. name=row["name"],
  181. description=row["description"] or "",
  182. config=row["config"],
  183. task_type=row["task_type"],
  184. status=ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT,
  185. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  186. external_id=row["external_id"],
  187. created_at=row["created_at"],
  188. updated_at=row["updated_at"],
  189. task_count=row["task_count"] or 0,
  190. completed_task_count=row["completed_task_count"] or 0,
  191. assigned_task_count=row["assigned_task_count"] or 0,
  192. ))
  193. return ProjectListPaginationResponse(
  194. projects=projects,
  195. total=total,
  196. page=page,
  197. page_size=page_size,
  198. total_pages=total_pages,
  199. has_next=page < total_pages,
  200. has_prev=page > 1
  201. )
  202. @router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
  203. async def create_project(request: Request, project: ProjectCreate):
  204. """
  205. Create a new project.
  206. Args:
  207. request: FastAPI Request object (contains user info)
  208. project: Project creation data
  209. Returns:
  210. Created project with generated ID
  211. Requires authentication.
  212. """
  213. # Generate unique ID
  214. project_id = f"proj_{uuid.uuid4().hex[:12]}"
  215. with get_db_connection() as conn:
  216. cursor = conn.cursor()
  217. # Insert new project with task_type
  218. cursor.execute("""
  219. INSERT INTO projects (id, name, description, config, task_type)
  220. VALUES (?, ?, ?, ?, ?)
  221. """, (
  222. project_id,
  223. project.name,
  224. project.description,
  225. project.config,
  226. project.task_type
  227. ))
  228. # Fetch the created project
  229. cursor.execute("""
  230. SELECT id, name, description, config, created_at
  231. FROM projects
  232. WHERE id = ?
  233. """, (project_id,))
  234. row = cursor.fetchone()
  235. return ProjectResponse(
  236. id=row["id"],
  237. name=row["name"],
  238. description=row["description"] or "",
  239. config=row["config"],
  240. created_at=row["created_at"],
  241. task_count=0
  242. )
  243. @router.get("/{project_id}", response_model=ProjectResponse)
  244. async def get_project(request: Request, project_id: str):
  245. """
  246. Get project by ID.
  247. Args:
  248. request: FastAPI Request object (contains user info)
  249. project_id: Project unique identifier
  250. Returns:
  251. Project details with task count
  252. Raises:
  253. HTTPException: 404 if project not found
  254. Requires authentication.
  255. """
  256. with get_db_connection() as conn:
  257. cursor = conn.cursor()
  258. # Get project with task count
  259. cursor.execute("""
  260. SELECT
  261. p.id,
  262. p.name,
  263. p.description,
  264. p.config,
  265. p.created_at,
  266. COUNT(t.id) as task_count
  267. FROM projects p
  268. LEFT JOIN tasks t ON p.id = t.project_id
  269. WHERE p.id = ?
  270. GROUP BY p.id
  271. """, (project_id,))
  272. row = cursor.fetchone()
  273. if not row:
  274. raise HTTPException(
  275. status_code=status.HTTP_404_NOT_FOUND,
  276. detail=f"Project with id '{project_id}' not found"
  277. )
  278. return ProjectResponse(
  279. id=row["id"],
  280. name=row["name"],
  281. description=row["description"] or "",
  282. config=row["config"],
  283. created_at=row["created_at"],
  284. task_count=row["task_count"]
  285. )
  286. @router.put("/{project_id}", response_model=ProjectResponse)
  287. async def update_project(request: Request, project_id: str, project: ProjectUpdate):
  288. """
  289. Update an existing project.
  290. Args:
  291. request: FastAPI Request object (contains user info)
  292. project_id: Project unique identifier
  293. project: Project update data
  294. Returns:
  295. Updated project details
  296. Raises:
  297. HTTPException: 404 if project not found
  298. Requires authentication.
  299. """
  300. with get_db_connection() as conn:
  301. cursor = conn.cursor()
  302. # Check if project exists
  303. cursor.execute("SELECT id FROM projects WHERE id = ?", (project_id,))
  304. if not cursor.fetchone():
  305. raise HTTPException(
  306. status_code=status.HTTP_404_NOT_FOUND,
  307. detail=f"Project with id '{project_id}' not found"
  308. )
  309. # Build update query dynamically based on provided fields
  310. update_fields = []
  311. update_values = []
  312. if project.name is not None:
  313. update_fields.append("name = ?")
  314. update_values.append(project.name)
  315. if project.description is not None:
  316. update_fields.append("description = ?")
  317. update_values.append(project.description)
  318. if project.config is not None:
  319. update_fields.append("config = ?")
  320. update_values.append(project.config)
  321. if not update_fields:
  322. # No fields to update, just return current project
  323. cursor.execute("""
  324. SELECT
  325. p.id,
  326. p.name,
  327. p.description,
  328. p.config,
  329. p.created_at,
  330. COUNT(t.id) as task_count
  331. FROM projects p
  332. LEFT JOIN tasks t ON p.id = t.project_id
  333. WHERE p.id = ?
  334. GROUP BY p.id
  335. """, (project_id,))
  336. row = cursor.fetchone()
  337. return ProjectResponse(
  338. id=row["id"],
  339. name=row["name"],
  340. description=row["description"] or "",
  341. config=row["config"],
  342. created_at=row["created_at"],
  343. task_count=row["task_count"]
  344. )
  345. # Execute update
  346. update_values.append(project_id)
  347. cursor.execute(f"""
  348. UPDATE projects
  349. SET {', '.join(update_fields)}
  350. WHERE id = ?
  351. """, update_values)
  352. # Fetch and return updated project
  353. cursor.execute("""
  354. SELECT
  355. p.id,
  356. p.name,
  357. p.description,
  358. p.config,
  359. p.created_at,
  360. COUNT(t.id) as task_count
  361. FROM projects p
  362. LEFT JOIN tasks t ON p.id = t.project_id
  363. WHERE p.id = ?
  364. GROUP BY p.id
  365. """, (project_id,))
  366. row = cursor.fetchone()
  367. return ProjectResponse(
  368. id=row["id"],
  369. name=row["name"],
  370. description=row["description"] or "",
  371. config=row["config"],
  372. created_at=row["created_at"],
  373. task_count=row["task_count"]
  374. )
  375. @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
  376. async def delete_project(request: Request, project_id: str):
  377. """
  378. Delete a project and all associated tasks.
  379. Args:
  380. request: FastAPI Request object (contains user info)
  381. project_id: Project unique identifier
  382. Raises:
  383. HTTPException: 404 if project not found
  384. HTTPException: 403 if user is not admin
  385. Requires authentication and admin role.
  386. """
  387. # Check if user has admin role
  388. user = request.state.user
  389. if user["role"] != "admin":
  390. raise HTTPException(
  391. status_code=status.HTTP_403_FORBIDDEN,
  392. detail="只有管理员可以删除项目"
  393. )
  394. with get_db_connection() as conn:
  395. cursor = conn.cursor()
  396. # Check if project exists
  397. cursor.execute("SELECT id FROM projects WHERE id = ?", (project_id,))
  398. if not cursor.fetchone():
  399. raise HTTPException(
  400. status_code=status.HTTP_404_NOT_FOUND,
  401. detail=f"Project with id '{project_id}' not found"
  402. )
  403. # Delete project (cascade will delete tasks and annotations)
  404. cursor.execute("DELETE FROM projects WHERE id = ?", (project_id,))
  405. return None
  406. @router.put("/{project_id}/status", response_model=ProjectResponseExtended)
  407. async def update_project_status(request: Request, project_id: str, status_update: ProjectStatusUpdate):
  408. """
  409. Update project status with validation.
  410. Only allows valid status transitions:
  411. - draft → configuring
  412. - configuring → ready, draft
  413. - ready → in_progress, configuring
  414. - in_progress → completed, ready
  415. - completed → in_progress (reopen)
  416. Args:
  417. request: FastAPI Request object (contains user info)
  418. project_id: Project unique identifier
  419. status_update: New status
  420. Returns:
  421. Updated project details
  422. Raises:
  423. HTTPException: 404 if project not found
  424. HTTPException: 400 if status transition is invalid
  425. HTTPException: 403 if user is not admin
  426. """
  427. # Check if user has admin role
  428. user = request.state.user
  429. if user["role"] != "admin":
  430. raise HTTPException(
  431. status_code=status.HTTP_403_FORBIDDEN,
  432. detail="只有管理员可以更新项目状态"
  433. )
  434. with get_db_connection() as conn:
  435. cursor = conn.cursor()
  436. # Get current project status
  437. cursor.execute("SELECT id, status FROM projects WHERE id = ?", (project_id,))
  438. row = cursor.fetchone()
  439. if not row:
  440. raise HTTPException(
  441. status_code=status.HTTP_404_NOT_FOUND,
  442. detail=f"项目 '{project_id}' 不存在"
  443. )
  444. current_status = ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT
  445. new_status = status_update.status
  446. # Validate status transition
  447. valid_transitions = VALID_STATUS_TRANSITIONS.get(current_status, [])
  448. if new_status not in valid_transitions:
  449. raise HTTPException(
  450. status_code=status.HTTP_400_BAD_REQUEST,
  451. detail=f"无效的状态转换: {current_status.value} → {new_status.value}。允许的转换: {[s.value for s in valid_transitions]}"
  452. )
  453. # Update status
  454. cursor.execute("""
  455. UPDATE projects
  456. SET status = ?, updated_at = ?
  457. WHERE id = ?
  458. """, (new_status.value, datetime.now(), project_id))
  459. # Fetch and return updated project
  460. cursor.execute("""
  461. SELECT
  462. p.id,
  463. p.name,
  464. p.description,
  465. p.config,
  466. p.task_type,
  467. p.status,
  468. p.source,
  469. p.external_id,
  470. p.created_at,
  471. p.updated_at,
  472. COUNT(t.id) as task_count,
  473. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  474. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  475. FROM projects p
  476. LEFT JOIN tasks t ON p.id = t.project_id
  477. WHERE p.id = ?
  478. GROUP BY p.id
  479. """, (project_id,))
  480. row = cursor.fetchone()
  481. return ProjectResponseExtended(
  482. id=row["id"],
  483. name=row["name"],
  484. description=row["description"] or "",
  485. config=row["config"],
  486. task_type=row["task_type"],
  487. status=ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT,
  488. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  489. external_id=row["external_id"],
  490. created_at=row["created_at"],
  491. updated_at=row["updated_at"],
  492. task_count=row["task_count"] or 0,
  493. completed_task_count=row["completed_task_count"] or 0,
  494. assigned_task_count=row["assigned_task_count"] or 0,
  495. )
  496. @router.patch("/{project_id}/mark-completed", response_model=ProjectResponseExtended)
  497. async def mark_project_completed(request: Request, project_id: str):
  498. """
  499. Mark a project as completed.
  500. This endpoint is specifically for marking a project as completed when
  501. all tasks are done (100% completion rate). It validates that the project
  502. has 100% completion before allowing the status change.
  503. Args:
  504. request: FastAPI Request object (contains user info)
  505. project_id: Project unique identifier
  506. Returns:
  507. Updated project details with completed status
  508. Raises:
  509. HTTPException: 404 if project not found
  510. HTTPException: 400 if project is not 100% complete
  511. HTTPException: 403 if user is not admin
  512. """
  513. # Check if user has admin role
  514. user = request.state.user
  515. if user["role"] != "admin":
  516. raise HTTPException(
  517. status_code=status.HTTP_403_FORBIDDEN,
  518. detail="只有管理员可以标记项目为已完成"
  519. )
  520. with get_db_connection() as conn:
  521. cursor = conn.cursor()
  522. # Get project with task statistics
  523. cursor.execute("""
  524. SELECT
  525. p.id,
  526. p.name,
  527. p.description,
  528. p.config,
  529. p.task_type,
  530. p.status,
  531. p.source,
  532. p.external_id,
  533. p.created_at,
  534. p.updated_at,
  535. COUNT(t.id) as task_count,
  536. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  537. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  538. FROM projects p
  539. LEFT JOIN tasks t ON p.id = t.project_id
  540. WHERE p.id = ?
  541. GROUP BY p.id
  542. """, (project_id,))
  543. row = cursor.fetchone()
  544. if not row:
  545. raise HTTPException(
  546. status_code=status.HTTP_404_NOT_FOUND,
  547. detail=f"项目 '{project_id}' 不存在"
  548. )
  549. task_count = row["task_count"] or 0
  550. completed_task_count = row["completed_task_count"] or 0
  551. current_status = ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT
  552. # Check if project has tasks
  553. if task_count == 0:
  554. raise HTTPException(
  555. status_code=status.HTTP_400_BAD_REQUEST,
  556. detail="项目没有任务,无法标记为已完成"
  557. )
  558. # Check if all tasks are completed (100% completion rate)
  559. completion_rate = (completed_task_count / task_count) * 100
  560. if completion_rate < 100:
  561. raise HTTPException(
  562. status_code=status.HTTP_400_BAD_REQUEST,
  563. detail=f"项目未完成,当前完成率: {completion_rate:.1f}%。只有 100% 完成的项目才能标记为已完成"
  564. )
  565. # Check if project is in a valid state for completion
  566. if current_status == ProjectStatus.COMPLETED:
  567. raise HTTPException(
  568. status_code=status.HTTP_400_BAD_REQUEST,
  569. detail="项目已经是已完成状态"
  570. )
  571. if current_status not in [ProjectStatus.IN_PROGRESS, ProjectStatus.READY]:
  572. raise HTTPException(
  573. status_code=status.HTTP_400_BAD_REQUEST,
  574. detail=f"只有进行中或待分发状态的项目才能标记为已完成,当前状态: {current_status.value}"
  575. )
  576. # Update status to completed with completion timestamp
  577. completed_at = datetime.now()
  578. cursor.execute("""
  579. UPDATE projects
  580. SET status = ?, updated_at = ?
  581. WHERE id = ?
  582. """, (ProjectStatus.COMPLETED.value, completed_at, project_id))
  583. return ProjectResponseExtended(
  584. id=row["id"],
  585. name=row["name"],
  586. description=row["description"] or "",
  587. config=row["config"],
  588. task_type=row["task_type"],
  589. status=ProjectStatus.COMPLETED,
  590. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  591. external_id=row["external_id"],
  592. created_at=row["created_at"],
  593. updated_at=completed_at,
  594. task_count=task_count,
  595. completed_task_count=completed_task_count,
  596. assigned_task_count=row["assigned_task_count"] or 0,
  597. )
  598. @router.put("/{project_id}/config", response_model=ProjectResponseExtended)
  599. async def update_project_config(request: Request, project_id: str, config_update: ProjectConfigUpdate):
  600. """
  601. Update project configuration (XML config and labels).
  602. This endpoint is used by admins to configure the labeling interface
  603. for projects created by external systems.
  604. Args:
  605. request: FastAPI Request object (contains user info)
  606. project_id: Project unique identifier
  607. config_update: New configuration
  608. Returns:
  609. Updated project details
  610. Raises:
  611. HTTPException: 404 if project not found
  612. HTTPException: 400 if config is invalid
  613. HTTPException: 403 if user is not admin
  614. """
  615. # Check if user has admin role
  616. user = request.state.user
  617. if user["role"] != "admin":
  618. raise HTTPException(
  619. status_code=status.HTTP_403_FORBIDDEN,
  620. detail="只有管理员可以更新项目配置"
  621. )
  622. with get_db_connection() as conn:
  623. cursor = conn.cursor()
  624. # Check if project exists
  625. cursor.execute("SELECT id, status FROM projects WHERE id = ?", (project_id,))
  626. row = cursor.fetchone()
  627. if not row:
  628. raise HTTPException(
  629. status_code=status.HTTP_404_NOT_FOUND,
  630. detail=f"项目 '{project_id}' 不存在"
  631. )
  632. current_status = ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT
  633. # Only allow config updates in draft or configuring status
  634. if current_status not in [ProjectStatus.DRAFT, ProjectStatus.CONFIGURING]:
  635. raise HTTPException(
  636. status_code=status.HTTP_400_BAD_REQUEST,
  637. detail=f"只能在 draft 或 configuring 状态下更新配置,当前状态: {current_status.value}"
  638. )
  639. # Validate XML config (basic check)
  640. config = config_update.config.strip()
  641. if not config.startswith("<") or not config.endswith(">"):
  642. raise HTTPException(
  643. status_code=status.HTTP_400_BAD_REQUEST,
  644. detail="无效的XML配置格式"
  645. )
  646. # Update config and set status to configuring if it was draft
  647. new_status = ProjectStatus.CONFIGURING if current_status == ProjectStatus.DRAFT else current_status
  648. # Build update query based on provided fields
  649. if config_update.task_type:
  650. cursor.execute("""
  651. UPDATE projects
  652. SET config = ?, task_type = ?, status = ?, updated_at = ?
  653. WHERE id = ?
  654. """, (config, config_update.task_type, new_status.value, datetime.now(), project_id))
  655. else:
  656. cursor.execute("""
  657. UPDATE projects
  658. SET config = ?, status = ?, updated_at = ?
  659. WHERE id = ?
  660. """, (config, new_status.value, datetime.now(), project_id))
  661. # Fetch and return updated project
  662. cursor.execute("""
  663. SELECT
  664. p.id,
  665. p.name,
  666. p.description,
  667. p.config,
  668. p.task_type,
  669. p.status,
  670. p.source,
  671. p.external_id,
  672. p.created_at,
  673. p.updated_at,
  674. COUNT(t.id) as task_count,
  675. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  676. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  677. FROM projects p
  678. LEFT JOIN tasks t ON p.id = t.project_id
  679. WHERE p.id = ?
  680. GROUP BY p.id
  681. """, (project_id,))
  682. row = cursor.fetchone()
  683. return ProjectResponseExtended(
  684. id=row["id"],
  685. name=row["name"],
  686. description=row["description"] or "",
  687. config=row["config"],
  688. task_type=row["task_type"],
  689. status=ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT,
  690. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  691. external_id=row["external_id"],
  692. created_at=row["created_at"],
  693. updated_at=row["updated_at"],
  694. task_count=row["task_count"] or 0,
  695. completed_task_count=row["completed_task_count"] or 0,
  696. assigned_task_count=row["assigned_task_count"] or 0,
  697. )