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:
parent
7251fea2ba
commit
66410bdab5
205
app/crud/todo.py
205
app/crud/todo.py
@ -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
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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
1
app/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Utils package
|
78
app/utils/date_utils.py
Normal file
78
app/utils/date_utils.py
Normal 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
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user