Harden auth and persist report schedules

This commit is contained in:
dwindown
2026-06-06 19:40:32 +07:00
parent aaf64264f7
commit fd7989f673
18 changed files with 748 additions and 105 deletions

View File

@@ -23,6 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.item import Item
from app.models.report_schedule import ReportScheduleModel
from app.models.session import Session
from app.models.tryout import Tryout
from app.models.tryout_stats import TryoutStats
@@ -256,7 +257,8 @@ async def generate_student_performance_report(
website_id: int,
db: AsyncSession,
date_range: Optional[Dict[str, datetime]] = None,
format_type: Literal["individual", "aggregate", "both"] = "both"
format_type: Literal["individual", "aggregate", "both"] = "both",
wp_user_id: Optional[str] = None,
) -> StudentPerformanceReport:
"""
Generate student performance report.
@@ -267,6 +269,7 @@ async def generate_student_performance_report(
db: Database session
date_range: Optional date range filter {"start": datetime, "end": datetime}
format_type: Report format - individual, aggregate, or both
wp_user_id: Optional WordPress user filter for student-scoped reports
Returns:
StudentPerformanceReport with aggregate stats and/or individual records
@@ -287,6 +290,9 @@ async def generate_student_performance_report(
query = query.where(Session.start_time >= date_range["start"])
if date_range.get("end"):
query = query.where(Session.start_time <= date_range["end"])
if wp_user_id is not None:
query = query.where(Session.wp_user_id == wp_user_id)
query = query.order_by(Session.NN.desc().nullslast())
@@ -1382,11 +1388,34 @@ class ReportSchedule:
is_active: bool = True
# In-memory store for scheduled reports (in production, use database)
_scheduled_reports: Dict[str, ReportSchedule] = {}
def _calculate_next_run(schedule: Literal["daily", "weekly", "monthly"]) -> datetime:
now = datetime.now(timezone.utc)
if schedule == "daily":
return now + timedelta(days=1)
if schedule == "weekly":
return now + timedelta(weeks=1)
return now + timedelta(days=30)
def schedule_report(
def _schedule_from_model(row: ReportScheduleModel) -> ReportSchedule:
return ReportSchedule(
schedule_id=row.schedule_id,
report_type=row.report_type,
schedule=row.schedule,
tryout_ids=list(row.tryout_ids or []),
website_id=row.website_id,
recipients=list(row.recipients or []),
format=row.format,
created_at=row.created_at,
last_run=row.last_run,
next_run=row.next_run,
is_active=row.is_active,
)
async def schedule_report(
db: AsyncSession,
*,
report_type: Literal["student_performance", "item_analysis", "calibration_status", "tryout_comparison"],
schedule: Literal["daily", "weekly", "monthly"],
tryout_ids: List[str],
@@ -1412,16 +1441,7 @@ def schedule_report(
schedule_id = str(uuid.uuid4())
# Calculate next run time
now = datetime.now(timezone.utc)
if schedule == "daily":
next_run = now + timedelta(days=1)
elif schedule == "weekly":
next_run = now + timedelta(weeks=1)
else: # monthly
next_run = now + timedelta(days=30)
report_schedule = ReportSchedule(
report_schedule = ReportScheduleModel(
schedule_id=schedule_id,
report_type=report_type,
schedule=schedule,
@@ -1429,35 +1449,54 @@ def schedule_report(
website_id=website_id,
recipients=recipients,
format=export_format,
next_run=next_run,
next_run=_calculate_next_run(schedule),
is_active=True,
)
_scheduled_reports[schedule_id] = report_schedule
db.add(report_schedule)
await db.flush()
logger.info(f"Scheduled report {schedule_id}: {report_type} {schedule}")
return schedule_id
def get_scheduled_report(schedule_id: str) -> Optional[ReportSchedule]:
async def get_scheduled_report(db: AsyncSession, schedule_id: str) -> Optional[ReportSchedule]:
"""Get a scheduled report by ID."""
return _scheduled_reports.get(schedule_id)
result = await db.execute(
select(ReportScheduleModel).where(ReportScheduleModel.schedule_id == schedule_id)
)
row = result.scalar_one_or_none()
return _schedule_from_model(row) if row else None
def list_scheduled_reports(website_id: Optional[int] = None) -> List[ReportSchedule]:
async def list_scheduled_reports(
db: AsyncSession,
website_id: Optional[int] = None,
) -> List[ReportSchedule]:
"""List all scheduled reports, optionally filtered by website."""
reports = list(_scheduled_reports.values())
if website_id:
reports = [r for r in reports if r.website_id == website_id]
return reports
query = (
select(ReportScheduleModel)
.where(ReportScheduleModel.is_active == True)
.order_by(ReportScheduleModel.created_at.desc())
)
if website_id is not None:
query = query.where(ReportScheduleModel.website_id == website_id)
result = await db.execute(query)
return [_schedule_from_model(row) for row in result.scalars().all()]
def cancel_scheduled_report(schedule_id: str) -> bool:
async def cancel_scheduled_report(db: AsyncSession, schedule_id: str) -> bool:
"""Cancel a scheduled report."""
if schedule_id in _scheduled_reports:
del _scheduled_reports[schedule_id]
logger.info(f"Cancelled scheduled report {schedule_id}")
return True
return False
result = await db.execute(
select(ReportScheduleModel).where(ReportScheduleModel.schedule_id == schedule_id)
)
row = result.scalar_one_or_none()
if row is None:
return False
row.is_active = False
await db.flush()
logger.info(f"Cancelled scheduled report {schedule_id}")
return True
# Export public API