File size: 8,035 Bytes
84c328d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
"""Task model and related I/O classes."""
import uuid
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel, Column
from pydantic import field_validator
from sqlalchemy import ARRAY, String, JSON


class PriorityLevel(str, Enum):
    """Task priority levels.

    Defines the three priority levels for tasks:
    - HIGH: Urgent tasks that need immediate attention
    - MEDIUM: Default priority for normal tasks
    - LOW: Optional tasks that can be done whenever
    """
    HIGH = "HIGH"
    MEDIUM = "MEDIUM"
    LOW = "LOW"


class Task(SQLModel, table=True):
    """Database table model for Task entity."""

    __tablename__ = "tasks"

    id: uuid.UUID = Field(
        default_factory=uuid.uuid4,
        primary_key=True,
        index=True
    )
    user_id: uuid.UUID = Field(
        foreign_key="users.id",
        index=True
    )
    title: str = Field(max_length=255)
    description: Optional[str] = Field(
        default=None,
        max_length=2000
    )
    priority: PriorityLevel = Field(
        default=PriorityLevel.MEDIUM,
        max_length=10
    )
    tags: list[str] = Field(
        default=[],
        sa_column=Column(ARRAY(String), nullable=False),  # PostgreSQL TEXT[] type
    )
    due_date: Optional[datetime] = Field(
        default=None,
        index=True
    )
    # Reminder fields (T003-T004)
    reminder_offset: Optional[int] = Field(
        default=None,
        description="Minutes before due_date to send notification (0 = at due time)"
    )
    reminder_sent: bool = Field(
        default=False,
        description="Whether notification has been sent for this task"
    )
    # Recurrence fields (T005-T006)
    recurrence: Optional[dict] = Field(
        default=None,
        sa_column=Column(JSON, nullable=True),
        description="Recurrence rule as JSONB (frequency, interval, count, end_date)"
    )
    parent_task_id: Optional[uuid.UUID] = Field(
        default=None,
        foreign_key="tasks.id",
        description="For recurring task instances, links to the original task"
    )
    completed: bool = Field(default=False)
    created_at: datetime = Field(
        default_factory=datetime.utcnow
    )
    updated_at: datetime = Field(
        default_factory=datetime.utcnow
    )


class TaskCreate(SQLModel):
    """Request model for creating a task.

    Validates input data when creating a new task.
    """
    title: str = Field(min_length=1, max_length=255)
    description: Optional[str] = Field(default=None, max_length=2000)
    priority: str = Field(default="MEDIUM")
    tags: list[str] = Field(default=[])
    due_date: Optional[datetime] = None
    # Advanced features fields (T007)
    reminder_offset: Optional[int] = Field(default=None, ge=0)
    recurrence: Optional[dict] = Field(default=None)
    completed: bool = False

    @field_validator('priority')
    @classmethod
    def normalize_priority(cls, v: str) -> str:
        """Normalize priority to uppercase."""
        if isinstance(v, str):
            v = v.upper()
        # Validate against enum values
        valid_values = {e.value for e in PriorityLevel}
        if v not in valid_values:
            raise ValueError(f"priority must be one of {valid_values}")
        return v

    @field_validator('tags')
    @classmethod
    def validate_tags(cls, v: list[str]) -> list[str]:
        """Validate tags: max 50 characters per tag, remove duplicates."""
        validated = []
        seen = set()
        for tag in v:
            if len(tag) > 50:
                raise ValueError(f"Tag '{tag[:20]}...' exceeds maximum length of 50 characters")
            # Normalize tag: lowercase and strip whitespace
            normalized = tag.strip().lower()
            if not normalized:
                continue
            if normalized not in seen:
                seen.add(normalized)
                validated.append(normalized)
        return validated

    @field_validator('due_date')
    @classmethod
    def validate_due_date(cls, v: Optional[datetime]) -> Optional[datetime]:
        """Validate due date is not more than 10 years in the past."""
        if v is not None:
            # Normalize to UTC timezone-aware datetime for comparison
            now = datetime.now(timezone.utc)
            if v.tzinfo is None:
                # If input is naive, assume it's UTC
                v = v.replace(tzinfo=timezone.utc)
            else:
                # Convert to UTC
                v = v.astimezone(timezone.utc)

            # Allow dates up to 10 years in the past (for historical tasks)
            min_date = now.replace(year=now.year - 10)
            if v < min_date:
                raise ValueError("Due date cannot be more than 10 years in the past")
        return v


class TaskUpdate(SQLModel):
    """Request model for updating a task.

    All fields are optional - only provided fields will be updated.
    """
    title: Optional[str] = Field(default=None, min_length=1, max_length=255)
    description: Optional[str] = Field(default=None, max_length=2000)
    priority: Optional[str] = None
    tags: Optional[list[str]] = None
    due_date: Optional[datetime] = None
    # Advanced features fields (T008)
    reminder_offset: Optional[int] = Field(default=None, ge=0)
    recurrence: Optional[dict] = None
    completed: Optional[bool] = None

    @field_validator('priority')
    @classmethod
    def normalize_priority(cls, v: Optional[str]) -> Optional[str]:
        """Normalize priority to uppercase."""
        if v is not None and isinstance(v, str):
            v = v.upper()
            # Validate against enum values
            valid_values = {e.value for e in PriorityLevel}
            if v not in valid_values:
                raise ValueError(f"priority must be one of {valid_values}")
        return v

    @field_validator('tags')
    @classmethod
    def validate_tags(cls, v: Optional[list[str]]) -> Optional[list[str]]:
        """Validate tags: max 50 characters per tag, remove duplicates."""
        if v is None:
            return v
        validated = []
        seen = set()
        for tag in v:
            if len(tag) > 50:
                raise ValueError(f"Tag '{tag[:20]}...' exceeds maximum length of 50 characters")
            # Normalize tag: lowercase and strip whitespace
            normalized = tag.strip().lower()
            if not normalized:
                continue
            if normalized not in seen:
                seen.add(normalized)
                validated.append(normalized)
        return validated

    @field_validator('due_date')
    @classmethod
    def validate_due_date(cls, v: Optional[datetime]) -> Optional[datetime]:
        """Validate due date is not more than 10 years in the past."""
        if v is not None:
            # Normalize to UTC timezone-aware datetime for comparison
            now = datetime.now(timezone.utc)
            if v.tzinfo is None:
                # If input is naive, assume it's UTC
                v = v.replace(tzinfo=timezone.utc)
            else:
                # Convert to UTC
                v = v.astimezone(timezone.utc)

            # Allow dates up to 10 years in the past (for historical tasks)
            min_date = now.replace(year=now.year - 10)
            if v < min_date:
                raise ValueError("Due date cannot be more than 10 years in the past")
        return v


class TaskRead(SQLModel):
    """Response model for task data.

    Used for serializing task data in API responses.
    """
    id: uuid.UUID
    user_id: uuid.UUID
    title: str
    description: Optional[str] | None
    priority: PriorityLevel
    tags: list[str]
    due_date: Optional[datetime] | None
    # Advanced features fields (T009)
    reminder_offset: Optional[int] | None
    reminder_sent: bool
    recurrence: Optional[dict] | None
    parent_task_id: Optional[uuid.UUID] | None
    completed: bool
    created_at: datetime
    updated_at: datetime