diff --git a/app/crud/todo.py b/app/crud/todo.py index 1521e52..d49f205 100644 --- a/app/crud/todo.py +++ b/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 diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 7b472da..f85fc6f 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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", diff --git a/app/schemas/todo.py b/app/schemas/todo.py index a1c0725..d070513 100644 --- a/app/schemas/todo.py +++ b/app/schemas/todo.py @@ -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() diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..67b9db6 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# Utils package \ No newline at end of file diff --git a/app/utils/date_utils.py b/app/utils/date_utils.py new file mode 100644 index 0000000..5b7dd1c --- /dev/null +++ b/app/utils/date_utils.py @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 98f9e19..6932f28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +ruff==0.1.6 +python-dateutil==2.8.2 \ No newline at end of file