309 lines
12 KiB
Python
309 lines
12 KiB
Python
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import false as sa_false, select, func, or_, case
|
|
from datetime import datetime, date, timedelta
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
from app.database import get_db
|
|
from app.models.settings import Settings
|
|
from app.models.todo import Todo
|
|
from app.models.calendar_event import CalendarEvent
|
|
from app.models.reminder import Reminder
|
|
from app.models.project import Project
|
|
from app.models.user import User
|
|
from app.routers.auth import get_current_user, get_current_settings
|
|
from app.models.event_invitation import EventInvitation
|
|
from app.services.calendar_sharing import get_accessible_event_scope
|
|
|
|
router = APIRouter()
|
|
|
|
# Reusable filter: exclude parent template events (they have a real recurrence_rule).
|
|
# Some legacy events have "" instead of NULL, so allow both.
|
|
_not_parent_template = or_(
|
|
CalendarEvent.recurrence_rule == None,
|
|
CalendarEvent.recurrence_rule == "",
|
|
)
|
|
|
|
|
|
@router.get("/dashboard")
|
|
async def get_dashboard(
|
|
client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
current_settings: Settings = Depends(get_current_settings),
|
|
):
|
|
"""Get aggregated dashboard data."""
|
|
today = client_date or date.today()
|
|
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
|
|
|
|
# Fetch all accessible calendar IDs + invited event IDs
|
|
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
|
|
|
# Today's events (exclude parent templates — they are hidden, children are shown)
|
|
today_start = datetime.combine(today, datetime.min.time())
|
|
today_end = datetime.combine(today, datetime.max.time())
|
|
events_query = select(CalendarEvent).where(
|
|
or_(
|
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
|
),
|
|
CalendarEvent.start_datetime >= today_start,
|
|
CalendarEvent.start_datetime <= today_end,
|
|
_not_parent_template,
|
|
)
|
|
events_result = await db.execute(events_query)
|
|
todays_events = events_result.scalars().all()
|
|
|
|
# Build invitation lookup for today's events
|
|
invited_event_id_set = set(invited_event_ids)
|
|
today_inv_map: dict[int, tuple[str, int | None]] = {}
|
|
today_event_ids = [e.id for e in todays_events]
|
|
parent_ids_in_today = [e.parent_event_id for e in todays_events if e.parent_event_id and e.parent_event_id in invited_event_id_set]
|
|
inv_lookup_ids = list(set(today_event_ids + parent_ids_in_today) & invited_event_id_set)
|
|
if inv_lookup_ids:
|
|
inv_result = await db.execute(
|
|
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
|
|
EventInvitation.user_id == current_user.id,
|
|
EventInvitation.event_id.in_(inv_lookup_ids),
|
|
)
|
|
)
|
|
for eid, status, disp_cal_id in inv_result.all():
|
|
today_inv_map[eid] = (status, disp_cal_id)
|
|
|
|
# Upcoming todos (not completed, with due date from today through upcoming_days)
|
|
todos_query = select(Todo).where(
|
|
Todo.user_id == current_user.id,
|
|
Todo.completed == False,
|
|
Todo.due_date.isnot(None),
|
|
Todo.due_date >= today,
|
|
Todo.due_date <= upcoming_cutoff
|
|
).order_by(Todo.due_date.asc())
|
|
todos_result = await db.execute(todos_query)
|
|
upcoming_todos = todos_result.scalars().all()
|
|
|
|
# Active reminders (not dismissed, is_active = true, from today onward)
|
|
reminders_query = select(Reminder).where(
|
|
Reminder.user_id == current_user.id,
|
|
Reminder.is_active == True,
|
|
Reminder.is_dismissed == False,
|
|
Reminder.remind_at >= today_start
|
|
).order_by(Reminder.remind_at.asc())
|
|
reminders_result = await db.execute(reminders_query)
|
|
active_reminders = reminders_result.scalars().all()
|
|
|
|
# Project stats — single GROUP BY query, derive total in Python
|
|
projects_by_status_result = await db.execute(
|
|
select(
|
|
Project.status,
|
|
func.count(Project.id).label("count"),
|
|
).where(Project.user_id == current_user.id).group_by(Project.status)
|
|
)
|
|
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
|
|
total_projects = sum(projects_by_status.values())
|
|
|
|
# Todo counts: total and incomplete in a single query
|
|
todo_counts_result = await db.execute(
|
|
select(
|
|
func.count(Todo.id).label("total"),
|
|
func.count(case((Todo.completed == False, Todo.id))).label("incomplete"),
|
|
).where(Todo.user_id == current_user.id)
|
|
)
|
|
todo_row = todo_counts_result.one()
|
|
total_todos = todo_row.total
|
|
total_incomplete_todos = todo_row.incomplete
|
|
|
|
# Starred events — no upper date bound so future events always appear in countdown.
|
|
# _not_parent_template excludes recurring parent templates (children still show).
|
|
starred_query = select(CalendarEvent).where(
|
|
or_(
|
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
|
),
|
|
CalendarEvent.is_starred == True,
|
|
CalendarEvent.start_datetime > today_start,
|
|
_not_parent_template,
|
|
).order_by(CalendarEvent.start_datetime.asc()).limit(5)
|
|
starred_result = await db.execute(starred_query)
|
|
starred_events = starred_result.scalars().all()
|
|
|
|
starred_events_data = [
|
|
{
|
|
"id": e.id,
|
|
"title": e.title,
|
|
"start_datetime": e.start_datetime
|
|
}
|
|
for e in starred_events
|
|
]
|
|
|
|
return {
|
|
"todays_events": [
|
|
{
|
|
"id": event.id,
|
|
"title": event.title,
|
|
"start_datetime": event.start_datetime,
|
|
"end_datetime": event.end_datetime,
|
|
"all_day": event.all_day,
|
|
"color": event.color,
|
|
"is_starred": event.is_starred,
|
|
"is_invited": (event.parent_event_id or event.id) in invited_event_id_set,
|
|
"invitation_status": today_inv_map.get(event.parent_event_id or event.id, (None,))[0],
|
|
"display_calendar_id": today_inv_map.get(event.parent_event_id or event.id, (None, None))[1],
|
|
}
|
|
for event in todays_events
|
|
],
|
|
"upcoming_todos": [
|
|
{
|
|
"id": todo.id,
|
|
"title": todo.title,
|
|
"due_date": todo.due_date,
|
|
"priority": todo.priority,
|
|
"category": todo.category
|
|
}
|
|
for todo in upcoming_todos
|
|
],
|
|
"active_reminders": [
|
|
{
|
|
"id": reminder.id,
|
|
"title": reminder.title,
|
|
"remind_at": reminder.remind_at
|
|
}
|
|
for reminder in active_reminders
|
|
],
|
|
"project_stats": {
|
|
"total": total_projects,
|
|
"by_status": projects_by_status
|
|
},
|
|
"total_incomplete_todos": total_incomplete_todos,
|
|
"total_todos": total_todos,
|
|
"starred_events": starred_events_data
|
|
}
|
|
|
|
|
|
@router.get("/upcoming")
|
|
async def get_upcoming(
|
|
days: int = Query(default=7, ge=1, le=90),
|
|
client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
current_settings: Settings = Depends(get_current_settings),
|
|
):
|
|
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
|
|
today = client_date or date.today()
|
|
cutoff_date = today + timedelta(days=days)
|
|
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
|
|
today_start = datetime.combine(today, datetime.min.time())
|
|
overdue_floor = today - timedelta(days=30)
|
|
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
|
|
|
|
# Fetch all accessible calendar IDs + invited event IDs
|
|
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
|
|
|
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
|
|
todos_query = select(Todo).where(
|
|
Todo.user_id == current_user.id,
|
|
Todo.completed == False,
|
|
Todo.due_date.isnot(None),
|
|
Todo.due_date >= overdue_floor,
|
|
Todo.due_date <= cutoff_date
|
|
)
|
|
|
|
events_query = select(CalendarEvent).where(
|
|
or_(
|
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
|
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
|
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
|
),
|
|
CalendarEvent.start_datetime >= today_start,
|
|
CalendarEvent.start_datetime <= cutoff_datetime,
|
|
_not_parent_template,
|
|
)
|
|
|
|
reminders_query = select(Reminder).where(
|
|
Reminder.user_id == current_user.id,
|
|
Reminder.is_active == True,
|
|
Reminder.is_dismissed == False,
|
|
Reminder.remind_at >= overdue_floor_dt,
|
|
Reminder.remind_at <= cutoff_datetime
|
|
)
|
|
|
|
# Execute queries sequentially (single session cannot run concurrent queries)
|
|
todos_result = await db.execute(todos_query)
|
|
todos = todos_result.scalars().all()
|
|
|
|
events_result = await db.execute(events_query)
|
|
events = events_result.scalars().all()
|
|
|
|
reminders_result = await db.execute(reminders_query)
|
|
reminders = reminders_result.scalars().all()
|
|
|
|
# Build invitation lookup for upcoming events
|
|
invited_event_id_set_up = set(invited_event_ids)
|
|
upcoming_inv_map: dict[int, tuple[str, int | None]] = {}
|
|
up_parent_ids = list({e.parent_event_id or e.id for e in events} & invited_event_id_set_up)
|
|
if up_parent_ids:
|
|
up_inv_result = await db.execute(
|
|
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
|
|
EventInvitation.user_id == current_user.id,
|
|
EventInvitation.event_id.in_(up_parent_ids),
|
|
)
|
|
)
|
|
for eid, status, disp_cal_id in up_inv_result.all():
|
|
upcoming_inv_map[eid] = (status, disp_cal_id)
|
|
|
|
# Combine into unified list
|
|
upcoming_items: List[Dict[str, Any]] = []
|
|
|
|
for todo in todos:
|
|
upcoming_items.append({
|
|
"type": "todo",
|
|
"id": todo.id,
|
|
"title": todo.title,
|
|
"date": todo.due_date.isoformat() if todo.due_date else None,
|
|
"datetime": None,
|
|
"priority": todo.priority,
|
|
"category": todo.category,
|
|
"is_overdue": todo.due_date < today if todo.due_date else False,
|
|
})
|
|
|
|
for event in events:
|
|
end_dt = event.end_datetime
|
|
parent_id = event.parent_event_id or event.id
|
|
is_inv = parent_id in invited_event_id_set_up
|
|
upcoming_items.append({
|
|
"type": "event",
|
|
"id": event.id,
|
|
"title": event.title,
|
|
"date": event.start_datetime.date().isoformat(),
|
|
"datetime": event.start_datetime.isoformat(),
|
|
"end_datetime": end_dt.isoformat() if end_dt else None,
|
|
"all_day": event.all_day,
|
|
"color": event.color,
|
|
"is_starred": event.is_starred,
|
|
"is_invited": is_inv,
|
|
"invitation_status": upcoming_inv_map.get(parent_id, (None,))[0] if is_inv else None,
|
|
"display_calendar_id": upcoming_inv_map.get(parent_id, (None, None))[1] if is_inv else None,
|
|
})
|
|
|
|
for reminder in reminders:
|
|
remind_at_date = reminder.remind_at.date() if reminder.remind_at else None
|
|
upcoming_items.append({
|
|
"type": "reminder",
|
|
"id": reminder.id,
|
|
"title": reminder.title,
|
|
"date": remind_at_date.isoformat() if remind_at_date else None,
|
|
"datetime": reminder.remind_at.isoformat() if reminder.remind_at else None,
|
|
"snoozed_until": reminder.snoozed_until.isoformat() if reminder.snoozed_until else None,
|
|
"is_overdue": remind_at_date < today if remind_at_date else False,
|
|
})
|
|
|
|
# Sort by date/datetime
|
|
upcoming_items.sort(key=lambda x: x["datetime"] if x["datetime"] else x["date"])
|
|
|
|
return {
|
|
"items": upcoming_items,
|
|
"days": days,
|
|
"cutoff_date": cutoff_date.isoformat()
|
|
}
|