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:
56
backend/app/tasks/onboarding.py
Normal file
56
backend/app/tasks/onboarding.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Celery task: welcome email on school onboarding."""
|
||||
import logging
|
||||
from app.worker import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="onboarding.send_welcome_email")
|
||||
def send_welcome_email(school_id: str):
|
||||
"""Send welcome email with license key to new school."""
|
||||
import os
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models.school import School
|
||||
from app.models.license import License
|
||||
from app.services.email import send_email
|
||||
|
||||
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)()
|
||||
try:
|
||||
school = db.get(School, school_id)
|
||||
if not school:
|
||||
return
|
||||
lic = db.execute(select(License).where(License.school_id == school_id)).scalar_one_or_none()
|
||||
email_to = school.billing_email or school.contact_email
|
||||
if not email_to:
|
||||
logger.warning("No email for school %s — skipping welcome", school.name)
|
||||
return
|
||||
|
||||
hub_url = os.getenv("HUB_BASE_URL", "http://localhost:8090")
|
||||
license_key = lic.key if lic else "Contact support to get your license key"
|
||||
|
||||
plain = (
|
||||
f"Welcome to TapTrack Hub!\n\n"
|
||||
f"Your school '{school.name}' has been registered.\n\n"
|
||||
f"License Key: {license_key}\n\n"
|
||||
f"Setup instructions:\n"
|
||||
f"1. Log in to TapTrack Hub: {hub_url}/login\n"
|
||||
f"2. Enter your license key in TapTrack's Hub settings\n"
|
||||
f"3. Restart the TapTrack service\n\n"
|
||||
f"Support: support@taptrack.io\n\nTapTrack Hub Team"
|
||||
)
|
||||
send_email(
|
||||
to=email_to,
|
||||
subject=f"Welcome to TapTrack Hub — {school.name}",
|
||||
body=plain,
|
||||
email_type="welcome",
|
||||
school_id=school.id,
|
||||
)
|
||||
logger.info("Welcome email sent to %s for school %s", email_to, school.name)
|
||||
except Exception as e:
|
||||
logger.error("send_welcome_email error for %s: %s", school_id, e)
|
||||
finally:
|
||||
db.close()
|
||||
engine.dispose()
|
||||
@@ -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()
|
||||
|
||||
70
backend/app/tasks/tickets.py
Normal file
70
backend/app/tasks/tickets.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Celery tasks: ticket priority escalation."""
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
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="tickets.escalate_stale")
|
||||
def escalate_stale():
|
||||
"""
|
||||
Escalate stale open tickets:
|
||||
- open > 48h + priority medium → high
|
||||
- open > 72h + no reply yet → urgent
|
||||
"""
|
||||
from sqlalchemy import select, and_
|
||||
from app.models.ticket import SupportTicket, TicketReply, TicketStatus, TicketPriority
|
||||
|
||||
db = _make_session()
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
threshold_48h = now - timedelta(hours=48)
|
||||
threshold_72h = now - timedelta(hours=72)
|
||||
|
||||
# Open tickets > 48h, medium priority → high
|
||||
stale_medium = db.execute(
|
||||
select(SupportTicket).where(
|
||||
and_(
|
||||
SupportTicket.status == TicketStatus.open,
|
||||
SupportTicket.priority == TicketPriority.medium,
|
||||
SupportTicket.created_at < threshold_48h,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for t in stale_medium:
|
||||
t.priority = TicketPriority.high
|
||||
logger.info("Escalated ticket %s to high priority (48h)", t.ticket_number)
|
||||
|
||||
# Open tickets > 72h, no reply → urgent
|
||||
stale_72h = db.execute(
|
||||
select(SupportTicket).where(
|
||||
and_(
|
||||
SupportTicket.status == TicketStatus.open,
|
||||
SupportTicket.first_response_at == None, # noqa: E711
|
||||
SupportTicket.created_at < threshold_72h,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for t in stale_72h:
|
||||
if t.priority != TicketPriority.urgent:
|
||||
t.priority = TicketPriority.urgent
|
||||
logger.warning("Escalated ticket %s to urgent (72h no response)", t.ticket_number)
|
||||
|
||||
db.commit()
|
||||
logger.info("escalate_stale: %d→high, %d→urgent", len(stale_medium), len(stale_72h))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("escalate_stale error: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user