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.
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/PKG-INFO +3 -3
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/README.md +1 -1
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/pyproject.toml +2 -8
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/dependencies.py +8 -4
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/models/requests.py +12 -2
- taskdog_server-0.29.0/src/taskdog_server/api/routers/analytics.py +420 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/tasks.py +6 -3
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/websocket.py +15 -5
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/validators.py +5 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/main.py +20 -1
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/PKG-INFO +3 -3
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/SOURCES.txt +2 -1
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/requires.txt +1 -1
- taskdog_server-0.29.0/tests/test_main.py +39 -0
- taskdog_server-0.28.0/src/taskdog_server/api/routers/analytics.py +0 -425
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/setup.cfg +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/__init__.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/__init__.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/app.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/audit_helpers.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/context.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/error_handlers.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/middleware.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/models/__init__.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/models/responses.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/__init__.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/audit.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/backup.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/bulk.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/lifecycle.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/notes.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/relationships.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/tags.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/utils.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/config/__init__.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/config/server_config_manager.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/infrastructure/__init__.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/infrastructure/logging/__init__.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/infrastructure/logging/config.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/py.typed +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/websocket/__init__.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/websocket/broadcaster.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/websocket/connection_manager.py +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/dependency_links.txt +0 -0
- {taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
...,
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/api/routers/relationships.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/config/server_config_manager.py
RENAMED
|
File without changes
|
{taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/infrastructure/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/infrastructure/logging/config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server/websocket/connection_manager.py
RENAMED
|
File without changes
|
{taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{taskdog_server-0.28.0 → taskdog_server-0.29.0}/src/taskdog_server.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|