project.py 26 KB

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