taskdog-server 0.28.0__tar.gz → 0.29.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/PKG-INFO +3 -3
  2. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/README.md +1 -1
  3. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/pyproject.toml +2 -8
  4. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/dependencies.py +8 -4
  5. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/models/requests.py +12 -2
  6. taskdog_server-0.29.0/src/taskdog_server/api/routers/analytics.py +420 -0
  7. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/tasks.py +6 -3
  8. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/websocket.py +15 -5
  9. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/validators.py +5 -0
  10. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/main.py +20 -1
  11. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/PKG-INFO +3 -3
  12. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/SOURCES.txt +2 -1
  13. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/requires.txt +1 -1
  14. taskdog_server-0.29.0/tests/test_main.py +39 -0
  15. taskdog_server-0.28.0/src/taskdog_server/api/routers/analytics.py +0 -425
  16. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/setup.cfg +0 -0
  17. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/__init__.py +0 -0
  18. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/__init__.py +0 -0
  19. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/app.py +0 -0
  20. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/audit_helpers.py +0 -0
  21. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/context.py +0 -0
  22. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/error_handlers.py +0 -0
  23. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/middleware.py +0 -0
  24. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/models/__init__.py +0 -0
  25. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/models/responses.py +0 -0
  26. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/__init__.py +0 -0
  27. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/audit.py +0 -0
  28. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/backup.py +0 -0
  29. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/bulk.py +0 -0
  30. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/lifecycle.py +0 -0
  31. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/notes.py +0 -0
  32. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/relationships.py +0 -0
  33. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/tags.py +0 -0
  34. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/utils.py +0 -0
  35. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/config/__init__.py +0 -0
  36. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/config/server_config_manager.py +0 -0
  37. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/infrastructure/__init__.py +0 -0
  38. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/infrastructure/logging/__init__.py +0 -0
  39. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/infrastructure/logging/config.py +0 -0
  40. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/py.typed +0 -0
  41. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/websocket/__init__.py +0 -0
  42. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/websocket/broadcaster.py +0 -0
  43. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/websocket/connection_manager.py +0 -0
  44. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/dependency_links.txt +0 -0
  45. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/entry_points.txt +0 -0
  46. {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskdog-server
3
- Version: 0.28.0
3
+ Version: 0.29.0
4
4
  Summary: FastAPI server for Taskdog task management system
5
5
  Author: Kohei Wada
6
6
  License: MIT
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Python: >=3.12
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: taskdog-core==0.28.0
18
+ Requires-Dist: taskdog-core==0.29.0
19
19
  Requires-Dist: fastapi>=0.141.1
20
20
  Requires-Dist: uvicorn[standard]>=0.52.3
21
21
  Requires-Dist: pydantic>=2.10.0
@@ -109,7 +109,7 @@ See [Authentication Documentation](https://kohei-wada.github.io/taskdog/referenc
109
109
 
110
110
  ## WebSocket Real-time Updates
111
111
 
112
- Connect to `/ws` for real-time task notifications:
112
+ Connect to `/ws` for real-time task notifications. Clients that can set headers should send `X-Api-Key`; browsers can only use `?token=`, which is written to access logs:
113
113
 
114
114
  ```javascript
115
115
  const ws = new WebSocket('ws://localhost:8000/ws?token=your-api-key');
@@ -79,7 +79,7 @@ See [Authentication Documentation](https://kohei-wada.github.io/taskdog/referenc
79
79
 
80
80
  ## WebSocket Real-time Updates
81
81
 
82
- Connect to `/ws` for real-time task notifications:
82
+ Connect to `/ws` for real-time task notifications. Clients that can set headers should send `X-Api-Key`; browsers can only use `?token=`, which is written to access logs:
83
83
 
84
84
  ```javascript
85
85
  const ws = new WebSocket('ws://localhost:8000/ws?token=your-api-key');
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskdog-server"
3
- version = "0.28.0"
3
+ version = "0.29.0"
4
4
  description = "FastAPI server for Taskdog task management system"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -18,7 +18,7 @@ classifiers = [
18
18
  ]
19
19
 
20
20
  dependencies = [
21
- "taskdog-core==0.28.0",
21
+ "taskdog-core==0.29.0",
22
22
  "fastapi>=0.141.1",
23
23
  "uvicorn[standard]>=0.52.3",
24
24
  "pydantic>=2.10.0",
@@ -54,12 +54,6 @@ include = ["taskdog*"]
54
54
  [tool.setuptools.package-data]
55
55
  taskdog_server = ["py.typed"]
56
56
 
57
- [tool.mypy]
58
- python_version = "3.13"
59
- strict = true
60
- # Disable unused-ignore warnings (environment-dependent)
61
- warn_unused_ignores = false
62
-
63
57
  [tool.pytest.ini_options]
64
58
  testpaths = ["tests"]
65
59
  python_files = ["test_*.py"]
@@ -102,23 +102,27 @@ def initialize_api_context(
102
102
  holiday_checker = HolidayChecker(config.region.country)
103
103
 
104
104
  # Initialize repository using factory based on storage config (shared engine)
105
- repository = RepositoryFactory.create(config.storage, engine=engine)
105
+ repository = RepositoryFactory.create(
106
+ config.storage, engine=engine, time_provider=time_provider
107
+ )
106
108
 
107
109
  # Initialize audit log repository (shared engine)
108
110
  audit_log_repository = SqliteAuditLogRepository(db_url, engine=engine)
109
111
 
110
112
  # Initialize controllers
111
113
  query_controller = QueryController(repository, notes_repository, time_provider)
112
- lifecycle_controller = TaskLifecycleController(repository, config)
114
+ lifecycle_controller = TaskLifecycleController(repository, config, time_provider)
113
115
  relationship_controller = TaskRelationshipController(repository, config)
114
116
  analytics_controller = TaskAnalyticsController(
115
117
  repository, config, holiday_checker, audit_log_repository
116
118
  )
117
- crud_controller = TaskCrudController(repository, config, holiday_checker)
119
+ crud_controller = TaskCrudController(
120
+ repository, config, time_provider, holiday_checker
121
+ )
118
122
  audit_log_controller = AuditLogController(audit_log_repository, time_provider)
119
123
  notes_controller = NotesController(repository, notes_repository)
120
124
 
121
- bulk_service = BulkOperationService(repository)
125
+ bulk_service = BulkOperationService(repository, time_provider)
122
126
 
123
127
  # Backup/restore controller over a storage-neutral port
124
128
  backup_controller = BackupController(SqliteBackupStore(db_url))
@@ -6,6 +6,10 @@ from pydantic import BaseModel, Field, field_validator, model_validator
6
6
 
7
7
  from taskdog_core.domain.entities.task import TaskStatus
8
8
  from taskdog_core.shared.constants import MAX_TASK_NAME_LENGTH
9
+ from taskdog_server.api.validators import (
10
+ MAX_NOTES_LENGTH,
11
+ MAX_TASK_IDS_PER_REQUEST,
12
+ )
9
13
  from taskdog_server.api.validators import validate_tags as _validate_tags
10
14
 
11
15
 
@@ -147,7 +151,10 @@ class BulkTaskIdsRequest(BaseModel):
147
151
  """Request model for bulk task operations."""
148
152
 
149
153
  task_ids: list[int] = Field(
150
- ..., min_length=1, description="List of task IDs to operate on"
154
+ ...,
155
+ min_length=1,
156
+ max_length=MAX_TASK_IDS_PER_REQUEST,
157
+ description="List of task IDs to operate on",
151
158
  )
152
159
 
153
160
 
@@ -166,6 +173,7 @@ class OptimizeScheduleRequest(BaseModel):
166
173
  )
167
174
  task_ids: list[int] | None = Field(
168
175
  None,
176
+ max_length=MAX_TASK_IDS_PER_REQUEST,
169
177
  description="Specific task IDs to optimize (None means all schedulable tasks)",
170
178
  )
171
179
  include_all_days: bool = Field(
@@ -177,4 +185,6 @@ class OptimizeScheduleRequest(BaseModel):
177
185
  class UpdateNotesRequest(BaseModel):
178
186
  """Request model for updating task notes."""
179
187
 
180
- content: str = Field(..., description="Notes content (markdown)")
188
+ content: str = Field(
189
+ ..., max_length=MAX_NOTES_LENGTH, description="Notes content (markdown)"
190
+ )
@@ -0,0 +1,420 @@
1
+ """Analytics endpoints (statistics, optimization, gantt chart)."""
2
+
3
+ from typing import TYPE_CHECKING, Annotated, Literal
4
+
5
+ from fastapi import APIRouter, Query
6
+
7
+ from taskdog_core.application.dto.query_inputs import ListTasksInput
8
+
9
+ if TYPE_CHECKING:
10
+ from taskdog_core.application.dto.task_dto import TaskSummaryDto
11
+
12
+ from taskdog_server.api.dependencies import (
13
+ AnalyticsControllerDep,
14
+ AuditLogControllerDep,
15
+ AuthenticatedClientDep,
16
+ EventBroadcasterDep,
17
+ HolidayCheckerDep,
18
+ QueryControllerDep,
19
+ TimeProviderDep,
20
+ )
21
+ from taskdog_server.api.models.requests import OptimizeScheduleRequest
22
+ from taskdog_server.api.models.responses import (
23
+ ActivityPattern,
24
+ CompletionStatistics,
25
+ DeadlineStatistics,
26
+ EstimationStatistics,
27
+ OptimizationResponse,
28
+ OptimizationSummary,
29
+ PriorityDistribution,
30
+ RescheduleStatisticsData,
31
+ SchedulingFailure,
32
+ StatisticsResponse,
33
+ TagStatisticsItem,
34
+ TagStatisticsResponse,
35
+ TaskListResponse,
36
+ TaskSummaryResponse,
37
+ TimeStatistics,
38
+ TrendData,
39
+ )
40
+ from taskdog_server.api.utils import parse_iso_date
41
+ from taskdog_server.api.validators import MAX_TAG_FILTERS
42
+
43
+ router = APIRouter()
44
+
45
+
46
+ @router.get("/statistics", response_model=StatisticsResponse)
47
+ def get_statistics(
48
+ controller: AnalyticsControllerDep,
49
+ _client_name: AuthenticatedClientDep,
50
+ period: Literal["all", "7d", "30d"] = Query(
51
+ "all", description="Time period: all, 7d, or 30d"
52
+ ),
53
+ ) -> StatisticsResponse:
54
+ """Get task statistics for a time period.
55
+
56
+ Args:
57
+ controller: Analytics controller dependency
58
+ period: Time period (all, 7d, 30d)
59
+
60
+ Returns:
61
+ Task statistics including completion, time, estimation, deadline, and trends
62
+ """
63
+ result = controller.calculate_statistics(period=period)
64
+
65
+ # Helper to convert TaskSummaryDto to TaskSummaryResponse
66
+ def to_task_summary(
67
+ dto: "TaskSummaryDto | None",
68
+ ) -> TaskSummaryResponse | None:
69
+ if dto is None:
70
+ return None
71
+ return TaskSummaryResponse(
72
+ id=dto.id,
73
+ name=dto.name,
74
+ estimated_duration=dto.estimated_duration,
75
+ actual_duration_hours=dto.actual_duration_hours,
76
+ )
77
+
78
+ def to_task_summary_list(
79
+ dtos: "list[TaskSummaryDto]",
80
+ ) -> list[TaskSummaryResponse]:
81
+ if not dtos:
82
+ return []
83
+ return [
84
+ TaskSummaryResponse(
85
+ id=d.id,
86
+ name=d.name,
87
+ estimated_duration=d.estimated_duration,
88
+ actual_duration_hours=d.actual_duration_hours,
89
+ )
90
+ for d in dtos
91
+ ]
92
+
93
+ # Convert DTO to response model
94
+ response = StatisticsResponse(
95
+ completion=CompletionStatistics(
96
+ total=result.task_stats.total_tasks,
97
+ completed=result.task_stats.completed_count,
98
+ in_progress=result.task_stats.in_progress_count,
99
+ pending=result.task_stats.pending_count,
100
+ canceled=result.task_stats.canceled_count,
101
+ completion_rate=result.task_stats.completion_rate,
102
+ ),
103
+ time=(
104
+ TimeStatistics(
105
+ total_work_hours=result.time_stats.total_work_hours,
106
+ average_work_hours=result.time_stats.average_work_hours,
107
+ median_work_hours=result.time_stats.median_work_hours,
108
+ longest_task=to_task_summary(result.time_stats.longest_task),
109
+ shortest_task=to_task_summary(result.time_stats.shortest_task),
110
+ tasks_with_time_tracking=result.time_stats.tasks_with_time_tracking,
111
+ )
112
+ if result.time_stats
113
+ else None
114
+ ),
115
+ estimation=(
116
+ EstimationStatistics(
117
+ total_tasks_with_estimation=result.estimation_stats.total_tasks_with_estimation,
118
+ accuracy_rate=result.estimation_stats.accuracy_rate,
119
+ over_estimated_count=result.estimation_stats.over_estimated_count,
120
+ under_estimated_count=result.estimation_stats.under_estimated_count,
121
+ exact_count=result.estimation_stats.exact_count,
122
+ best_estimated_tasks=to_task_summary_list(
123
+ result.estimation_stats.best_estimated_tasks
124
+ ),
125
+ worst_estimated_tasks=to_task_summary_list(
126
+ result.estimation_stats.worst_estimated_tasks
127
+ ),
128
+ estimation_pairs=result.estimation_stats.estimation_pairs,
129
+ )
130
+ if result.estimation_stats
131
+ else None
132
+ ),
133
+ deadline=(
134
+ DeadlineStatistics(
135
+ total_tasks_with_deadline=result.deadline_stats.total_tasks_with_deadline,
136
+ met_deadline_count=result.deadline_stats.met_deadline_count,
137
+ missed_deadline_count=result.deadline_stats.missed_deadline_count,
138
+ compliance_rate=result.deadline_stats.compliance_rate,
139
+ average_delay_days=result.deadline_stats.average_delay_days,
140
+ )
141
+ if result.deadline_stats
142
+ else None
143
+ ),
144
+ priority=PriorityDistribution(
145
+ distribution=result.priority_stats.priority_completion_map,
146
+ high_priority_count=result.priority_stats.high_priority_count,
147
+ medium_priority_count=result.priority_stats.medium_priority_count,
148
+ low_priority_count=result.priority_stats.low_priority_count,
149
+ high_priority_completion_rate=result.priority_stats.high_priority_completion_rate,
150
+ ),
151
+ trends=(
152
+ TrendData(
153
+ last_7_days_completed=result.trend_stats.last_7_days_completed,
154
+ last_30_days_completed=result.trend_stats.last_30_days_completed,
155
+ weekly_completion_trend=result.trend_stats.weekly_completion_trend,
156
+ monthly_completion_trend=result.trend_stats.monthly_completion_trend,
157
+ )
158
+ if result.trend_stats
159
+ else None
160
+ ),
161
+ activity=(
162
+ ActivityPattern(
163
+ hourly_completions=result.activity_stats.hourly_completions,
164
+ daily_completions=result.activity_stats.daily_completions,
165
+ heatmap=result.activity_stats.heatmap,
166
+ total_completed_with_time=result.activity_stats.total_completed_with_time,
167
+ )
168
+ if result.activity_stats
169
+ else None
170
+ ),
171
+ reschedule=(
172
+ RescheduleStatisticsData.model_validate(
173
+ result.reschedule_stats, from_attributes=True
174
+ )
175
+ if result.reschedule_stats
176
+ else None
177
+ ),
178
+ )
179
+ return response
180
+
181
+
182
+ @router.get("/tags/statistics", response_model=TagStatisticsResponse)
183
+ def get_tag_statistics(
184
+ controller: QueryControllerDep,
185
+ _client_name: AuthenticatedClientDep,
186
+ ) -> TagStatisticsResponse:
187
+ """Get tag usage statistics.
188
+
189
+ Args:
190
+ controller: Query controller dependency
191
+
192
+ Returns:
193
+ Tag statistics including count and completion rate per tag
194
+ """
195
+ result = controller.get_tag_statistics()
196
+
197
+ # Convert DTO to response model
198
+ # Note: TagStatisticsOutput.tag_counts is a dict[str, int]
199
+ # completion_rate is not available in the DTO, so we set it to 0.0
200
+ tags = [
201
+ TagStatisticsItem(tag=tag_name, count=count, completion_rate=0.0)
202
+ for tag_name, count in result.tag_counts.items()
203
+ ]
204
+
205
+ return TagStatisticsResponse(tags=tags, total_tags=result.total_tags)
206
+
207
+
208
+ @router.get("/gantt", response_model=TaskListResponse)
209
+ def get_gantt_chart(
210
+ controller: QueryControllerDep,
211
+ holiday_checker: HolidayCheckerDep,
212
+ _client_name: AuthenticatedClientDep,
213
+ include_archived: Annotated[
214
+ bool, Query(alias="all", description="Include archived tasks")
215
+ ] = False,
216
+ status_filter: Annotated[
217
+ str | None, Query(alias="status", description="Filter by status")
218
+ ] = None,
219
+ tags: Annotated[
220
+ list[str] | None,
221
+ Query(max_length=MAX_TAG_FILTERS, description="Filter by tags (OR logic)"),
222
+ ] = None,
223
+ filter_start_date: Annotated[
224
+ str | None, Query(description="Task filter start date (ISO format)")
225
+ ] = None,
226
+ filter_end_date: Annotated[
227
+ str | None, Query(description="Task filter end date (ISO format)")
228
+ ] = None,
229
+ start_date: Annotated[
230
+ str | None,
231
+ Query(
232
+ description="Chart start date (ISO format). "
233
+ "Filters tasks when no task filter dates are given."
234
+ ),
235
+ ] = None,
236
+ end_date: Annotated[
237
+ str | None,
238
+ Query(
239
+ description="Chart end date (ISO format). "
240
+ "Filters tasks when no task filter dates are given."
241
+ ),
242
+ ] = None,
243
+ sort: Annotated[str, Query(description="Sort field")] = "deadline",
244
+ reverse: Annotated[bool, Query(description="Reverse sort order")] = False,
245
+ ) -> TaskListResponse:
246
+ """Get Gantt chart data with workload calculations.
247
+
248
+ Returns the shared task list plus the Gantt overlay (daily hours, workload,
249
+ holidays); clients join them by task id.
250
+
251
+ Without task filter dates, chart dates also filter tasks.
252
+
253
+ Args:
254
+ controller: Query controller dependency
255
+ holiday_checker: Holiday checker dependency
256
+ include_archived: Include archived tasks
257
+ status_filter: Filter by task status
258
+ tags: Filter by tags (OR logic)
259
+ filter_start_date: Task filter start date
260
+ filter_end_date: Task filter end date
261
+ start_date: Chart start date
262
+ end_date: Chart end date
263
+ sort: Sort field name
264
+ reverse: Reverse sort order
265
+
266
+ Returns:
267
+ Task list with the Gantt overlay populated
268
+ """
269
+ # Parse date strings to date objects
270
+ start = parse_iso_date(start_date)
271
+ end = parse_iso_date(end_date)
272
+ if filter_start_date is None and filter_end_date is None:
273
+ task_start = start
274
+ task_end = end
275
+ else:
276
+ task_start = parse_iso_date(filter_start_date)
277
+ task_end = parse_iso_date(filter_end_date)
278
+
279
+ # Create Input DTO (filter building is done in Use Case)
280
+ input_dto = ListTasksInput(
281
+ include_archived=include_archived,
282
+ status=status_filter,
283
+ tags=tags or [],
284
+ start_date=task_start,
285
+ end_date=task_end,
286
+ sort_by=sort,
287
+ reverse=reverse,
288
+ )
289
+
290
+ # Single fetch builds both the task list and the Gantt overlay
291
+ result = controller.list_tasks(
292
+ input_dto=input_dto,
293
+ include_gantt=True,
294
+ gantt_start_date=start,
295
+ gantt_end_date=end,
296
+ holiday_checker=holiday_checker,
297
+ )
298
+
299
+ return TaskListResponse.from_dto(result)
300
+
301
+
302
+ @router.post("/optimize", response_model=OptimizationResponse)
303
+ def optimize_schedule(
304
+ request: OptimizeScheduleRequest,
305
+ controller: AnalyticsControllerDep,
306
+ broadcaster: EventBroadcasterDep,
307
+ audit_controller: AuditLogControllerDep,
308
+ time_provider: TimeProviderDep,
309
+ client_name: AuthenticatedClientDep,
310
+ ) -> OptimizationResponse:
311
+ """Optimize task schedules using specified algorithm.
312
+
313
+ Args:
314
+ request: Optimization parameters
315
+ controller: Analytics controller dependency
316
+ broadcaster: Event broadcaster dependency
317
+ time_provider: Time provider dependency
318
+ client_name: Authenticated client name (used for broadcast exclusion)
319
+
320
+ Returns:
321
+ Optimization results with summary and failures
322
+
323
+ Raises:
324
+ TaskNotFoundException: 404 via app-wide handler if a task ID is unknown
325
+ TaskValidationError: 400 via app-wide handler (incl. NoSchedulableTasksError)
326
+ """
327
+ # Use current date if not specified
328
+ start_date = request.start_date or time_provider.now()
329
+
330
+ # Run synchronously
331
+ result = controller.optimize_schedule(
332
+ algorithm=request.algorithm,
333
+ start_date=start_date,
334
+ max_hours_per_day=request.max_hours_per_day,
335
+ force_override=request.force_override,
336
+ task_ids=request.task_ids,
337
+ include_all_days=request.include_all_days,
338
+ )
339
+
340
+ # Broadcast WebSocket event in background (exclude the requester by client name)
341
+ broadcaster.schedule_optimized(
342
+ len(result.successful_tasks),
343
+ len(result.failed_tasks),
344
+ request.algorithm,
345
+ client_name,
346
+ )
347
+
348
+ # Audit log
349
+ audit_controller.log_operation(
350
+ operation="optimize_schedule",
351
+ resource_type="schedule",
352
+ resource_id=None,
353
+ resource_name=None,
354
+ client_name=client_name,
355
+ new_values={
356
+ "algorithm": request.algorithm,
357
+ "scheduled_tasks": len(result.successful_tasks),
358
+ "failed_tasks": len(result.failed_tasks),
359
+ "total_hours": result.summary.total_hours,
360
+ "task_ids": request.task_ids,
361
+ },
362
+ success=True,
363
+ )
364
+
365
+ # Convert DTO to response model
366
+ failures = [
367
+ SchedulingFailure(task_id=f.task.id, task_name=f.task.name, reason=f.reason)
368
+ for f in result.failed_tasks
369
+ ]
370
+
371
+ # Calculate date range from daily allocations
372
+ if result.daily_allocations:
373
+ dates = list(result.daily_allocations.keys())
374
+ opt_start_date = min(dates)
375
+ opt_end_date = max(dates)
376
+ else:
377
+ opt_start_date = start_date.date()
378
+ opt_end_date = start_date.date()
379
+
380
+ # Build optimization message
381
+ if result.all_failed():
382
+ message = f"No tasks were scheduled. {len(result.failed_tasks)} task(s) failed."
383
+ elif result.has_failures():
384
+ message = f"Partially optimized: {len(result.successful_tasks)} succeeded, {len(result.failed_tasks)} failed."
385
+ else:
386
+ message = f"Successfully optimized {len(result.successful_tasks)} task(s)."
387
+
388
+ return OptimizationResponse(
389
+ summary=OptimizationSummary(
390
+ total_tasks=len(result.successful_tasks) + len(result.failed_tasks),
391
+ scheduled_tasks=len(result.successful_tasks),
392
+ failed_tasks=len(result.failed_tasks),
393
+ total_hours=result.summary.total_hours,
394
+ start_date=opt_start_date,
395
+ end_date=opt_end_date,
396
+ algorithm=request.algorithm,
397
+ ),
398
+ failures=failures,
399
+ message=message,
400
+ )
401
+
402
+
403
+ @router.get("/algorithms", response_model=list[dict[str, str]])
404
+ def list_algorithms(
405
+ controller: QueryControllerDep,
406
+ _client_name: AuthenticatedClientDep,
407
+ ) -> list[dict[str, str]]:
408
+ """List available optimization algorithms.
409
+
410
+ Args:
411
+ controller: Query controller dependency
412
+
413
+ Returns:
414
+ List of algorithms with name, display name, and description
415
+ """
416
+ algorithms = controller.get_algorithm_metadata()
417
+ return [
418
+ {"name": name, "display_name": display_name, "description": description}
419
+ for name, display_name, description in algorithms
420
+ ]
@@ -27,6 +27,7 @@ from taskdog_server.api.models.responses import (
27
27
  UpdateTaskResponse,
28
28
  )
29
29
  from taskdog_server.api.utils import parse_iso_date
30
+ from taskdog_server.api.validators import MAX_TAG_FILTERS
30
31
 
31
32
  router = APIRouter()
32
33
 
@@ -97,7 +98,8 @@ def list_tasks(
97
98
  str | None, Query(alias="status", description="Filter by status")
98
99
  ] = None,
99
100
  tags: Annotated[
100
- list[str] | None, Query(description="Filter by tags (OR logic)")
101
+ list[str] | None,
102
+ Query(max_length=MAX_TAG_FILTERS, description="Filter by tags (OR logic)"),
101
103
  ] = None,
102
104
  start_date: Annotated[str | None, Query(description="Filter by start date")] = None,
103
105
  end_date: Annotated[str | None, Query(description="Filter by end date")] = None,
@@ -190,7 +192,8 @@ def get_executable_tasks(
190
192
  controller: QueryControllerDep,
191
193
  _client_name: AuthenticatedClientDep,
192
194
  tags: Annotated[
193
- list[str] | None, Query(description="Filter by tags (OR logic)")
195
+ list[str] | None,
196
+ Query(max_length=MAX_TAG_FILTERS, description="Filter by tags (OR logic)"),
194
197
  ] = None,
195
198
  limit: Annotated[int, Query(ge=1, description="Maximum tasks to return")] = 10,
196
199
  ) -> NextTasksResponse:
@@ -372,7 +375,7 @@ def restore_task(
372
375
  return TaskOperationResponse.from_dto(result)
373
376
 
374
377
 
375
- @router.delete("/{task_id}")
378
+ @router.delete("/{task_id}", response_model=TaskOperationResponse)
376
379
  def delete_task(
377
380
  task_id: int,
378
381
  controller: CrudControllerDep,
@@ -6,7 +6,7 @@ real-time notifications about task changes.
6
6
 
7
7
  import uuid
8
8
 
9
- from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
9
+ from fastapi import APIRouter, Header, Query, WebSocket, WebSocketDisconnect
10
10
 
11
11
  from taskdog_server.api.dependencies import (
12
12
  ConnectionManagerWsDep,
@@ -22,7 +22,12 @@ async def websocket_endpoint(
22
22
  websocket: WebSocket,
23
23
  manager: ConnectionManagerWsDep,
24
24
  server_config: ServerConfigWsDep,
25
- token: str | None = Query(None, description="API key for authentication"),
25
+ x_api_key: str | None = Header(None, description="API key for authentication"),
26
+ token: str | None = Query(
27
+ None,
28
+ description="API key as query parameter, for clients that cannot set "
29
+ "headers (browsers). It appears in access logs; prefer X-Api-Key.",
30
+ ),
26
31
  ) -> None:
27
32
  """WebSocket endpoint for real-time task updates.
28
33
 
@@ -30,13 +35,16 @@ async def websocket_endpoint(
30
35
  when tasks are created, updated, or deleted.
31
36
 
32
37
  Authentication:
33
- Pass API key as query parameter: /ws?token=sk-xxx
38
+ Send the API key in the X-Api-Key header. Browsers cannot set
39
+ WebSocket headers, so ?token= is still accepted, but the key then
40
+ shows up in access logs. The header wins when both are present.
34
41
 
35
42
  Args:
36
43
  websocket: The WebSocket connection
37
44
  manager: Connection manager dependency
38
45
  server_config: Server configuration dependency
39
- token: API key for authentication (query parameter)
46
+ x_api_key: API key from the X-Api-Key header
47
+ token: API key from the query string (fallback)
40
48
 
41
49
  Message Format:
42
50
  {
@@ -48,7 +56,9 @@ async def websocket_endpoint(
48
56
  """
49
57
  # Validate API key before accepting connection
50
58
  try:
51
- client_name = validate_api_key_for_websocket(token, server_config)
59
+ client_name = validate_api_key_for_websocket(
60
+ x_api_key if x_api_key is not None else token, server_config
61
+ )
52
62
  except ValueError as e:
53
63
  # Use standard WebSocket close code 1008 (Policy Violation) for auth failures
54
64
  await websocket.close(code=1008, reason=str(e))
@@ -2,6 +2,11 @@
2
2
 
3
3
  from taskdog_core.shared.constants import MAX_TAG_LENGTH, MAX_TAGS_PER_TASK
4
4
 
5
+ # Request size bounds against oversized payloads; far above real usage.
6
+ MAX_TASK_IDS_PER_REQUEST = 1000
7
+ MAX_NOTES_LENGTH = 1_000_000
8
+ MAX_TAG_FILTERS = 100
9
+
5
10
 
6
11
  def validate_tags(tags: list[str]) -> list[str]:
7
12
  """Validate tags are non-empty, unique, and within limits.
@@ -1,9 +1,20 @@
1
1
  """Main entry point for Taskdog API server."""
2
2
 
3
3
  import argparse
4
+ import ipaddress
4
5
  import sys
5
6
 
6
7
  from taskdog_server import __version__
8
+ from taskdog_server.config.server_config_manager import ServerConfigManager
9
+
10
+
11
+ def _is_loopback(host: str) -> bool:
12
+ if host == "localhost":
13
+ return True
14
+ try:
15
+ return ipaddress.ip_address(host).is_loopback
16
+ except ValueError:
17
+ return False
7
18
 
8
19
 
9
20
  def main() -> None:
@@ -14,7 +25,7 @@ def main() -> None:
14
25
  epilog="""
15
26
  Examples:
16
27
  taskdog-server # Start server on default host:port
17
- taskdog-server --host 0.0.0.0 # Listen on all interfaces
28
+ taskdog-server --host 0.0.0.0 # Listen on all interfaces (enable auth first)
18
29
  taskdog-server --port 3000 # Use custom port
19
30
  taskdog-server --reload # Enable auto-reload for dev
20
31
 
@@ -56,6 +67,14 @@ The API will be available at:
56
67
  )
57
68
  sys.exit(1)
58
69
 
70
+ if not _is_loopback(args.host) and not ServerConfigManager.load().auth.enabled:
71
+ print(
72
+ f"WARNING: authentication is disabled but the server listens on {args.host}. "
73
+ "Anyone who can reach this address can read and delete your tasks. "
74
+ "Enable [auth] in server.toml or set TASKDOG_AUTH_ENABLED=true.",
75
+ file=sys.stderr,
76
+ )
77
+
59
78
  print(f"Starting Taskdog API server on {args.host}:{args.port}")
60
79
  print(f"API documentation: http://{args.host}:{args.port}/docs")
61
80
  print(f"Health check: http://{args.host}:{args.port}/health")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskdog-server
3
- Version: 0.28.0
3
+ Version: 0.29.0
4
4
  Summary: FastAPI server for Taskdog task management system
5
5
  Author: Kohei Wada
6
6
  License: MIT
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Python: >=3.12
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: taskdog-core==0.28.0
18
+ Requires-Dist: taskdog-core==0.29.0
19
19
  Requires-Dist: fastapi>=0.141.1
20
20
  Requires-Dist: uvicorn[standard]>=0.52.3
21
21
  Requires-Dist: pydantic>=2.10.0
@@ -109,7 +109,7 @@ See [Authentication Documentation](https://kohei-wada.github.io/taskdog/referenc
109
109
 
110
110
  ## WebSocket Real-time Updates
111
111
 
112
- Connect to `/ws` for real-time task notifications:
112
+ Connect to `/ws` for real-time task notifications. Clients that can set headers should send `X-Api-Key`; browsers can only use `?token=`, which is written to access logs:
113
113
 
114
114
  ```javascript
115
115
  const ws = new WebSocket('ws://localhost:8000/ws?token=your-api-key');
@@ -39,4 +39,5 @@ src/taskdog_server/infrastructure/logging/__init__.py
39
39
  src/taskdog_server/infrastructure/logging/config.py
40
40
  src/taskdog_server/websocket/__init__.py
41
41
  src/taskdog_server/websocket/broadcaster.py
42
- src/taskdog_server/websocket/connection_manager.py
42
+ src/taskdog_server/websocket/connection_manager.py
43
+ tests/test_main.py
@@ -1,4 +1,4 @@
1
- taskdog-core==0.28.0
1
+ taskdog-core==0.29.0
2
2
  fastapi>=0.141.1
3
3
  uvicorn[standard]>=0.52.3
4
4
  pydantic>=2.10.0
@@ -0,0 +1,39 @@
1
+ """Tests for the server entry point."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ from taskdog_server.config.server_config_manager import AuthConfig, ServerConfig
8
+ from taskdog_server.main import main
9
+
10
+
11
+ def _run_main(host: str, auth_enabled: bool) -> None:
12
+ config = ServerConfig(auth=AuthConfig(enabled=auth_enabled))
13
+ with (
14
+ patch("sys.argv", ["taskdog-server", "--host", host]),
15
+ patch("taskdog_server.main.ServerConfigManager.load", return_value=config),
16
+ patch("uvicorn.run"),
17
+ ):
18
+ main()
19
+
20
+
21
+ class TestExposureWarning:
22
+ """Test the warning for non-loopback binds without auth."""
23
+
24
+ @pytest.mark.parametrize("host", ["0.0.0.0", "192.168.1.10", "::"])
25
+ def test_warns_on_non_loopback_without_auth(self, capsys, host):
26
+ _run_main(host, auth_enabled=False)
27
+
28
+ assert "authentication is disabled" in capsys.readouterr().err
29
+
30
+ @pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1"])
31
+ def test_no_warning_on_loopback(self, capsys, host):
32
+ _run_main(host, auth_enabled=False)
33
+
34
+ assert capsys.readouterr().err == ""
35
+
36
+ def test_no_warning_when_auth_enabled(self, capsys):
37
+ _run_main("0.0.0.0", auth_enabled=True)
38
+
39
+ assert capsys.readouterr().err == ""
@@ -1,425 +0,0 @@
1
- """Analytics endpoints (statistics, optimization, gantt chart)."""
2
-
3
- from typing import TYPE_CHECKING, Annotated
4
-
5
- from fastapi import APIRouter, HTTPException, Query, status
6
-
7
- from taskdog_core.application.dto.query_inputs import ListTasksInput
8
-
9
- if TYPE_CHECKING:
10
- from taskdog_core.application.dto.task_dto import TaskSummaryDto
11
-
12
- from taskdog_core.domain.exceptions.task_exceptions import (
13
- NoSchedulableTasksError,
14
- TaskNotFoundException,
15
- TaskValidationError,
16
- )
17
- from taskdog_server.api.dependencies import (
18
- AnalyticsControllerDep,
19
- AuditLogControllerDep,
20
- AuthenticatedClientDep,
21
- EventBroadcasterDep,
22
- HolidayCheckerDep,
23
- QueryControllerDep,
24
- TimeProviderDep,
25
- )
26
- from taskdog_server.api.models.requests import OptimizeScheduleRequest
27
- from taskdog_server.api.models.responses import (
28
- ActivityPattern,
29
- CompletionStatistics,
30
- DeadlineStatistics,
31
- EstimationStatistics,
32
- OptimizationResponse,
33
- OptimizationSummary,
34
- PriorityDistribution,
35
- RescheduleStatisticsData,
36
- SchedulingFailure,
37
- StatisticsResponse,
38
- TagStatisticsItem,
39
- TagStatisticsResponse,
40
- TaskListResponse,
41
- TaskSummaryResponse,
42
- TimeStatistics,
43
- TrendData,
44
- )
45
- from taskdog_server.api.utils import parse_iso_date
46
-
47
- VALID_PERIODS = ("all", "7d", "30d")
48
-
49
- router = APIRouter()
50
-
51
-
52
- @router.get("/statistics", response_model=StatisticsResponse)
53
- def get_statistics(
54
- controller: AnalyticsControllerDep,
55
- _client_name: AuthenticatedClientDep,
56
- period: str = Query(VALID_PERIODS[0], description="Time period: all, 7d, or 30d"),
57
- ) -> StatisticsResponse:
58
- """Get task statistics for a time period.
59
-
60
- Args:
61
- controller: Analytics controller dependency
62
- period: Time period (all, 7d, 30d)
63
-
64
- Returns:
65
- Task statistics including completion, time, estimation, deadline, and trends
66
-
67
- Raises:
68
- HTTPException: 400 if period is invalid
69
- """
70
- if period not in VALID_PERIODS:
71
- raise HTTPException(
72
- status_code=status.HTTP_400_BAD_REQUEST,
73
- detail="Period must be one of: all, 7d, 30d",
74
- )
75
-
76
- try:
77
- result = controller.calculate_statistics(period=period)
78
-
79
- # Helper to convert TaskSummaryDto to TaskSummaryResponse
80
- def to_task_summary(
81
- dto: "TaskSummaryDto | None",
82
- ) -> TaskSummaryResponse | None:
83
- if dto is None:
84
- return None
85
- return TaskSummaryResponse(
86
- id=dto.id,
87
- name=dto.name,
88
- estimated_duration=dto.estimated_duration,
89
- actual_duration_hours=dto.actual_duration_hours,
90
- )
91
-
92
- def to_task_summary_list(
93
- dtos: "list[TaskSummaryDto]",
94
- ) -> list[TaskSummaryResponse]:
95
- if not dtos:
96
- return []
97
- return [
98
- TaskSummaryResponse(
99
- id=d.id,
100
- name=d.name,
101
- estimated_duration=d.estimated_duration,
102
- actual_duration_hours=d.actual_duration_hours,
103
- )
104
- for d in dtos
105
- ]
106
-
107
- # Convert DTO to response model
108
- response = StatisticsResponse(
109
- completion=CompletionStatistics(
110
- total=result.task_stats.total_tasks,
111
- completed=result.task_stats.completed_count,
112
- in_progress=result.task_stats.in_progress_count,
113
- pending=result.task_stats.pending_count,
114
- canceled=result.task_stats.canceled_count,
115
- completion_rate=result.task_stats.completion_rate,
116
- ),
117
- time=(
118
- TimeStatistics(
119
- total_work_hours=result.time_stats.total_work_hours,
120
- average_work_hours=result.time_stats.average_work_hours,
121
- median_work_hours=result.time_stats.median_work_hours,
122
- longest_task=to_task_summary(result.time_stats.longest_task),
123
- shortest_task=to_task_summary(result.time_stats.shortest_task),
124
- tasks_with_time_tracking=result.time_stats.tasks_with_time_tracking,
125
- )
126
- if result.time_stats
127
- else None
128
- ),
129
- estimation=(
130
- EstimationStatistics(
131
- total_tasks_with_estimation=result.estimation_stats.total_tasks_with_estimation,
132
- accuracy_rate=result.estimation_stats.accuracy_rate,
133
- over_estimated_count=result.estimation_stats.over_estimated_count,
134
- under_estimated_count=result.estimation_stats.under_estimated_count,
135
- exact_count=result.estimation_stats.exact_count,
136
- best_estimated_tasks=to_task_summary_list(
137
- result.estimation_stats.best_estimated_tasks
138
- ),
139
- worst_estimated_tasks=to_task_summary_list(
140
- result.estimation_stats.worst_estimated_tasks
141
- ),
142
- estimation_pairs=result.estimation_stats.estimation_pairs,
143
- )
144
- if result.estimation_stats
145
- else None
146
- ),
147
- deadline=(
148
- DeadlineStatistics(
149
- total_tasks_with_deadline=result.deadline_stats.total_tasks_with_deadline,
150
- met_deadline_count=result.deadline_stats.met_deadline_count,
151
- missed_deadline_count=result.deadline_stats.missed_deadline_count,
152
- compliance_rate=result.deadline_stats.compliance_rate,
153
- average_delay_days=result.deadline_stats.average_delay_days,
154
- )
155
- if result.deadline_stats
156
- else None
157
- ),
158
- priority=PriorityDistribution(
159
- distribution=result.priority_stats.priority_completion_map,
160
- high_priority_count=result.priority_stats.high_priority_count,
161
- medium_priority_count=result.priority_stats.medium_priority_count,
162
- low_priority_count=result.priority_stats.low_priority_count,
163
- high_priority_completion_rate=result.priority_stats.high_priority_completion_rate,
164
- ),
165
- trends=(
166
- TrendData(
167
- last_7_days_completed=result.trend_stats.last_7_days_completed,
168
- last_30_days_completed=result.trend_stats.last_30_days_completed,
169
- weekly_completion_trend=result.trend_stats.weekly_completion_trend,
170
- monthly_completion_trend=result.trend_stats.monthly_completion_trend,
171
- )
172
- if result.trend_stats
173
- else None
174
- ),
175
- activity=(
176
- ActivityPattern(
177
- hourly_completions=result.activity_stats.hourly_completions,
178
- daily_completions=result.activity_stats.daily_completions,
179
- heatmap=result.activity_stats.heatmap,
180
- total_completed_with_time=result.activity_stats.total_completed_with_time,
181
- )
182
- if result.activity_stats
183
- else None
184
- ),
185
- reschedule=(
186
- RescheduleStatisticsData.model_validate(
187
- result.reschedule_stats, from_attributes=True
188
- )
189
- if result.reschedule_stats
190
- else None
191
- ),
192
- )
193
- return response
194
- except TaskValidationError as e:
195
- raise HTTPException(
196
- status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
197
- ) from e
198
-
199
-
200
- @router.get("/tags/statistics", response_model=TagStatisticsResponse)
201
- def get_tag_statistics(
202
- controller: QueryControllerDep,
203
- _client_name: AuthenticatedClientDep,
204
- ) -> TagStatisticsResponse:
205
- """Get tag usage statistics.
206
-
207
- Args:
208
- controller: Query controller dependency
209
-
210
- Returns:
211
- Tag statistics including count and completion rate per tag
212
- """
213
- result = controller.get_tag_statistics()
214
-
215
- # Convert DTO to response model
216
- # Note: TagStatisticsOutput.tag_counts is a dict[str, int]
217
- # completion_rate is not available in the DTO, so we set it to 0.0
218
- tags = [
219
- TagStatisticsItem(tag=tag_name, count=count, completion_rate=0.0)
220
- for tag_name, count in result.tag_counts.items()
221
- ]
222
-
223
- return TagStatisticsResponse(tags=tags, total_tags=result.total_tags)
224
-
225
-
226
- @router.get("/gantt", response_model=TaskListResponse)
227
- def get_gantt_chart(
228
- controller: QueryControllerDep,
229
- holiday_checker: HolidayCheckerDep,
230
- _client_name: AuthenticatedClientDep,
231
- include_archived: Annotated[
232
- bool, Query(alias="all", description="Include archived tasks")
233
- ] = False,
234
- status_filter: Annotated[
235
- str | None, Query(alias="status", description="Filter by status")
236
- ] = None,
237
- tags: Annotated[
238
- list[str] | None, Query(description="Filter by tags (OR logic)")
239
- ] = None,
240
- start_date: Annotated[
241
- str | None, Query(description="Chart start date (ISO format)")
242
- ] = None,
243
- end_date: Annotated[
244
- str | None, Query(description="Chart end date (ISO format)")
245
- ] = None,
246
- sort: Annotated[str, Query(description="Sort field")] = "deadline",
247
- reverse: Annotated[bool, Query(description="Reverse sort order")] = False,
248
- ) -> TaskListResponse:
249
- """Get Gantt chart data with workload calculations.
250
-
251
- Returns the shared task list plus the Gantt overlay (daily hours, workload,
252
- holidays); clients join them by task id.
253
-
254
- Args:
255
- controller: Query controller dependency
256
- holiday_checker: Holiday checker dependency
257
- include_archived: Include archived tasks
258
- status_filter: Filter by task status
259
- tags: Filter by tags (OR logic)
260
- start_date: Chart start date
261
- end_date: Chart end date
262
- sort: Sort field name
263
- reverse: Reverse sort order
264
-
265
- Returns:
266
- Task list with the Gantt overlay populated
267
- """
268
- # Parse date strings to date objects
269
- start = parse_iso_date(start_date)
270
- end = parse_iso_date(end_date)
271
-
272
- # Create Input DTO (filter building is done in Use Case)
273
- input_dto = ListTasksInput(
274
- include_archived=include_archived,
275
- status=status_filter,
276
- tags=tags or [],
277
- start_date=start,
278
- end_date=end,
279
- sort_by=sort,
280
- reverse=reverse,
281
- )
282
-
283
- # Single fetch builds both the task list and the Gantt overlay
284
- result = controller.list_tasks(
285
- input_dto=input_dto,
286
- include_gantt=True,
287
- gantt_start_date=start,
288
- gantt_end_date=end,
289
- holiday_checker=holiday_checker,
290
- )
291
-
292
- return TaskListResponse.from_dto(result)
293
-
294
-
295
- @router.post("/optimize", response_model=OptimizationResponse)
296
- def optimize_schedule(
297
- request: OptimizeScheduleRequest,
298
- controller: AnalyticsControllerDep,
299
- broadcaster: EventBroadcasterDep,
300
- audit_controller: AuditLogControllerDep,
301
- time_provider: TimeProviderDep,
302
- client_name: AuthenticatedClientDep,
303
- ) -> OptimizationResponse:
304
- """Optimize task schedules using specified algorithm.
305
-
306
- Args:
307
- request: Optimization parameters
308
- controller: Analytics controller dependency
309
- broadcaster: Event broadcaster dependency
310
- time_provider: Time provider dependency
311
- client_name: Authenticated client name (used for broadcast exclusion)
312
-
313
- Returns:
314
- Optimization results with summary and failures
315
-
316
- Raises:
317
- HTTPException: 400 if validation fails
318
- """
319
- try:
320
- # Use current date if not specified
321
- start_date = request.start_date or time_provider.now()
322
-
323
- # Run synchronously
324
- result = controller.optimize_schedule(
325
- algorithm=request.algorithm,
326
- start_date=start_date,
327
- max_hours_per_day=request.max_hours_per_day,
328
- force_override=request.force_override,
329
- task_ids=request.task_ids,
330
- include_all_days=request.include_all_days,
331
- )
332
-
333
- # Broadcast WebSocket event in background (exclude the requester by client name)
334
- broadcaster.schedule_optimized(
335
- len(result.successful_tasks),
336
- len(result.failed_tasks),
337
- request.algorithm,
338
- client_name,
339
- )
340
-
341
- # Audit log
342
- audit_controller.log_operation(
343
- operation="optimize_schedule",
344
- resource_type="schedule",
345
- resource_id=None,
346
- resource_name=None,
347
- client_name=client_name,
348
- new_values={
349
- "algorithm": request.algorithm,
350
- "scheduled_tasks": len(result.successful_tasks),
351
- "failed_tasks": len(result.failed_tasks),
352
- "total_hours": result.summary.total_hours,
353
- "task_ids": request.task_ids,
354
- },
355
- success=True,
356
- )
357
-
358
- # Convert DTO to response model
359
- failures = [
360
- SchedulingFailure(task_id=f.task.id, task_name=f.task.name, reason=f.reason)
361
- for f in result.failed_tasks
362
- ]
363
-
364
- # Calculate date range from daily allocations
365
- if result.daily_allocations:
366
- dates = list(result.daily_allocations.keys())
367
- opt_start_date = min(dates)
368
- opt_end_date = max(dates)
369
- else:
370
- opt_start_date = start_date.date()
371
- opt_end_date = start_date.date()
372
-
373
- # Build optimization message
374
- if result.all_failed():
375
- message = (
376
- f"No tasks were scheduled. {len(result.failed_tasks)} task(s) failed."
377
- )
378
- elif result.has_failures():
379
- message = f"Partially optimized: {len(result.successful_tasks)} succeeded, {len(result.failed_tasks)} failed."
380
- else:
381
- message = f"Successfully optimized {len(result.successful_tasks)} task(s)."
382
-
383
- return OptimizationResponse(
384
- summary=OptimizationSummary(
385
- total_tasks=len(result.successful_tasks) + len(result.failed_tasks),
386
- scheduled_tasks=len(result.successful_tasks),
387
- failed_tasks=len(result.failed_tasks),
388
- total_hours=result.summary.total_hours,
389
- start_date=opt_start_date,
390
- end_date=opt_end_date,
391
- algorithm=request.algorithm,
392
- ),
393
- failures=failures,
394
- message=message,
395
- )
396
- except TaskNotFoundException as e:
397
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
398
- except NoSchedulableTasksError as e:
399
- raise HTTPException(
400
- status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
401
- ) from e
402
- except TaskValidationError as e:
403
- raise HTTPException(
404
- status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
405
- ) from e
406
-
407
-
408
- @router.get("/algorithms", response_model=list[dict[str, str]])
409
- def list_algorithms(
410
- controller: QueryControllerDep,
411
- _client_name: AuthenticatedClientDep,
412
- ) -> list[dict[str, str]]:
413
- """List available optimization algorithms.
414
-
415
- Args:
416
- controller: Query controller dependency
417
-
418
- Returns:
419
- List of algorithms with name, display name, and description
420
- """
421
- algorithms = controller.get_algorithm_metadata()
422
- return [
423
- {"name": name, "display_name": display_name, "description": description}
424
- for name, display_name, description in algorithms
425
- ]