fix: calendar datetime filters, tasks N+1 query, AI report hours accuracy
- calendar.py: replace fragile string-concat datetime filters with proper datetime.combine(date, time.min/max) for PlannedBlock and ManualEntry overlap - tasks.py list_tasks: batch-load projects + work items in 2 queries instead of N individual lookups per task (fixes N+1 performance issue) - ai_reports.py: use wall-clock union hours + overhead for report totals, consistent with KPI summary endpoint; import _union_hours from aggregator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
908205da44
commit
ac6ba28008
3 changed files with 70 additions and 16 deletions
|
|
@ -1,5 +1,5 @@
|
|||
"""Calendar endpoint — unified session/planned/manual blocks."""
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
|
@ -61,12 +61,13 @@ async def get_calendar(
|
|||
else:
|
||||
to_date = min(to_date, from_date + timedelta(days=6))
|
||||
|
||||
from_iso = from_date.isoformat()
|
||||
to_iso = to_date.isoformat()
|
||||
# Half-open overlap predicate: block overlaps [from_date, to_date] window
|
||||
window_start = datetime.combine(from_date, time.min, tzinfo=timezone.utc)
|
||||
window_end = datetime.combine(to_date, time.max, tzinfo=timezone.utc)
|
||||
|
||||
blocks: list[CalendarBlock] = []
|
||||
|
||||
# Sessions
|
||||
# Sessions (filtered by date column — more efficient with existing index)
|
||||
sessions_result = await db.execute(
|
||||
select(Session, Project.display_name, Project.job_number)
|
||||
.join(Project, Session.project_id == Project.id)
|
||||
|
|
@ -93,15 +94,15 @@ async def get_calendar(
|
|||
)
|
||||
)
|
||||
|
||||
# Planned blocks
|
||||
# Planned blocks — half-open overlap check using proper datetime values
|
||||
planned_result = await db.execute(
|
||||
select(PlannedBlock, Project.display_name, Project.job_number, Task.title)
|
||||
.outerjoin(Project, PlannedBlock.project_id == Project.id)
|
||||
.outerjoin(Task, PlannedBlock.task_id == Task.id)
|
||||
.where(
|
||||
PlannedBlock.user_id == user.id,
|
||||
PlannedBlock.start_at <= to_iso + "T23:59:59+00:00",
|
||||
PlannedBlock.end_at >= from_iso + "T00:00:00+00:00",
|
||||
PlannedBlock.start_at <= window_end,
|
||||
PlannedBlock.end_at >= window_start,
|
||||
)
|
||||
.order_by(PlannedBlock.start_at)
|
||||
)
|
||||
|
|
@ -121,14 +122,14 @@ async def get_calendar(
|
|||
)
|
||||
)
|
||||
|
||||
# Manual entries
|
||||
# Manual entries — same overlap predicate
|
||||
manual_result = await db.execute(
|
||||
select(ManualEntry, Project.display_name, Project.job_number)
|
||||
.outerjoin(Project, ManualEntry.project_id == Project.id)
|
||||
.where(
|
||||
ManualEntry.user_id == user.id,
|
||||
ManualEntry.start_at <= to_iso + "T23:59:59+00:00",
|
||||
ManualEntry.end_at >= from_iso + "T00:00:00+00:00",
|
||||
ManualEntry.start_at <= window_end,
|
||||
ManualEntry.end_at >= window_start,
|
||||
)
|
||||
.order_by(ManualEntry.start_at)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -58,13 +58,52 @@ async def list_tasks(
|
|||
task_date: date | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Batch-load related entities to avoid N+1 queries
|
||||
query = select(Task).where(Task.user_id == user.id)
|
||||
if task_date is not None:
|
||||
query = query.where(Task.planned_date == task_date)
|
||||
query = query.order_by(Task.planned_date, Task.sort_index, Task.created_at)
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
return [await _task_out(t, db) for t in tasks]
|
||||
|
||||
if not tasks:
|
||||
return []
|
||||
|
||||
# Prefetch projects and work items in two bulk queries
|
||||
project_ids = {t.project_id for t in tasks if t.project_id}
|
||||
wi_ids = {t.azure_work_item_id for t in tasks if t.azure_work_item_id}
|
||||
|
||||
projects: dict[str, Project] = {}
|
||||
if project_ids:
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(Project.id.in_(project_ids))
|
||||
)
|
||||
projects = {p.id: p for p in proj_result.scalars().all()}
|
||||
|
||||
work_items: dict[str, AzureWorkItem] = {}
|
||||
if wi_ids:
|
||||
wi_result = await db.execute(
|
||||
select(AzureWorkItem).where(AzureWorkItem.id.in_(wi_ids))
|
||||
)
|
||||
work_items = {w.id: w for w in wi_result.scalars().all()}
|
||||
|
||||
out = []
|
||||
for t in tasks:
|
||||
proj = projects.get(t.project_id) if t.project_id else None
|
||||
wi = work_items.get(t.azure_work_item_id) if t.azure_work_item_id else None
|
||||
out.append(TaskOut(
|
||||
id=t.id, user_id=t.user_id, project_id=t.project_id,
|
||||
azure_work_item_id=t.azure_work_item_id, title=t.title,
|
||||
notes=t.notes or "", planned_date=t.planned_date,
|
||||
estimate_hours=t.estimate_hours, actual_hours=t.actual_hours,
|
||||
status=t.status, priority=t.priority, sort_index=t.sort_index,
|
||||
completed_at=t.completed_at, ado_synced_at=t.ado_synced_at,
|
||||
created_at=t.created_at,
|
||||
project_name=proj.display_name if proj else "",
|
||||
job_number=proj.job_number if proj else "",
|
||||
work_item_title=wi.title if wi else "",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut, status_code=201)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""AI report generation + email delivery via Mailgun."""
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
|
@ -7,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from src.config import settings
|
||||
from src.models import AiReport, Project, Session, Task, User
|
||||
from src.services.aggregator import _union_hours
|
||||
from src.services.mailgun import send_email
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -27,11 +29,15 @@ async def _gather_daily_context(user: User, report_date: date, db: AsyncSession)
|
|||
)
|
||||
tasks = tasks_result.scalars().all()
|
||||
|
||||
total_hours = sum(s.active_hours for s, _, _ in rows)
|
||||
# Wall-clock union hours (consistent with KPI summary endpoint)
|
||||
day_intervals = [(s.start_at, s.end_at) for s, _, _ in rows]
|
||||
total_hours = _union_hours(day_intervals) + user.daily_overhead_hours if day_intervals else 0.0
|
||||
|
||||
projects_worked: dict[str, float] = {}
|
||||
for s, display_name, job_number in rows:
|
||||
label = f"{job_number} {display_name}".strip() if job_number else display_name
|
||||
projects_worked[label] = projects_worked.get(label, 0) + s.active_hours
|
||||
duration_h = (s.end_at - s.start_at).total_seconds() / 3600
|
||||
projects_worked[label] = projects_worked.get(label, 0) + duration_h
|
||||
|
||||
return {
|
||||
"date": report_date.isoformat(),
|
||||
|
|
@ -58,10 +64,18 @@ async def _gather_weekly_context(user: User, week_start: date, db: AsyncSession)
|
|||
)
|
||||
)
|
||||
rows = sessions_result.all()
|
||||
# Wall-clock union per day + overhead — consistent with KPI summary
|
||||
day_intervals_map: dict[date, list] = defaultdict(list)
|
||||
projects_worked: dict[str, float] = {}
|
||||
for s, display_name, job_number in rows:
|
||||
day_intervals_map[s.date].append((s.start_at, s.end_at))
|
||||
label = f"{job_number} {display_name}".strip() if job_number else display_name
|
||||
projects_worked[label] = projects_worked.get(label, 0) + s.active_hours
|
||||
duration_h = (s.end_at - s.start_at).total_seconds() / 3600
|
||||
projects_worked[label] = projects_worked.get(label, 0) + duration_h
|
||||
|
||||
working_days_set = set(day_intervals_map.keys())
|
||||
total_hours = sum(_union_hours(v) for v in day_intervals_map.values())
|
||||
total_hours += user.daily_overhead_hours * len(working_days_set)
|
||||
|
||||
tasks_result = await db.execute(
|
||||
select(Task).where(
|
||||
|
|
@ -75,8 +89,8 @@ async def _gather_weekly_context(user: User, week_start: date, db: AsyncSession)
|
|||
return {
|
||||
"week_start": week_start.isoformat(),
|
||||
"week_end": week_end.isoformat(),
|
||||
"total_hours": round(sum(s.active_hours for s, _, _ in rows), 2),
|
||||
"working_days": len({s.date for s, _, _ in rows}),
|
||||
"total_hours": round(total_hours, 2),
|
||||
"working_days": len(working_days_set),
|
||||
"session_count": len(rows),
|
||||
"projects": {
|
||||
k: round(v, 2)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue