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 typing import List, Optional, Tuple
from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from app.models.todo import Todo, Priority from app.models.todo import Todo, Priority
from app.models.tag import Tag from app.models.tag import Tag
from app.schemas.todo import TodoCreate, TodoUpdate, SubtaskCreate 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]: def get_todo(db: Session, todo_id: int) -> Optional[Todo]:
@ -21,6 +33,11 @@ def get_todos(
project_id: Optional[int] = None, project_id: Optional[int] = None,
parent_id: Optional[int] = None, parent_id: Optional[int] = None,
include_subtasks: bool = True, 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]: ) -> Tuple[List[Todo], int]:
query = db.query(Todo) query = db.query(Todo)
@ -45,11 +62,197 @@ def get_todos(
Todo.title.contains(search) | Todo.description.contains(search) 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 # Get total count before pagination
total = query.count() total = query.count()
# Apply pagination and ordering # 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 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 .category import Category, CategoryCreate, CategoryUpdate
from .project import ( from .project import (
Project, Project,
@ -10,6 +10,7 @@ from .project import (
__all__ = [ __all__ = [
"Todo", "Todo",
"TodoBase",
"TodoCreate", "TodoCreate",
"TodoUpdate", "TodoUpdate",
"TodoListResponse", "TodoListResponse",

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING, List
from pydantic import BaseModel from pydantic import BaseModel, Field, field_validator, computed_field
from app.models.todo import Priority from app.models.todo import Priority
@ -10,23 +10,51 @@ if TYPE_CHECKING:
class TodoBase(BaseModel): class TodoBase(BaseModel):
title: str title: str = Field(..., min_length=1, max_length=200, description="Todo title")
description: Optional[str] = None description: Optional[str] = Field(None, max_length=500, description="Todo description")
completed: bool = False completed: bool = Field(False, description="Whether the todo is completed")
priority: Priority = Priority.MEDIUM priority: Priority = Field(Priority.MEDIUM, description="Priority level")
category_id: Optional[int] = None 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): 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): class TodoUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = Field(None, min_length=1, max_length=200, description="Todo title")
description: Optional[str] = None description: Optional[str] = Field(None, max_length=500, description="Todo description")
completed: Optional[bool] = None completed: Optional[bool] = Field(None, description="Whether the todo is completed")
priority: Optional[Priority] = None priority: Optional[Priority] = Field(None, description="Priority level")
category_id: Optional[int] = None 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 # Forward declaration to handle circular reference
@ -36,9 +64,39 @@ class Todo(TodoBase):
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
category: Optional["Category"] = None category: Optional["Category"] = None
project: Optional["Project"] = 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: model_config = {"from_attributes": True}
from_attributes = True
class TodoListResponse(BaseModel): class TodoListResponse(BaseModel):
@ -50,5 +108,24 @@ class TodoListResponse(BaseModel):
has_prev: bool 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 # Update forward references
Todo.model_rebuild() 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 alembic==1.12.1
pydantic==2.5.0 pydantic==2.5.0
python-multipart==0.0.6 python-multipart==0.0.6
ruff==0.1.6 ruff==0.1.6
python-dateutil==2.8.2