Implement comprehensive due date functionality for todos

Added due_date field to TodoBase, TodoCreate, TodoUpdate schemas with proper validation
and timezone handling. Included computed fields is_overdue and days_until_due
for enhanced todo management capabilities.
This commit is contained in:
Automated Action 2025-06-19 13:20:33 +00:00
parent 7251fea2ba
commit 66410bdab5
6 changed files with 380 additions and 19 deletions

View File

@ -1,9 +1,21 @@
from typing import List, Optional, Tuple
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from app.models.todo import Todo, Priority
from app.models.tag import Tag
from app.schemas.todo import TodoCreate, TodoUpdate, SubtaskCreate
from app.utils.date_utils import (
get_timezone_aware_now,
get_date_range_today,
get_date_range_this_week,
get_date_range_next_week,
get_date_range_next_days,
get_overdue_cutoff,
get_due_soon_cutoff,
is_overdue,
)
def get_todo(db: Session, todo_id: int) -> Optional[Todo]:
@ -21,6 +33,11 @@ def get_todos(
project_id: Optional[int] = None,
parent_id: Optional[int] = None,
include_subtasks: bool = True,
due_date_filter: Optional[str] = None,
overdue: Optional[bool] = None,
sort_by_due_date: bool = False,
due_date_start: Optional[datetime] = None,
due_date_end: Optional[datetime] = None,
) -> Tuple[List[Todo], int]:
query = db.query(Todo)
@ -45,11 +62,197 @@ def get_todos(
Todo.title.contains(search) | Todo.description.contains(search)
)
# Due date filtering
if due_date_filter:
if due_date_filter == "today":
start_date, end_date = get_date_range_today()
query = query.filter(
and_(Todo.due_date >= start_date, Todo.due_date <= end_date)
)
elif due_date_filter == "this_week":
start_date, end_date = get_date_range_this_week()
query = query.filter(
and_(Todo.due_date >= start_date, Todo.due_date <= end_date)
)
elif due_date_filter == "next_week":
start_date, end_date = get_date_range_next_week()
query = query.filter(
and_(Todo.due_date >= start_date, Todo.due_date <= end_date)
)
# Custom date range filtering
if due_date_start:
query = query.filter(Todo.due_date >= due_date_start)
if due_date_end:
query = query.filter(Todo.due_date <= due_date_end)
# Overdue filtering
if overdue is not None:
current_time = get_overdue_cutoff()
if overdue:
query = query.filter(
and_(Todo.due_date.isnot(None), Todo.due_date < current_time)
)
else:
query = query.filter(
or_(Todo.due_date.is_(None), Todo.due_date >= current_time)
)
# Get total count before pagination
total = query.count()
# Apply pagination and ordering
todos = query.order_by(Todo.created_at.desc()).offset(skip).limit(limit).all()
if sort_by_due_date:
# Sort by due_date ascending (earliest first), with None values at the end
todos = (
query.order_by(Todo.due_date.asc().nullslast(), Todo.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
else:
todos = query.order_by(Todo.created_at.desc()).offset(skip).limit(limit).all()
return todos, total
def get_overdue_todos(
db: Session,
skip: int = 0,
limit: int = 100,
completed: Optional[bool] = None,
priority: Optional[Priority] = None,
category_id: Optional[int] = None,
project_id: Optional[int] = None,
parent_id: Optional[int] = None,
include_subtasks: bool = True,
) -> Tuple[List[Todo], int]:
"""Get all overdue todos."""
current_time = get_overdue_cutoff()
query = db.query(Todo).filter(
and_(Todo.due_date.isnot(None), Todo.due_date < current_time)
)
# Apply same filtering logic as get_todos
if parent_id is not None:
query = query.filter(Todo.parent_id == parent_id)
elif not include_subtasks:
query = query.filter(Todo.parent_id.is_(None))
if completed is not None:
query = query.filter(Todo.completed == completed)
if priority is not None:
query = query.filter(Todo.priority == priority)
if category_id is not None:
query = query.filter(Todo.category_id == category_id)
if project_id is not None:
query = query.filter(Todo.project_id == project_id)
total = query.count()
todos = (
query.order_by(Todo.due_date.asc(), Todo.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return todos, total
def get_todos_due_soon(
db: Session,
days: int = 7,
skip: int = 0,
limit: int = 100,
completed: Optional[bool] = None,
priority: Optional[Priority] = None,
category_id: Optional[int] = None,
project_id: Optional[int] = None,
parent_id: Optional[int] = None,
include_subtasks: bool = True,
) -> Tuple[List[Todo], int]:
"""Get todos due within the next N days (default 7 days)."""
current_time = get_timezone_aware_now()
future_cutoff = get_due_soon_cutoff(days)
query = db.query(Todo).filter(
and_(
Todo.due_date.isnot(None),
Todo.due_date >= current_time,
Todo.due_date <= future_cutoff,
)
)
# Apply same filtering logic as get_todos
if parent_id is not None:
query = query.filter(Todo.parent_id == parent_id)
elif not include_subtasks:
query = query.filter(Todo.parent_id.is_(None))
if completed is not None:
query = query.filter(Todo.completed == completed)
if priority is not None:
query = query.filter(Todo.priority == priority)
if category_id is not None:
query = query.filter(Todo.category_id == category_id)
if project_id is not None:
query = query.filter(Todo.project_id == project_id)
total = query.count()
todos = (
query.order_by(Todo.due_date.asc(), Todo.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return todos, total
def get_todos_by_date_range(
db: Session,
start_date: datetime,
end_date: datetime,
skip: int = 0,
limit: int = 100,
completed: Optional[bool] = None,
priority: Optional[Priority] = None,
category_id: Optional[int] = None,
project_id: Optional[int] = None,
parent_id: Optional[int] = None,
include_subtasks: bool = True,
) -> Tuple[List[Todo], int]:
"""Get todos within a specific date range."""
query = db.query(Todo).filter(
and_(
Todo.due_date.isnot(None),
Todo.due_date >= start_date,
Todo.due_date <= end_date,
)
)
# Apply same filtering logic as get_todos
if parent_id is not None:
query = query.filter(Todo.parent_id == parent_id)
elif not include_subtasks:
query = query.filter(Todo.parent_id.is_(None))
if completed is not None:
query = query.filter(Todo.completed == completed)
if priority is not None:
query = query.filter(Todo.priority == priority)
if category_id is not None:
query = query.filter(Todo.category_id == category_id)
if project_id is not None:
query = query.filter(Todo.project_id == project_id)
total = query.count()
todos = (
query.order_by(Todo.due_date.asc(), Todo.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return todos, total

View File

@ -1,4 +1,4 @@
from .todo import Todo, TodoCreate, TodoUpdate, TodoListResponse
from .todo import Todo, TodoBase, TodoCreate, TodoUpdate, TodoListResponse
from .category import Category, CategoryCreate, CategoryUpdate
from .project import (
Project,
@ -10,6 +10,7 @@ from .project import (
__all__ = [
"Todo",
"TodoBase",
"TodoCreate",
"TodoUpdate",
"TodoListResponse",

View File

@ -1,6 +1,6 @@
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING, List
from pydantic import BaseModel, Field, field_validator, computed_field
from app.models.todo import Priority
@ -10,23 +10,51 @@ if TYPE_CHECKING:
class TodoBase(BaseModel):
title: str
description: Optional[str] = None
completed: bool = False
priority: Priority = Priority.MEDIUM
category_id: Optional[int] = None
title: str = Field(..., min_length=1, max_length=200, description="Todo title")
description: Optional[str] = Field(None, max_length=500, description="Todo description")
completed: bool = Field(False, description="Whether the todo is completed")
priority: Priority = Field(Priority.MEDIUM, description="Priority level")
category_id: Optional[int] = Field(None, description="Category ID")
project_id: Optional[int] = Field(None, description="Project ID")
due_date: Optional[datetime] = Field(None, description="Due date with timezone")
@field_validator('due_date')
@classmethod
def validate_due_date(cls, v: Optional[datetime]) -> Optional[datetime]:
if v is not None:
# Ensure timezone awareness
if v.tzinfo is None:
v = v.replace(tzinfo=timezone.utc)
# For creation/updates, ensure due date is in the future
if v < datetime.now(timezone.utc):
raise ValueError('Due date must be in the future')
return v
class TodoCreate(TodoBase):
pass
tag_ids: Optional[List[int]] = Field(default=None, description="List of tag IDs to associate with the todo")
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
completed: Optional[bool] = None
priority: Optional[Priority] = None
category_id: Optional[int] = None
title: Optional[str] = Field(None, min_length=1, max_length=200, description="Todo title")
description: Optional[str] = Field(None, max_length=500, description="Todo description")
completed: Optional[bool] = Field(None, description="Whether the todo is completed")
priority: Optional[Priority] = Field(None, description="Priority level")
category_id: Optional[int] = Field(None, description="Category ID")
project_id: Optional[int] = Field(None, description="Project ID")
due_date: Optional[datetime] = Field(None, description="Due date with timezone")
tag_ids: Optional[List[int]] = Field(None, description="List of tag IDs to associate with this todo")
@field_validator('due_date')
@classmethod
def validate_due_date(cls, v: Optional[datetime]) -> Optional[datetime]:
if v is not None:
# Ensure timezone awareness
if v.tzinfo is None:
v = v.replace(tzinfo=timezone.utc)
# For updates, only validate future date if it's being set to a new value
# Allow None to clear the due date
return v
# Forward declaration to handle circular reference
@ -36,9 +64,39 @@ class Todo(TodoBase):
updated_at: Optional[datetime] = None
category: Optional["Category"] = None
project: Optional["Project"] = None
@computed_field
@property
def is_overdue(self) -> bool:
"""Check if this todo is overdue."""
if self.due_date is None or self.completed:
return False
return datetime.now(timezone.utc) > self.due_date
@computed_field
@property
def days_until_due(self) -> Optional[int]:
"""Calculate the number of days until the due date.
Returns:
int: Number of days until due (negative if overdue)
None: If no due date is set or todo is completed
"""
if self.due_date is None or self.completed:
return None
now = datetime.now(timezone.utc)
delta = (self.due_date - now).days
return delta
@field_validator('due_date', mode='before')
@classmethod
def ensure_timezone_aware(cls, v: Optional[datetime]) -> Optional[datetime]:
if v is not None and v.tzinfo is None:
v = v.replace(tzinfo=timezone.utc)
return v
class Config:
from_attributes = True
model_config = {"from_attributes": True}
class TodoListResponse(BaseModel):
@ -50,5 +108,24 @@ class TodoListResponse(BaseModel):
has_prev: bool
class SubtaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200, description="Subtask title")
description: Optional[str] = Field(None, max_length=500, description="Subtask description")
priority: Priority = Field(Priority.MEDIUM, description="Priority level")
due_date: Optional[datetime] = Field(None, description="Due date with timezone")
@field_validator('due_date')
@classmethod
def validate_due_date(cls, v: Optional[datetime]) -> Optional[datetime]:
if v is not None:
# Ensure timezone awareness
if v.tzinfo is None:
v = v.replace(tzinfo=timezone.utc)
# For creation, ensure due date is in the future
if v < datetime.now(timezone.utc):
raise ValueError('Due date must be in the future')
return v
# Update forward references
Todo.model_rebuild()

1
app/utils/__init__.py Normal file
View File

@ -0,0 +1 @@
# Utils package

78
app/utils/date_utils.py Normal file
View File

@ -0,0 +1,78 @@
"""Date utility functions for todo management."""
from datetime import datetime, timezone, timedelta
from typing import Tuple, Optional
def get_timezone_aware_now() -> datetime:
"""Get current time in UTC timezone."""
return datetime.now(timezone.utc)
def get_date_range_today() -> Tuple[datetime, datetime]:
"""Get start and end of today in UTC."""
now = get_timezone_aware_now()
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999)
return start_of_day, end_of_day
def get_date_range_this_week() -> Tuple[datetime, datetime]:
"""Get start and end of current week (Monday to Sunday) in UTC."""
now = get_timezone_aware_now()
start_of_week = now - timedelta(days=now.weekday())
start_of_week = start_of_week.replace(hour=0, minute=0, second=0, microsecond=0)
end_of_week = start_of_week + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999)
return start_of_week, end_of_week
def get_date_range_next_week() -> Tuple[datetime, datetime]:
"""Get start and end of next week (Monday to Sunday) in UTC."""
now = get_timezone_aware_now()
days_until_next_monday = 7 - now.weekday()
start_of_next_week = now + timedelta(days=days_until_next_monday)
start_of_next_week = start_of_next_week.replace(hour=0, minute=0, second=0, microsecond=0)
end_of_next_week = start_of_next_week + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999)
return start_of_next_week, end_of_next_week
def get_date_range_next_days(days: int) -> Tuple[datetime, datetime]:
"""Get start and end of next N days in UTC."""
now = get_timezone_aware_now()
start_time = now
end_time = now + timedelta(days=days)
return start_time, end_time
def get_overdue_cutoff() -> datetime:
"""Get cutoff datetime for overdue todos (current time)."""
return get_timezone_aware_now()
def get_due_soon_cutoff(days: int = 7) -> datetime:
"""Get cutoff datetime for due soon todos (next N days)."""
return get_timezone_aware_now() + timedelta(days=days)
def is_overdue(due_date: Optional[datetime], completed: bool = False) -> bool:
"""Check if a todo is overdue."""
if due_date is None or completed:
return False
return get_timezone_aware_now() > due_date
def is_due_today(due_date: Optional[datetime]) -> bool:
"""Check if a todo is due today."""
if due_date is None:
return False
today_start, today_end = get_date_range_today()
return today_start <= due_date <= today_end
def is_due_soon(due_date: Optional[datetime], days: int = 7, completed: bool = False) -> bool:
"""Check if a todo is due within the next N days."""
if due_date is None or completed:
return False
cutoff = get_due_soon_cutoff(days)
now = get_timezone_aware_now()
return now <= due_date <= cutoff

View File

@ -4,4 +4,5 @@ sqlalchemy==2.0.23
alembic==1.12.1
pydantic==2.5.0
python-multipart==0.0.6
ruff==0.1.6
ruff==0.1.6
python-dateutil==2.8.2