vikunja-python 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,217 @@
1
+ """
2
+ Project Models for Vikunja API
3
+
4
+ Covers: models.Project and related request/response types
5
+ API endpoints: /projects, /projects/{id}
6
+ """
7
+
8
+ from pydantic import Field, field_validator
9
+ from datetime import datetime
10
+ from typing import Optional, Any
11
+ from .base import VikunjaBaseModel, User
12
+
13
+
14
+ # ============================================================================
15
+ # View Configuration Models (nested in Project)
16
+ # ============================================================================
17
+
18
+ class ViewFilter(VikunjaBaseModel):
19
+ """
20
+ Filter configuration for a project view.
21
+
22
+ Used in: ProjectView.filter
23
+ """
24
+ s: Optional[str] = Field(None, description="Search term")
25
+ sort_by: Optional[list[str]] = Field(None, description="Fields to sort by")
26
+ order_by: Optional[str] = Field(None, description="Sort order (asc/desc)")
27
+ filter: Optional[str] = Field(None, description="Filter expression (e.g., 'done = false')")
28
+ filter_include_nulls: bool = Field(False, description="Include null values in filter")
29
+
30
+
31
+ class BucketConfiguration(VikunjaBaseModel):
32
+ """
33
+ Bucket configuration for Kanban/board views.
34
+
35
+ Used in: ProjectView.bucket_configuration
36
+
37
+ UNKNOWN: Full structure not yet verified from API response
38
+ TODO: Extract from actual API call with project that has buckets configured
39
+ """
40
+ # UNKNOWN: Field definitions pending API verification
41
+ mode: Optional[str] = Field(None, description="Configuration mode (none/manual)")
42
+ # TODO: Add remaining fields after API inspection
43
+
44
+
45
+ class ProjectView(VikunjaBaseModel):
46
+ """
47
+ View configuration within a project.
48
+
49
+ Vikunja supports multiple view types per project:
50
+ - list: List view
51
+ - gantt: Gantt chart view
52
+ - table: Table/spreadsheet view
53
+ - kanban: Kanban board with buckets
54
+
55
+ Used in: Project.views[]
56
+ """
57
+ id: int = Field(..., description="Unique view identifier")
58
+ title: str = Field(..., description="View name/title")
59
+ project_id: int = Field(..., description="Parent project ID")
60
+ view_kind: str = Field(..., description="View type: list, gantt, table, kanban")
61
+
62
+ # Filter configuration
63
+ filter: Optional[ViewFilter] = Field(None, description="View filter settings")
64
+
65
+ # Positioning
66
+ position: int = Field(0, description="Display order within project")
67
+
68
+ # Bucket configuration (for Kanban views)
69
+ bucket_configuration_mode: str = Field("none", description="Bucket mode: none, manual, automatic")
70
+ bucket_configuration: Optional[BucketConfiguration] = Field(None, description="Bucket settings")
71
+ default_bucket_id: int = Field(0, description="Default bucket for new tasks")
72
+ done_bucket_id: int = Field(0, description="Bucket for completed tasks")
73
+
74
+ # Timestamps
75
+ created: datetime = Field(..., description="Creation timestamp")
76
+ updated: datetime = Field(..., description="Last update timestamp")
77
+
78
+
79
+ # ============================================================================
80
+ # Project Entity Model (18 fields from API)
81
+ # ============================================================================
82
+
83
+ class Project(VikunjaBaseModel):
84
+ """
85
+ Project model representing a project in Vikunja.
86
+
87
+ API endpoint: GET /projects, GET /projects/{id}
88
+
89
+ All 18 fields discovered from actual API response at v2.3.0:
90
+ https://vikunja.ok9.io/api/v1/projects
91
+
92
+ Nested Objects:
93
+ - owner: User object (project creator)
94
+ - views[]: Array of ProjectView objects
95
+ - background_information: null or object (Unsplash background data)
96
+
97
+ Special Values:
98
+ - parent_project_id: 0 = root level project
99
+ - position: 65536 for default Inbox project
100
+ - max_permission: null or int (0=read, 1=write, 2=admin)
101
+ """
102
+
103
+ # Core Identification (5 fields)
104
+ id: int = Field(..., description="Unique project identifier")
105
+ title: str = Field(..., max_length=255, description="Project title")
106
+ description: Optional[str] = Field(None, max_length=10000, description="Project description (markdown)")
107
+ identifier: str = Field("", description="Short identifier/code for project")
108
+ hex_color: str = Field("", description="Project color (without #)")
109
+
110
+ # Hierarchy (2 fields)
111
+ parent_project_id: int = Field(0, description="Parent project ID (0 = root level)")
112
+ owner: User = Field(..., description="Project owner/creator")
113
+
114
+ # Status Flags (2 fields)
115
+ is_archived: bool = Field(False, description="Archived status")
116
+ is_favorite: bool = Field(False, description="Marked as favorite/starred")
117
+
118
+ # Positioning (1 field)
119
+ position: int = Field(0, description="Display order (65536 for default Inbox)")
120
+
121
+ # Settings & Configuration (4 fields)
122
+ background_information: Optional[Any] = Field(None, description="Background image info (Unsplash data)")
123
+ background_blur_hash: str = Field("", description="Blur hash for background preview")
124
+ views: list[ProjectView] = Field(default_factory=list, description="Configured views for this project")
125
+ max_permission: Optional[int] = Field(None, ge=0, le=2, description="Max permission level (0=read, 1=write, 2=admin)")
126
+
127
+ # Timestamps (2 fields)
128
+ created: datetime = Field(..., description="Creation timestamp")
129
+ updated: datetime = Field(..., description="Last update timestamp")
130
+
131
+
132
+ # ============================================================================
133
+ # Project Request Models (for Create/Update operations)
134
+ # ============================================================================
135
+
136
+ class ProjectCreateRequest(VikunjaBaseModel):
137
+ """
138
+ Request body for creating a new project.
139
+
140
+ Required fields: title
141
+ Optional fields: All other project properties
142
+
143
+ API endpoint: PUT /projects
144
+ """
145
+ title: str = Field(..., min_length=1, max_length=255, description="Project title (required)")
146
+
147
+ # Optional fields - only send if explicitly set
148
+ description: Optional[str] = Field(None, max_length=10000)
149
+ identifier: Optional[str] = None
150
+ hex_color: Optional[str] = None
151
+
152
+ # Hierarchy
153
+ parent_project_id: Optional[int] = Field(None, description="Parent project ID (omit for root)")
154
+
155
+ def model_dump_for_api(self) -> dict:
156
+ """Convert to dict, excluding None values."""
157
+ return self.model_dump(exclude_none=True)
158
+
159
+
160
+ class ProjectUpdateRequest(VikunjaBaseModel):
161
+ """
162
+ Request body for updating an existing project.
163
+
164
+ All fields optional - only provided fields are updated.
165
+
166
+ API endpoint: POST /projects/{id} (PATCH semantics)
167
+ """
168
+ title: Optional[str] = Field(None, min_length=1, max_length=255)
169
+ description: Optional[str] = Field(None, max_length=10000)
170
+ identifier: Optional[str] = None
171
+ hex_color: Optional[str] = None
172
+ is_archived: Optional[bool] = None
173
+ is_favorite: Optional[bool] = None
174
+
175
+ # Hierarchy (moving projects)
176
+ parent_project_id: Optional[int] = None
177
+
178
+ def model_dump_for_api(self) -> dict:
179
+ """Convert to dict, excluding None values."""
180
+ return self.model_dump(exclude_none=True)
181
+
182
+
183
+ # ============================================================================
184
+ # Project List Request/Response Models
185
+ # ============================================================================
186
+
187
+ class ProjectListRequest(VikunjaBaseModel):
188
+ """
189
+ Query parameters for listing projects.
190
+
191
+ API endpoint: GET /projects
192
+
193
+ Pagination headers returned:
194
+ x-pagination-total-pages
195
+ x-pagination-result-count
196
+ """
197
+ # Pagination
198
+ page: int = Field(1, ge=1, description="Page number (1-based)")
199
+ per_page: int = Field(50, ge=1, le=100, description="Items per page")
200
+
201
+ # Filtering
202
+ search: Optional[str] = Field(None, description="Search term for project title/description")
203
+ sort_by: Optional[str] = Field(None, description="Field to sort by (e.g., 'title', 'updated')")
204
+
205
+
206
+ class ProjectListResponse(VikunjaBaseModel):
207
+ """
208
+ Response for project list operations.
209
+
210
+ Includes pagination metadata from response headers.
211
+ """
212
+ success: bool = Field(True, description="Request succeeded")
213
+ projects: list[Project] = Field(default_factory=list, description="List of projects")
214
+ total_count: Optional[int] = Field(None, description="Total number of projects (across all pages)")
215
+ page: Optional[int] = Field(None, description="Current page number")
216
+ per_page: Optional[int] = Field(None, description="Items per page")
217
+ total_pages: Optional[int] = Field(None, description="Total number of pages")
@@ -0,0 +1,199 @@
1
+ """
2
+ Task Relationship Models for Vikunja API
3
+
4
+ Covers: models.TaskRelation, models.RelationKind, and related types
5
+ API endpoints: /tasks/{id}/relations (implied - relationships are part of task CRUD)
6
+
7
+ Relationship Types (12 kinds):
8
+ - subtask/parenttask: Hierarchical parent-child relationships
9
+ - blocking/blocked: Dependency relationships (A blocks B means B can't start until A done)
10
+ - precedes/follows: Sequential ordering (A precedes B means A comes before B)
11
+ - related: Generic association
12
+ - duplicateof/duplicates: Duplicate task tracking
13
+ - copiedfrom/copiedto: Source/destination of task copies
14
+
15
+ Usage Examples:
16
+ # Create a subtask relationship (Task 2 is a subtask of Task 1)
17
+ relation = TaskRelation(
18
+ task_id=2, # This task (the child/subtask)
19
+ other_task_id=1, # Related task (the parent)
20
+ relation_kind="subtask" # This task IS A subtask of other_task
21
+ )
22
+
23
+ # Create a blocking relationship (Task 1 blocks Task 2)
24
+ relation = TaskRelation(
25
+ task_id=1, # This task (the blocker)
26
+ other_task_id=2, # Blocked task (depends on this one)
27
+ relation_kind="blocking" # This task BLOCKS the other
28
+ )
29
+ """
30
+
31
+ from pydantic import Field
32
+ from datetime import datetime
33
+ from typing import Optional, Literal
34
+ from .base import VikunjaBaseModel, User
35
+
36
+
37
+ # ============================================================================
38
+ # Relation Kind Enum (12 types)
39
+ # ============================================================================
40
+
41
+ RelationKind = Literal[
42
+ "unknown", # Default/undefined relationship
43
+ "subtask", # This task IS A subtask of other_task (child → parent)
44
+ "parenttask", # This task HAS OTHER as subtask (parent → child)
45
+ "related", # Generic association (bidirectional)
46
+ "duplicateof", # This task IS A DUPLICATE OF other_task
47
+ "duplicates", # This task HAS OTHER AS duplicate (reverse of duplicateof)
48
+ "blocking", # This task BLOCKS other_task (other can't start until this done)
49
+ "blocked", # This task IS BLOCKED BY other_task (depends on other)
50
+ "precedes", # This task COMES BEFORE other_task (sequential)
51
+ "follows", # This task COMES AFTER other_task (sequential)
52
+ "copiedfrom", # This task WAS COPIED FROM other_task (source)
53
+ "copiedto", # This task IS A COPY IN other_task (destination)
54
+ ]
55
+
56
+ # Human-readable descriptions for each relation kind
57
+ RELATION_KIND_DESCRIPTIONS: dict[str, str] = {
58
+ "unknown": "Undefined relationship type",
59
+ "subtask": "This task is a subtask of the other task (child relationship)",
60
+ "parenttask": "This task has the other task as a subtask (parent relationship)",
61
+ "related": "Generic association between tasks",
62
+ "duplicateof": "This task is a duplicate of the other task",
63
+ "duplicates": "The other task is a duplicate of this task",
64
+ "blocking": "This task blocks the other task (other depends on this)",
65
+ "blocked": "This task is blocked by the other task (this depends on other)",
66
+ "precedes": "This task must be completed before the other task",
67
+ "follows": "This task comes after the other task in sequence",
68
+ "copiedfrom": "This task was copied from the other task (original)",
69
+ "copiedto": "This task is a copy of the other task (duplicate)",
70
+ }
71
+
72
+
73
+ # ============================================================================
74
+ # Task Relation Model
75
+ # ============================================================================
76
+
77
+ class TaskRelation(VikunjaBaseModel):
78
+ """
79
+ Relationship between two tasks.
80
+
81
+ API endpoint: Part of task CRUD, managed via /tasks/{id}/relations
82
+
83
+ Key Concept: The relationship is FROM this task TO another task.
84
+
85
+ Examples:
86
+ # Task 5 is a subtask of Task 1
87
+ relation = TaskRelation(
88
+ task_id=5, # This task (the subtask)
89
+ other_task_id=1, # Parent task
90
+ relation_kind="subtask" # "I am a subtask of other"
91
+ )
92
+
93
+ # Task 3 blocks Task 7 (Task 7 can't start until Task 3 done)
94
+ relation = TaskRelation(
95
+ task_id=3, # This task (the blocker)
96
+ other_task_id=7, # Blocked task
97
+ relation_kind="blocking" # "I block the other task"
98
+ )
99
+
100
+ # Task 2 is blocked by Task 4 (Task 2 depends on Task 4)
101
+ relation = TaskRelation(
102
+ task_id=2, # This task (the one waiting)
103
+ other_task_id=4, # Blocking task
104
+ relation_kind="blocked" # "I am blocked by the other task"
105
+ )
106
+
107
+ Common Patterns:
108
+ - Subtask hierarchy: Use "subtask" from child to parent
109
+ - Dependencies: Use "blocking" for blocker, "blocked" for dependent
110
+ - Sequence: Use "precedes" for earlier task, "follows" for later
111
+
112
+ Fields from API spec (models.TaskRelation):
113
+ - task_id: The "base" task (this task)
114
+ - other_task_id: The related task
115
+ - relation_kind: Type of relationship
116
+ - created: Auto-set timestamp
117
+ - created_by: User who created the relation
118
+ """
119
+
120
+ # Core Identification (2 fields)
121
+ id: Optional[int] = Field(None, description="Unique relation identifier (auto-assigned)")
122
+ task_id: int = Field(..., description="This task's ID (the 'base' task)")
123
+
124
+ # Relationship Definition (2 fields)
125
+ other_task_id: int = Field(..., description="ID of the related task")
126
+ relation_kind: RelationKind = Field(..., description="Type of relationship")
127
+
128
+ # Metadata (2 fields - auto-managed by server)
129
+ created: Optional[datetime] = Field(None, description="Creation timestamp (read-only)")
130
+ created_by: Optional[User] = Field(None, description="User who created this relation")
131
+
132
+ @property
133
+ def relationship_description(self) -> str:
134
+ """Human-readable description of this relationship."""
135
+ kind_desc = RELATION_KIND_DESCRIPTIONS.get(self.relation_kind, "Unknown relationship")
136
+ return f"Task {self.task_id} {kind_desc.lower()} (related task: {self.other_task_id})"
137
+
138
+
139
+ # ============================================================================
140
+ # Task Relation Request Models
141
+ # ============================================================================
142
+
143
+ class TaskRelationCreateRequest(VikunjaBaseModel):
144
+ """
145
+ Request body for creating a task relationship.
146
+
147
+ Required fields: task_id, other_task_id, relation_kind
148
+
149
+ API endpoint: POST /tasks/{id}/relations (implied)
150
+
151
+ Example:
152
+ # Make Task 5 a subtask of Task 1
153
+ req = TaskRelationCreateRequest(
154
+ task_id=5,
155
+ other_task_id=1,
156
+ relation_kind="subtask"
157
+ )
158
+ """
159
+ task_id: int = Field(..., description="This task's ID (the base task)")
160
+ other_task_id: int = Field(..., description="Related task's ID")
161
+ relation_kind: RelationKind = Field(..., description="Type of relationship")
162
+
163
+ def model_dump_for_api(self) -> dict:
164
+ """Convert to dict for API submission."""
165
+ return self.model_dump(exclude_none=True)
166
+
167
+
168
+ class TaskRelationUpdateRequest(VikunjaBaseModel):
169
+ """
170
+ Request body for updating a task relationship.
171
+
172
+ Only provided fields are updated.
173
+
174
+ API endpoint: POST /tasks/{id}/relations/{relation_id} (implied)
175
+ """
176
+ other_task_id: Optional[int] = None
177
+ relation_kind: Optional[RelationKind] = None
178
+
179
+ def model_dump_for_api(self) -> dict:
180
+ """Convert to dict, excluding None values."""
181
+ return self.model_dump(exclude_none=True)
182
+
183
+
184
+ # ============================================================================
185
+ # Task Relation Response Models
186
+ # ============================================================================
187
+
188
+ class TaskRelationListResponse(VikunjaBaseModel):
189
+ """Response for listing task relationships."""
190
+ success: bool = Field(True, description="Request succeeded")
191
+ relations: list[TaskRelation] = Field(default_factory=list, description="List of task relations")
192
+ error: Optional[str] = Field(None, description="Error message if failed")
193
+
194
+
195
+ class TaskRelationGetResponse(VikunjaBaseModel):
196
+ """Response for getting a single task relationship."""
197
+ success: bool = Field(True, description="Request succeeded")
198
+ relation: Optional[TaskRelation] = Field(None, description="The task relation")
199
+ error: Optional[str] = Field(None, description="Error message if failed")
@@ -0,0 +1,261 @@
1
+ """
2
+ Task Models for Vikunja API
3
+
4
+ Covers: models.Task and related request/response types
5
+ API endpoints: /tasks, /projects/{id}/tasks, /tasks/{id}
6
+ """
7
+
8
+ from pydantic import Field, field_validator, model_validator
9
+ from datetime import datetime
10
+ from typing import Optional, Any
11
+ from .base import VikunjaBaseModel, User, Label
12
+
13
+
14
+ # ============================================================================
15
+ # Task Entity Model (27 fields from API)
16
+ # ============================================================================
17
+
18
+ class Task(VikunjaBaseModel):
19
+ """
20
+ Task model representing a single task in Vikunja.
21
+
22
+ API endpoint: GET /tasks, GET /tasks/{id}
23
+
24
+ All 27 fields discovered from actual API response at v2.3.0:
25
+ https://vikunja.ok9.io/api/v1/tasks
26
+
27
+ Date Format: ISO 8601 with timezone (e.g., "2026-04-27T14:51:09-05:00")
28
+ Default Dates: "0001-01-01T00:00:00Z" represents null/unset
29
+
30
+ Nested Objects:
31
+ - assignees[]: User objects
32
+ - labels[]: Full Label objects
33
+ - created_by: User object
34
+ - related_tasks: dict (relation types)
35
+
36
+ Nullable Fields (return null in API):
37
+ - attachments, reminders, reactions
38
+ """
39
+
40
+ # Core Identification (6 fields)
41
+ id: int = Field(..., description="Unique task identifier")
42
+ title: str = Field(..., max_length=255, description="Task title (required)")
43
+ description: Optional[str] = Field(None, max_length=10000, description="Task description (markdown)")
44
+ identifier: str = Field(..., description="Task identifier within project (e.g., '#1')")
45
+ index: float = Field(0.0, description="Task index for ordering within view/bucket")
46
+ project_id: int = Field(..., description="Parent project ID")
47
+
48
+ # Status Fields (3 fields)
49
+ done: bool = Field(False, description="Completion status")
50
+ done_at: Optional[datetime] = Field(None, description="Timestamp when task was marked done")
51
+ percent_done: float = Field(0.0, ge=0.0, le=100.0, description="Completion percentage (0-100)")
52
+
53
+ # Priority & Importance (3 fields)
54
+ priority: int = Field(0, ge=0, le=5, description="Task priority (0-5, higher = more important)")
55
+ is_favorite: bool = Field(False, description="Marked as favorite/starred")
56
+ hex_color: str = Field("", description="Custom hex color for task (without #)")
57
+
58
+ # Date/Time Fields (6 fields) - All ISO 8601 with timezone
59
+ created: datetime = Field(..., description="Creation timestamp")
60
+ updated: datetime = Field(..., description="Last update timestamp")
61
+ due_date: Optional[datetime] = Field(None, description="Due date/time")
62
+ start_date: Optional[datetime] = Field(None, description="Start date/time")
63
+ end_date: Optional[datetime] = Field(None, description="End date/time")
64
+
65
+ # Note: "0001-01-01T00:00:00Z" is returned for unset dates - handle in validator
66
+
67
+ # Recurrence Fields (2 fields)
68
+ repeat_after: int = Field(0, ge=0, description="Recurrence interval in seconds")
69
+ repeat_mode: int = Field(0, description="Recurrence mode (1=after completion, 2=after original due date)")
70
+
71
+ # Positioning Fields (3 fields) - For Kanban/board views
72
+ bucket_id: int = Field(0, description="Bucket ID in board view")
73
+ position: float = Field(0.0, description="Position within bucket")
74
+
75
+ # Nested Objects (4 fields)
76
+ assignees: Optional[list[User]] = Field(default_factory=list, description="Users assigned to this task")
77
+ labels: Optional[list[Label]] = Field(default_factory=list, description="Labels/tags on this task")
78
+ created_by: Optional[User] = Field(None, description="User who created this task")
79
+ related_tasks: Optional[dict[str, Any]] = Field(default_factory=dict, description="Related tasks by relation type")
80
+ subtasks: Optional[list['Task']] = Field(default_factory=list, description="Subtasks of this task (requires expand=subtasks)")
81
+
82
+ # Nullable Fields (3 fields) - May be null in API response
83
+ attachments: Optional[list[Any]] = Field(None, description="File attachments (requires expand=attachments)")
84
+ reminders: Optional[list[Any]] = Field(None, description="Task reminders (requires expand=reminders)")
85
+ reactions: Optional[list[Any]] = Field(None, description="Emoji reactions (requires expand=reactions)")
86
+
87
+ # Custom Fields (1 field) - UNKNOWN: Full structure not verified
88
+ custom_fields: Optional[dict[str, Any]] = Field(None, description="Custom field values")
89
+
90
+ # Expanded Fields (not in base task summary, but available via expand)
91
+ comment_count: Optional[int] = Field(None, description="Count of comments on this task")
92
+ is_unread: Optional[bool] = Field(None, description="Whether the task is unread for the current user")
93
+ buckets: Optional[list[dict[str, Any]]] = Field(None, description="Kanban buckets this task belongs to")
94
+
95
+ @model_validator(mode='after')
96
+ def populate_subtasks_from_relations(self) -> 'Task':
97
+ """Populate subtasks field from related_tasks['subtask'] if expanded."""
98
+ if not self.subtasks and self.related_tasks and "subtask" in self.related_tasks:
99
+ rels = self.related_tasks["subtask"]
100
+ if rels and isinstance(rels, list) and len(rels) > 0:
101
+ # API might return dicts or Task objects
102
+ # Use type(self) to avoid circular imports during definition
103
+ cls = type(self)
104
+ self.subtasks = [cls(**r) if isinstance(r, dict) else r for r in rels]
105
+ return self
106
+
107
+ @field_validator('start_date', 'end_date', mode='before')
108
+ @classmethod
109
+ def handle_default_dates(cls, v):
110
+ """Convert '0001-01-01T00:00:00Z' to None for unset dates."""
111
+ if v == "0001-01-01T00:00:00Z":
112
+ return None
113
+ return v
114
+
115
+ @field_validator('due_date', 'done_at', mode='before')
116
+ @classmethod
117
+ def parse_datetime(cls, v):
118
+ """Parse ISO 8601 datetime strings."""
119
+ if v is None or v == "":
120
+ return None
121
+ # Handle timezone offset format (e.g., -05:00)
122
+ if isinstance(v, str):
123
+ try:
124
+ # Python 3.11+ handles timezone offsets natively
125
+ return datetime.fromisoformat(v)
126
+ except ValueError:
127
+ return None
128
+ return v
129
+
130
+
131
+ # ============================================================================
132
+ # Task Request Models (for Create/Update operations)
133
+ # ============================================================================
134
+
135
+ class TaskCreateRequest(VikunjaBaseModel):
136
+ """
137
+ Request body for creating a new task.
138
+
139
+ Required fields: title
140
+ Optional fields: All other task properties
141
+
142
+ API endpoint: POST /tasks, POST /projects/{id}/tasks
143
+ """
144
+ title: str = Field(..., min_length=1, max_length=255, description="Task title (required)")
145
+
146
+ # Optional fields - only send if explicitly set
147
+ description: Optional[str] = Field(None, max_length=10000)
148
+ priority: Optional[int] = Field(None, ge=0, le=5)
149
+ due_date: Optional[datetime] = None
150
+ start_date: Optional[datetime] = None
151
+ end_date: Optional[datetime] = None
152
+ done: Optional[bool] = Field(None, description="Set initial completion status")
153
+
154
+ # Recurrence
155
+ repeat_after: Optional[int] = Field(None, ge=0)
156
+ repeat_mode: Optional[int] = Field(None)
157
+
158
+ # Assignments (IDs, not full objects)
159
+ assignee_ids: Optional[list[int]] = Field(None, description="User IDs to assign")
160
+ label_ids: Optional[list[int]] = Field(None, description="Label IDs to apply")
161
+
162
+ # Positioning
163
+ bucket_id: Optional[int] = None
164
+ project_id: Optional[int] = None
165
+
166
+ def model_dump_for_api(self) -> dict:
167
+ """
168
+ Convert to dict, excluding None values.
169
+
170
+ Vikunja API expects only provided fields - don't send nulls.
171
+ """
172
+ return self.model_dump(exclude_none=True)
173
+
174
+
175
+ class TaskUpdateRequest(VikunjaBaseModel):
176
+ """
177
+ Request body for updating an existing task.
178
+
179
+ All fields optional - only provided fields are updated.
180
+
181
+ API endpoint: POST /tasks/{id} (PATCH semantics)
182
+ """
183
+ title: Optional[str] = Field(None, min_length=1, max_length=255)
184
+ description: Optional[str] = Field(None, max_length=10000)
185
+ priority: Optional[int] = Field(None, ge=0, le=5)
186
+ done: Optional[bool] = None
187
+ due_date: Optional[datetime] = None
188
+ start_date: Optional[datetime] = None
189
+ end_date: Optional[datetime] = None
190
+ percent_done: Optional[float] = Field(None, ge=0.0, le=100.0)
191
+ is_favorite: Optional[bool] = None
192
+ hex_color: Optional[str] = None
193
+
194
+ # Recurrence
195
+ repeat_after: Optional[int] = Field(None, ge=0)
196
+ repeat_mode: Optional[int] = None
197
+
198
+ # Positioning
199
+ bucket_id: Optional[int] = None
200
+ position: Optional[float] = None
201
+
202
+ def model_dump_for_api(self) -> dict:
203
+ """Convert to dict, excluding None values."""
204
+ return self.model_dump(exclude_none=True)
205
+
206
+
207
+ # ============================================================================
208
+ # Task List Request/Response Models
209
+ # ============================================================================
210
+
211
+ class TaskListRequest(VikunjaBaseModel):
212
+ """
213
+ Query parameters for listing tasks.
214
+
215
+ API endpoint: GET /tasks, GET /projects/{id}/tasks
216
+
217
+ Pagination headers returned:
218
+ x-pagination-total-pages
219
+ x-pagination-result-count
220
+ """
221
+ # Filtering
222
+ project_id: Optional[int] = Field(None, description="Filter by project ID")
223
+ filter: Optional[str] = Field(None, description="Vikunja filter syntax (e.g., 'done = false')")
224
+ filter_timezone: Optional[str] = Field(None, description="Timezone for date filters")
225
+ filter_include_nulls: bool = Field(False, description="Include null-valued fields in filter")
226
+
227
+ # Pagination
228
+ page: int = Field(1, ge=1, description="Page number (1-based)")
229
+ per_page: int = Field(50, ge=1, le=100, description="Items per page")
230
+
231
+ # Sorting
232
+ sort_by: Optional[list[str]] = Field(None, description="Fields to sort by (e.g., ['due_date', 'priority'])")
233
+ order_by: Optional[str] = Field(None, pattern="^(asc|desc)$", description="Sort order")
234
+
235
+ # Expansion (fetch nested objects)
236
+ expand: Optional[list[str]] = Field(
237
+ None,
238
+ description=(
239
+ "Expand nested objects to include metadata in summaries. "
240
+ "Valid: subtasks, comments, reactions, buckets, comment_count, is_unread. "
241
+ "Invalid (412): attachments, reminders, assignees — explicitly marked as do-not-use for listing. "
242
+ "Note: list_tasks() returns summaries only (no full descriptions). Use get_task() for full details."
243
+ )
244
+ )
245
+
246
+
247
+ class TaskListResponse(VikunjaBaseModel):
248
+ """
249
+ Response for task list operations.
250
+
251
+ Includes pagination metadata from response headers.
252
+ """
253
+ success: bool = Field(True, description="Request succeeded")
254
+ tasks: list[Task] = Field(default_factory=list, description="List of tasks")
255
+ total_count: Optional[int] = Field(None, description="Total number of tasks (across all pages)")
256
+ page: Optional[int] = Field(None, description="Current page number")
257
+ per_page: Optional[int] = Field(None, description="Items per page")
258
+ total_pages: Optional[int] = Field(None, description="Total number of pages")
259
+
260
+ # Rebuild model for recursive subtasks
261
+ Task.model_rebuild()