feat(phases-10-15): complete TapTrack Hub v1.0

Phase 10 — Support Ticket System:
- tickets.py router: SLA status (on_track/at_risk/breached/responded), email
  notifications on create+reply via background threads, school_name in list,
  priority filter, bulk-close endpoint
- tasks/tickets.py: escalate_stale Celery task (48h→high, 72h no reply→urgent)
- worker.py: escalate_stale scheduled every hour
- templates/email/ticket_notification.html: HTML ticket notification email
- TicketsPage.vue: status tabs, SLA badge, priority badge, school name column,
  checkbox bulk-close, pagination
- TicketDetailPage.vue: inline priority/status/assignee selectors, SLA timer,
  internal note lock icon, closed-ticket guard

Phase 11 — Monthly Report Generation:
- models/report.py: MonthlyReport + SchoolMonthlyStats ORM models
- tasks/reports.py: send_monthly_reports enhanced with SMS stats, attendance
  data, invoice summary, stores MonthlyReport record per school per month

Phase 12 — On-Prem Monthly Report Pull:
- tasks/reports.py: pull_monthly_stats task — httpx GET to each school's
  hub_base_url, upserts SchoolMonthlyStats; runs 1st at 5am
- worker.py: pull_monthly_stats scheduled 1st at 5am

Phase 13 — Feature Flags + Suspension:
- models/school.py: hub_base_url, feature_overrides (JSON), onboarding_completed_at
- routers/schools.py: PUT /{id}/feature-overrides endpoint
- routers/sync.py: _tier_features() merges school.feature_overrides into poll config

Phase 14 — Onboarding Wizard + Welcome Email:
- tasks/onboarding.py: send_welcome_email Celery task with license key
- routers/schools.py: auto-trigger welcome email on POST /schools,
  POST /{id}/activate (status→active + onboarding_completed_at),
  POST /{id}/resend-welcome

Phase 15 — UX Polish + Ops Tools:
- routers/search.py: GET /api/search?q= (schools + invoices + tickets, 5 each)
- routers/audit.py: GET /api/audit-logs (paginated, filterable)
- AppLayout.vue: global search bar with debounced dropdown, result navigation
- AuditLogsPage.vue: new page with filter + pagination
- AppSidebar.vue: Audit Logs nav item added
- router/index.ts: /audit-logs route
- api.ts: globalSearch, getAuditLogs, activateSchool, resendWelcomeEmail,
  updateFeatureOverrides, bulkCloseTickets

Deployment:
- docker-compose.yml: x-backend-env anchor (DRY), PDF_DIR env var,
  seed service (one-shot python seed.py on first boot)
- migrations/003_phases11_15.py: monthly_reports, school_monthly_stats tables
  + schools hub_base_url/feature_overrides/onboarding_completed_at columns
This commit is contained in:
kevin-asprec
2026-03-16 14:28:52 +08:00
parent 1febb3cfa9
commit 9ef8f1a421
26 changed files with 1375 additions and 213 deletions

View File

@@ -1,35 +1,196 @@
"""Celery task: send monthly reports to schools."""
"""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__)
@celery_app.task(name="reports.send_monthly_reports")
def send_monthly_reports():
"""Send monthly attendance and SMS report email to each active school."""
import os
from datetime import date, timedelta
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.models.school import School, SchoolStatus
from app.services.email import send_email
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)
db = sessionmaker(bind=engine)()
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_month_end = date(today.year, today.month, 1) - timedelta(days=1)
prev_month_start = date(prev_month_end.year, prev_month_end.month, 1)
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
send_email(
to=school.billing_email,
subject=f"Monthly Report — {school.name}{prev_month_start.strftime('%B %Y')}",
body=f"Dear {school.contact_name or school.name},\n\nPlease find your monthly summary for {prev_month_start.strftime('%B %Y')} in your TapTrack Hub portal.\n\nSMS Credits Remaining: {float(school.sms_credits):.0f}\n\nLog in to view full details.\n\nThank you,\nTapTrack Hub Team",
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"
)
logger.info(f"Sent monthly reports to {len(schools)} schools")
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()