project.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  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.patch("/{project_id}/mark-completed", response_model=ProjectResponseExtended)
  431. async def mark_project_completed(request: Request, project_id: str):
  432. """
  433. Mark a project as completed.
  434. This endpoint is specifically for marking a project as completed when
  435. all tasks are done (100% completion rate). It validates that the project
  436. has 100% completion before allowing the status change.
  437. Args:
  438. request: FastAPI Request object (contains user info)
  439. project_id: Project unique identifier
  440. Returns:
  441. Updated project details with completed status
  442. Raises:
  443. HTTPException: 404 if project not found
  444. HTTPException: 400 if project is not 100% complete
  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. # Get project with task statistics
  457. cursor.execute("""
  458. SELECT
  459. p.id,
  460. p.name,
  461. p.description,
  462. p.config,
  463. p.task_type,
  464. p.status,
  465. p.source,
  466. p.external_id,
  467. p.created_at,
  468. p.updated_at,
  469. COUNT(t.id) as task_count,
  470. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  471. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  472. FROM projects p
  473. LEFT JOIN tasks t ON p.id = t.project_id
  474. WHERE p.id = ?
  475. GROUP BY p.id
  476. """, (project_id,))
  477. row = cursor.fetchone()
  478. if not row:
  479. raise HTTPException(
  480. status_code=status.HTTP_404_NOT_FOUND,
  481. detail=f"项目 '{project_id}' 不存在"
  482. )
  483. task_count = row["task_count"] or 0
  484. completed_task_count = row["completed_task_count"] or 0
  485. current_status = ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT
  486. # Check if project has tasks
  487. if task_count == 0:
  488. raise HTTPException(
  489. status_code=status.HTTP_400_BAD_REQUEST,
  490. detail="项目没有任务,无法标记为已完成"
  491. )
  492. # Check if all tasks are completed (100% completion rate)
  493. completion_rate = (completed_task_count / task_count) * 100
  494. if completion_rate < 100:
  495. raise HTTPException(
  496. status_code=status.HTTP_400_BAD_REQUEST,
  497. detail=f"项目未完成,当前完成率: {completion_rate:.1f}%。只有 100% 完成的项目才能标记为已完成"
  498. )
  499. # Check if project is in a valid state for completion
  500. if current_status == ProjectStatus.COMPLETED:
  501. raise HTTPException(
  502. status_code=status.HTTP_400_BAD_REQUEST,
  503. detail="项目已经是已完成状态"
  504. )
  505. if current_status not in [ProjectStatus.IN_PROGRESS, ProjectStatus.READY]:
  506. raise HTTPException(
  507. status_code=status.HTTP_400_BAD_REQUEST,
  508. detail=f"只有进行中或待分发状态的项目才能标记为已完成,当前状态: {current_status.value}"
  509. )
  510. # Update status to completed with completion timestamp
  511. completed_at = datetime.now()
  512. cursor.execute("""
  513. UPDATE projects
  514. SET status = ?, updated_at = ?
  515. WHERE id = ?
  516. """, (ProjectStatus.COMPLETED.value, completed_at, project_id))
  517. return ProjectResponseExtended(
  518. id=row["id"],
  519. name=row["name"],
  520. description=row["description"] or "",
  521. config=row["config"],
  522. task_type=row["task_type"],
  523. status=ProjectStatus.COMPLETED,
  524. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  525. external_id=row["external_id"],
  526. created_at=row["created_at"],
  527. updated_at=completed_at,
  528. task_count=task_count,
  529. completed_task_count=completed_task_count,
  530. assigned_task_count=row["assigned_task_count"] or 0,
  531. )
  532. @router.put("/{project_id}/config", response_model=ProjectResponseExtended)
  533. async def update_project_config(request: Request, project_id: str, config_update: ProjectConfigUpdate):
  534. """
  535. Update project configuration (XML config and labels).
  536. This endpoint is used by admins to configure the labeling interface
  537. for projects created by external systems.
  538. Args:
  539. request: FastAPI Request object (contains user info)
  540. project_id: Project unique identifier
  541. config_update: New configuration
  542. Returns:
  543. Updated project details
  544. Raises:
  545. HTTPException: 404 if project not found
  546. HTTPException: 400 if config is invalid
  547. HTTPException: 403 if user is not admin
  548. """
  549. # Check if user has admin role
  550. user = request.state.user
  551. if user["role"] != "admin":
  552. raise HTTPException(
  553. status_code=status.HTTP_403_FORBIDDEN,
  554. detail="只有管理员可以更新项目配置"
  555. )
  556. with get_db_connection() as conn:
  557. cursor = conn.cursor()
  558. # Check if project exists
  559. cursor.execute("SELECT id, status FROM projects WHERE id = ?", (project_id,))
  560. row = cursor.fetchone()
  561. if not row:
  562. raise HTTPException(
  563. status_code=status.HTTP_404_NOT_FOUND,
  564. detail=f"项目 '{project_id}' 不存在"
  565. )
  566. current_status = ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT
  567. # Only allow config updates in draft or configuring status
  568. if current_status not in [ProjectStatus.DRAFT, ProjectStatus.CONFIGURING]:
  569. raise HTTPException(
  570. status_code=status.HTTP_400_BAD_REQUEST,
  571. detail=f"只能在 draft 或 configuring 状态下更新配置,当前状态: {current_status.value}"
  572. )
  573. # Validate XML config (basic check)
  574. config = config_update.config.strip()
  575. if not config.startswith("<") or not config.endswith(">"):
  576. raise HTTPException(
  577. status_code=status.HTTP_400_BAD_REQUEST,
  578. detail="无效的XML配置格式"
  579. )
  580. # Update config and set status to configuring if it was draft
  581. new_status = ProjectStatus.CONFIGURING if current_status == ProjectStatus.DRAFT else current_status
  582. # Build update query based on provided fields
  583. if config_update.task_type:
  584. cursor.execute("""
  585. UPDATE projects
  586. SET config = ?, task_type = ?, status = ?, updated_at = ?
  587. WHERE id = ?
  588. """, (config, config_update.task_type, new_status.value, datetime.now(), project_id))
  589. else:
  590. cursor.execute("""
  591. UPDATE projects
  592. SET config = ?, status = ?, updated_at = ?
  593. WHERE id = ?
  594. """, (config, new_status.value, datetime.now(), project_id))
  595. # Fetch and return updated project
  596. cursor.execute("""
  597. SELECT
  598. p.id,
  599. p.name,
  600. p.description,
  601. p.config,
  602. p.task_type,
  603. p.status,
  604. p.source,
  605. p.external_id,
  606. p.created_at,
  607. p.updated_at,
  608. COUNT(t.id) as task_count,
  609. SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
  610. SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
  611. FROM projects p
  612. LEFT JOIN tasks t ON p.id = t.project_id
  613. WHERE p.id = ?
  614. GROUP BY p.id
  615. """, (project_id,))
  616. row = cursor.fetchone()
  617. return ProjectResponseExtended(
  618. id=row["id"],
  619. name=row["name"],
  620. description=row["description"] or "",
  621. config=row["config"],
  622. task_type=row["task_type"],
  623. status=ProjectStatus(row["status"]) if row["status"] else ProjectStatus.DRAFT,
  624. source=ProjectSource(row["source"]) if row["source"] else ProjectSource.INTERNAL,
  625. external_id=row["external_id"],
  626. created_at=row["created_at"],
  627. updated_at=row["updated_at"],
  628. task_count=row["task_count"] or 0,
  629. completed_task_count=row["completed_task_count"] or 0,
  630. assigned_task_count=row["assigned_task_count"] or 0,
  631. )