flask_template_codex/app/tasks/services.py
2026-08-08 05:37:41 +02:00

342 lines
8.7 KiB
Python

from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from uuid import uuid4
from sqlalchemy import case, or_, select
from sqlalchemy.orm import selectinload
from app.extensions import db
from .models import TASK_PRIORITIES, TASK_STATUSES, Subtask, Task
class TaskNotFoundError(LookupError):
pass
@dataclass(frozen=True)
class ForgivingStreak:
days: int
freeze_used: bool
@dataclass(frozen=True)
class TaskPage:
items: list[Task]
page: int
pages: int
total: int
has_prev: bool
has_next: bool
def _task_query(user_id: int):
return (
select(Task)
.where(Task.user_id == user_id, Task.cleared_at.is_(None))
.options(selectinload(Task.subtasks))
)
def _task_order():
priority_order = case(
{"urgent": 0, "important": 1, "normal": 2},
value=Task.priority,
else_=3,
)
return (
case((Task.status == "done", 1), else_=0),
priority_order,
Task.due_date.is_(None),
Task.due_date,
Task.created_at,
)
def get_task(task_id: int, user_id: int) -> Task:
task = db.session.scalar(_task_query(user_id).where(Task.id == task_id))
if task is None:
raise TaskNotFoundError
return task
def list_tasks(user_id: int, status: str | None = None) -> list[Task]:
query = _task_query(user_id)
if status is not None:
if status not in TASK_STATUSES:
raise ValueError("Unknown task status.")
query = query.where(Task.status == status)
return list(db.session.scalars(query.order_by(*_task_order())))
def paginate_tasks(
user_id: int,
*,
status: str | None = None,
page: int = 1,
per_page: int = 50,
) -> TaskPage:
query = _task_query(user_id)
if status is not None:
if status not in TASK_STATUSES:
raise ValueError("Unknown task status.")
query = query.where(Task.status == status)
pagination = db.paginate(
query.order_by(*_task_order()),
page=page,
per_page=per_page,
max_per_page=50,
error_out=False,
)
return TaskPage(
items=list(pagination.items),
page=pagination.page,
pages=pagination.pages,
total=pagination.total,
has_prev=pagination.has_prev,
has_next=pagination.has_next,
)
def list_today_tasks(
user_id: int,
*,
now: datetime | None = None,
limit: int = 7,
) -> list[Task]:
local_now = now or datetime.now().astimezone()
end_of_today = datetime.combine(local_now.date(), time.max)
query = (
_task_query(user_id)
.where(Task.status != "done")
.where(or_(Task.due_date.is_(None), Task.due_date <= end_of_today))
.order_by(*_task_order())
.limit(limit)
)
return list(db.session.scalars(query))
def create_task(
user_id: int,
*,
title: str,
description: str | None,
status: str,
priority: str,
due_date: datetime | None,
) -> Task:
_validate_choices(status, priority)
task = Task(
user_id=user_id,
title=title.strip(),
description=_clean_optional(description),
status=status,
priority=priority,
due_date=due_date,
)
if status == "done":
task.completed_at = datetime.now(timezone.utc)
db.session.add(task)
db.session.commit()
return task
def update_task(
task: Task,
*,
title: str,
description: str | None,
status: str,
priority: str,
due_date: datetime | None,
) -> Task:
_validate_choices(status, priority)
was_done = task.status == "done"
task.title = title.strip()
task.description = _clean_optional(description)
task.status = status
task.priority = priority
task.due_date = due_date
if status == "done" and not was_done:
task.completed_at = datetime.now(timezone.utc)
elif status != "done":
task.completed_at = None
db.session.commit()
return task
def delete_task(task: Task) -> None:
db.session.delete(task)
db.session.commit()
def add_subtask(task: Task, title: str) -> Subtask:
subtask = Subtask(task=task, title=title.strip())
db.session.add(subtask)
db.session.commit()
return subtask
def toggle_subtask(task: Task, subtask_id: int) -> Subtask:
subtask = next(
(item for item in task.subtasks if item.id == subtask_id),
None,
)
if subtask is None:
raise TaskNotFoundError
subtask.completed = not subtask.completed
db.session.commit()
return subtask
def configure_focus(
task: Task,
*,
timer_minutes: int | None,
context: str | None,
chunk_minutes: list[int],
) -> Task:
existing_sessions = [
_normalize_chunk_session(session) for session in task.chunk_sessions
]
sessions = []
for index, minutes in enumerate(chunk_minutes):
completed = (
index < len(existing_sessions)
and existing_sessions[index]["minutes"] == minutes
and existing_sessions[index]["completed"]
)
sessions.append({"minutes": minutes, "completed": completed})
task.timer_seconds = (timer_minutes or 0) * 60
task.context = _clean_optional(context)
task.chunk_sessions = sessions
db.session.commit()
return task
def toggle_chunk_session(task: Task, chunk_index: int) -> Task:
sessions = [
_normalize_chunk_session(session) for session in task.chunk_sessions
]
if chunk_index < 0 or chunk_index >= len(sessions):
raise TaskNotFoundError
sessions[chunk_index]["completed"] = not sessions[chunk_index]["completed"]
task.chunk_sessions = sessions
db.session.commit()
return task
def complete_task(task: Task, *, completed_at: datetime | None = None) -> Task:
task.status = "done"
task.completed_at = completed_at or datetime.now(timezone.utc)
db.session.commit()
return task
def calculate_forgiving_streak(
user_id: int,
*,
today: date | None = None,
) -> ForgivingStreak:
completion_dates = {
completed_at.date()
for completed_at in db.session.scalars(
select(Task.completed_at).where(
Task.user_id == user_id,
Task.completed_at.is_not(None),
)
)
if completed_at is not None
}
current_day = today or datetime.now().astimezone().date()
# The current day is never treated as missed while it is still in progress.
cursor = (
current_day
if current_day in completion_dates
else current_day - timedelta(days=1)
)
if cursor not in completion_dates:
return ForgivingStreak(days=0, freeze_used=False)
days = 0
freeze_used = False
while True:
if cursor in completion_dates:
days += 1
cursor -= timedelta(days=1)
continue
previous_day = cursor - timedelta(days=1)
if not freeze_used and previous_day in completion_dates:
freeze_used = True
cursor = previous_day
continue
break
return ForgivingStreak(days=days, freeze_used=freeze_used)
def clear_completed_tasks(user_id: int) -> tuple[str | None, int]:
tasks = list(
db.session.scalars(
select(Task).where(
Task.user_id == user_id,
Task.status == "done",
Task.cleared_at.is_(None),
)
)
)
if not tasks:
return None, 0
batch_id = str(uuid4())
cleared_at = datetime.now(timezone.utc)
for task in tasks:
task.cleared_at = cleared_at
task.clear_batch_id = batch_id
db.session.commit()
return batch_id, len(tasks)
def undo_clear_completed(user_id: int, batch_id: str) -> int:
tasks = list(
db.session.scalars(
select(Task).where(
Task.user_id == user_id,
Task.clear_batch_id == batch_id,
Task.cleared_at.is_not(None),
)
)
)
for task in tasks:
task.cleared_at = None
task.clear_batch_id = None
db.session.commit()
return len(tasks)
def _validate_choices(status: str, priority: str) -> None:
if status not in TASK_STATUSES:
raise ValueError("Unknown task status.")
if priority not in TASK_PRIORITIES:
raise ValueError("Unknown task priority.")
def _clean_optional(value: str | None) -> str | None:
cleaned = value.strip() if value else ""
return cleaned or None
def _normalize_chunk_session(session: Any) -> dict[str, int | bool]:
if isinstance(session, dict):
return {
"minutes": int(session.get("minutes", 0)),
"completed": bool(session.get("completed", False)),
}
return {"minutes": int(session), "completed": False}