"""Celery tasks: monthly report generation and on-prem data pull.""" import logging from datetime import datetime, timezone, timedelta, date from app.worker import celery_app logger = logging.getLogger(__name__) def _make_session(): import os from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker db_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/taptrack_hub") engine = create_engine(db_url.replace("postgresql+asyncpg://", "postgresql://"), pool_pre_ping=True) return sessionmaker(bind=engine)() @celery_app.task(name="reports.pull_monthly_stats") def pull_monthly_stats(): """ Pull attendance stats from each on-prem TapTrack instance. Runs 1st of month at 5am, before report generation at 7am. """ import httpx from sqlalchemy import select from app.models.school import School, SchoolStatus from app.models.license import License from app.models.report import SchoolMonthlyStats db = _make_session() try: today = date.today() prev_end = date(today.year, today.month, 1) - timedelta(days=1) report_month = f"{prev_end.year}-{prev_end.month:02d}" schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all() for school in schools: lic = db.execute(select(License).where(License.school_id == school.id)).scalar_one_or_none() hub_url = getattr(school, 'hub_base_url', None) existing = db.execute( select(SchoolMonthlyStats).where( SchoolMonthlyStats.school_id == school.id, SchoolMonthlyStats.report_month == report_month, ) ).scalar_one_or_none() stats = existing or SchoolMonthlyStats(school_id=school.id, report_month=report_month) if hub_url and lic: try: resp = httpx.get( f"{hub_url.rstrip('/')}/api/hub/monthly-report", params={"key": lic.key, "month": report_month}, timeout=10, ) if resp.status_code == 200: data = resp.json() stats.total_students = data.get("total_students") stats.school_days = data.get("school_days") stats.present_days_total = data.get("present_days_total") stats.absent_days_total = data.get("absent_days_total") stats.late_days_total = data.get("late_days_total") stats.avg_attendance_rate = data.get("avg_attendance_rate") stats.sms_sent = data.get("sms_sent") stats.pull_status = "success" stats.pulled_at = datetime.now(timezone.utc) stats.hub_base_url = hub_url else: stats.pull_status = "failed" except Exception as e: logger.warning("Failed to pull stats for %s: %s", school.name, e) stats.pull_status = "failed" else: stats.pull_status = "unavailable" if not existing: db.add(stats) db.commit() logger.info("pull_monthly_stats complete for %s", report_month) except Exception as e: db.rollback() logger.error("pull_monthly_stats error: %s", e) finally: db.close() @celery_app.task(name="reports.send_monthly_reports") def send_monthly_reports(): """Send monthly report emails to all active schools on the 1st at 7am.""" from sqlalchemy import select, func from app.models.school import School, SchoolStatus from app.models.sms import SmsJob, SmsJobStatus from app.models.billing import Invoice from app.models.report import MonthlyReport, SchoolMonthlyStats from app.services.email import send_email db = _make_session() try: today = date.today() prev_end = date(today.year, today.month, 1) - timedelta(days=1) prev_start = date(prev_end.year, prev_end.month, 1) report_month = f"{prev_end.year}-{prev_end.month:02d}" month_label = prev_start.strftime("%B %Y") schools = db.execute(select(School).where(School.status == SchoolStatus.active)).scalars().all() sent = 0 for school in schools: if not school.billing_email: continue sms_sent_count = db.execute( select(func.count()).where( SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.sent, func.date(SmsJob.sent_at) >= prev_start, func.date(SmsJob.sent_at) <= prev_end, ) ).scalar_one() sms_failed_count = db.execute( select(func.count()).where( SmsJob.school_id == school.id, SmsJob.status == SmsJobStatus.failed, func.date(SmsJob.created_at) >= prev_start, func.date(SmsJob.created_at) <= prev_end, ) ).scalar_one() inv = db.execute( select(Invoice).where(Invoice.school_id == school.id, Invoice.billing_period_start == prev_start) ).scalar_one_or_none() att = db.execute( select(SchoolMonthlyStats).where( SchoolMonthlyStats.school_id == school.id, SchoolMonthlyStats.report_month == report_month, ) ).scalar_one_or_none() report_data = { "month": month_label, "sms_sent": sms_sent_count, "sms_failed": sms_failed_count, "credits_remaining": float(school.sms_credits), "attendance": { "available": att is not None and att.pull_status == "success", "total_students": att.total_students if att else None, "school_days": att.school_days if att else None, "avg_attendance_rate": float(att.avg_attendance_rate) if att and att.avg_attendance_rate else None, }, "invoice": {"number": inv.invoice_number, "total": float(inv.total_amount), "status": inv.status.value} if inv else None, } existing_report = db.execute( select(MonthlyReport).where( MonthlyReport.school_id == school.id, MonthlyReport.report_month == report_month, ) ).scalar_one_or_none() if not existing_report: report = MonthlyReport(school_id=school.id, report_month=report_month, report_data=report_data) db.add(report) db.flush() else: report = existing_report report.report_data = report_data att_section = "" if report_data["attendance"]["available"]: att_section = (f"\nAttendance Rate: {report_data['attendance']['avg_attendance_rate']:.1f}%" f" | School Days: {report_data['attendance']['school_days']}" f" | Students: {report_data['attendance']['total_students']}") plain = ( f"Dear {school.contact_name or school.name},\n\n" f"Monthly summary for {month_label}:\n\n" f"SMS Sent: {sms_sent_count} | Failed: {sms_failed_count}\n" f"SMS Credits Remaining: {float(school.sms_credits):.0f}\n" f"{att_section}\n\n" f"Log in to view full details.\n\nTapTrack Hub Team" ) ok = send_email( to=school.billing_email, subject=f"Monthly Report — {school.name} — {month_label}", body=plain, email_type="monthly_report", school_id=school.id, ) if ok: report.email_sent_at = datetime.now(timezone.utc) sent += 1 db.commit() logger.info("send_monthly_reports: %d/%d sent", sent, len(schools)) except Exception as e: db.rollback() logger.error("send_monthly_reports error: %s", e) finally: db.close()