Coverage for modules\planner\api.py: 36%
78 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-05 09:25 +0800
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-05 09:25 +0800
1from ninja import Router
2from typing import List, Optional
3from uuid import UUID
4from core.auth import JWTAuth
5from core.permissions import require_tenant
6from core.exceptions import NotFoundException
7from .schemas import (
8 ProjectIn, ProjectOut,
9 BoardOut, BoardColumnOut,
10 TaskIn, TaskMoveIn, TaskUpdateIn, TaskOut,
11 TaskCommentIn, TaskCommentOut,
12 TimeLogIn, TimeLogOut
13)
14from .models import Project, Board, BoardColumn, Task, TaskComment, TimeLog
15from .services import PlannerService
17router = Router(tags=["Planner & Projects"])
19# ── Projects ──────────────────────────────────────────────────────────────────
20@router.get("/projects", auth=JWTAuth(), response={200: List[ProjectOut]})
21def list_projects(request):
22 tenant_id = require_tenant(request)
23 projects = PlannerService.visible_projects(tenant_id, request.user).select_related('lead', 'department')
24 result = []
25 for p in projects:
26 total_tasks = p.tasks.count()
27 completed_tasks = p.tasks.filter(status='DONE').count()
28 result.append(ProjectOut(
29 id=p.id,
30 name=p.name,
31 key=p.key,
32 description=p.description,
33 color=p.color,
34 status=p.status,
35 lead_id=p.lead.id if p.lead else None,
36 lead_name=p.lead.full_name if p.lead else None,
37 tasks_count=total_tasks,
38 completed_tasks_count=completed_tasks,
39 created_at=p.created_at,
40 updated_at=p.updated_at,
41 department_id=p.department_id,
42 department_name=p.department.name if p.department else None,
43 goal=p.goal,
44 roadmap_start=p.roadmap_start,
45 roadmap_end=p.roadmap_end,
46 ))
47 return result
49@router.post("/projects", auth=JWTAuth(), response={201: ProjectOut})
50def create_project(request, payload: ProjectIn):
51 tenant_id = require_tenant(request)
52 p = PlannerService.create_project(tenant_id, request.user, payload)
53 return 201, ProjectOut(
54 id=p.id,
55 name=p.name,
56 key=p.key,
57 description=p.description,
58 color=p.color,
59 status=p.status,
60 lead_id=p.lead.id if p.lead else None,
61 lead_name=p.lead.full_name if p.lead else None,
62 tasks_count=0,
63 completed_tasks_count=0,
64 created_at=p.created_at,
65 updated_at=p.updated_at,
66 department_id=p.department_id,
67 department_name=p.department.name if p.department else None,
68 goal=p.goal,
69 roadmap_start=p.roadmap_start,
70 roadmap_end=p.roadmap_end,
71 )
73# ── Boards ────────────────────────────────────────────────────────────────────
74@router.get("/projects/{project_id}/board", auth=JWTAuth(), response={200: BoardOut})
75def get_project_board(request, project_id: UUID):
76 tenant_id = require_tenant(request)
77 board = Board.objects.filter(project_id=project_id, tenant_id=tenant_id, project__in=PlannerService.visible_projects(tenant_id, request.user)).prefetch_related('columns').first()
78 if not board:
79 raise NotFoundException("Board not found for this project.")
81 return BoardOut(
82 id=board.id,
83 project_id=board.project_id,
84 name=board.name,
85 columns=[
86 BoardColumnOut(
87 id=c.id,
88 name=c.name,
89 order=c.order,
90 limit=c.limit,
91 status_category=c.status_category,
92 ) for c in board.columns.all()
93 ]
94 )
96# ── Tasks ─────────────────────────────────────────────────────────────────────
97@router.get("/tasks", auth=JWTAuth(), response={200: List[TaskOut]})
98def list_tasks(request, project_id: Optional[UUID] = None, assignee_id: Optional[UUID] = None, status: Optional[str] = None):
99 tenant_id = require_tenant(request)
100 qs = Task.objects.filter(tenant_id=tenant_id, project__in=PlannerService.visible_projects(tenant_id, request.user)).select_related('project', 'column', 'assignee', 'reporter')
101 if project_id:
102 qs = qs.filter(project_id=project_id)
103 if assignee_id:
104 qs = qs.filter(assignee_id=assignee_id)
105 if status:
106 qs = qs.filter(status=status)
108 result = []
109 for t in qs:
110 result.append(TaskOut(
111 id=t.id,
112 project_id=t.project.id,
113 project_key=t.project.key,
114 board_id=t.board_id,
115 column_id=t.column.id if t.column else None,
116 column_name=t.column.name if t.column else None,
117 title=t.title,
118 description=t.description,
119 task_number=t.task_number,
120 task_code=t.task_code,
121 priority=t.priority,
122 status=t.status,
123 order=t.order,
124 assignee_id=t.assignee.id if t.assignee else None,
125 assignee_name=t.assignee.full_name if t.assignee else None,
126 assignee_avatar=t.assignee.avatar_url if t.assignee else None,
127 reporter_id=t.reporter.id,
128 reporter_name=t.reporter.full_name,
129 due_date=t.due_date,
130 estimated_hours=t.estimated_hours,
131 logged_hours=t.logged_hours,
132 tags=t.tags,
133 comments_count=t.comments.count(),
134 created_at=t.created_at,
135 updated_at=t.updated_at,
136 ))
137 return result
139@router.post("/tasks", auth=JWTAuth(), response={201: TaskOut})
140def create_task(request, payload: TaskIn):
141 tenant_id = require_tenant(request)
142 t = PlannerService.create_task(tenant_id, request.user, payload)
143 return 201, TaskOut(
144 id=t.id,
145 project_id=t.project.id,
146 project_key=t.project.key,
147 board_id=t.board_id,
148 column_id=t.column.id if t.column else None,
149 column_name=t.column.name if t.column else None,
150 title=t.title,
151 description=t.description,
152 task_number=t.task_number,
153 task_code=t.task_code,
154 priority=t.priority,
155 status=t.status,
156 order=t.order,
157 assignee_id=t.assignee.id if t.assignee else None,
158 assignee_name=t.assignee.full_name if t.assignee else None,
159 assignee_avatar=t.assignee.avatar_url if t.assignee else None,
160 reporter_id=t.reporter.id,
161 reporter_name=t.reporter.full_name,
162 due_date=t.due_date,
163 estimated_hours=t.estimated_hours,
164 logged_hours=t.logged_hours,
165 tags=t.tags,
166 comments_count=0,
167 created_at=t.created_at,
168 updated_at=t.updated_at,
169 )
171@router.post("/tasks/{task_id}/move", auth=JWTAuth(), response={200: TaskOut})
172def move_task(request, task_id: UUID, payload: TaskMoveIn):
173 tenant_id = require_tenant(request)
174 t = PlannerService.move_task(tenant_id, request.user, task_id, payload.column_id, payload.order, payload.status)
175 return TaskOut(
176 id=t.id,
177 project_id=t.project.id,
178 project_key=t.project.key,
179 board_id=t.board_id,
180 column_id=t.column.id if t.column else None,
181 column_name=t.column.name if t.column else None,
182 title=t.title,
183 description=t.description,
184 task_number=t.task_number,
185 task_code=t.task_code,
186 priority=t.priority,
187 status=t.status,
188 order=t.order,
189 assignee_id=t.assignee.id if t.assignee else None,
190 assignee_name=t.assignee.full_name if t.assignee else None,
191 assignee_avatar=t.assignee.avatar_url if t.assignee else None,
192 reporter_id=t.reporter.id,
193 reporter_name=t.reporter.full_name,
194 due_date=t.due_date,
195 estimated_hours=t.estimated_hours,
196 logged_hours=t.logged_hours,
197 tags=t.tags,
198 comments_count=t.comments.count(),
199 created_at=t.created_at,
200 updated_at=t.updated_at,
201 )
203# ── Comments & Time Logs ──────────────────────────────────────────────────────
204@router.get("/tasks/{task_id}/comments", auth=JWTAuth(), response={200: List[TaskCommentOut]})
205def list_comments(request, task_id: UUID):
206 tenant_id = require_tenant(request)
207 comments = TaskComment.objects.filter(task_id=task_id, tenant_id=tenant_id).select_related('author')
208 return [
209 TaskCommentOut(
210 id=c.id,
211 task_id=c.task_id,
212 author_id=c.author.id,
213 author_name=c.author.full_name,
214 author_avatar=c.author.avatar_url,
215 content=c.content,
216 created_at=c.created_at,
217 ) for c in comments
218 ]
220@router.post("/tasks/{task_id}/comments", auth=JWTAuth(), response={201: TaskCommentOut})
221def add_comment(request, task_id: UUID, payload: TaskCommentIn):
222 tenant_id = require_tenant(request)
223 task = Task.objects.filter(id=task_id, tenant_id=tenant_id, project__in=PlannerService.visible_projects(tenant_id, request.user)).first()
224 if not task:
225 raise NotFoundException("Task not found.")
227 c = TaskComment.objects.create(
228 tenant_id=tenant_id,
229 task=task,
230 author=request.user,
231 content=payload.content,
232 )
233 return 201, TaskCommentOut(
234 id=c.id,
235 task_id=c.task_id,
236 author_id=c.author.id,
237 author_name=c.author.full_name,
238 author_avatar=c.author.avatar_url,
239 content=c.content,
240 created_at=c.created_at,
241 )
243@router.post("/tasks/{task_id}/timelogs", auth=JWTAuth(), response={201: TimeLogOut})
244def log_time(request, task_id: UUID, payload: TimeLogIn):
245 tenant_id = require_tenant(request)
246 task = Task.objects.filter(id=task_id, tenant_id=tenant_id, project__in=PlannerService.visible_projects(tenant_id, request.user)).first()
247 if not task:
248 raise NotFoundException("Task not found.")
250 from django.utils import timezone
251 tl = TimeLog.objects.create(
252 tenant_id=tenant_id,
253 task=task,
254 user=request.user,
255 hours=payload.hours,
256 description=payload.description,
257 logged_date=payload.logged_date or timezone.now().date(),
258 )
259 return 201, TimeLogOut(
260 id=tl.id,
261 task_id=tl.task_id,
262 user_id=tl.user.id,
263 user_name=tl.user.full_name,
264 hours=tl.hours,
265 description=tl.description,
266 logged_date=tl.logged_date,
267 created_at=tl.created_at,
268 )