Files
TapTrack-Hub/backend/app/models/school.py
kevin-asprec 9ef8f1a421 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
2026-03-16 14:28:52 +08:00

49 lines
2.8 KiB
Python

import uuid
import enum
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, Text, Numeric, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SchoolStatus(str, enum.Enum):
active = "active"
suspended = "suspended"
expired = "expired"
pending = "pending"
class LicenseTier(str, enum.Enum):
basic = "basic"
standard = "standard"
premium = "premium"
class School(Base):
__tablename__ = "schools"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
billing_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[SchoolStatus] = mapped_column(SAEnum(SchoolStatus), default=SchoolStatus.pending, nullable=False)
tier: Mapped[LicenseTier] = mapped_column(SAEnum(LicenseTier), default=LicenseTier.standard, nullable=False)
student_limit: Mapped[int] = mapped_column(Integer, default=500, nullable=False)
sms_sender_name: Mapped[str] = mapped_column(String(11), default="SCHOOL", nullable=False)
sms_credits: Mapped[float] = mapped_column(Numeric(12, 2), default=0.0, nullable=False)
sms_credit_low_threshold: Mapped[int] = mapped_column(Integer, default=50, nullable=False)
hub_base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
feature_overrides: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string
onboarding_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
admins: Mapped[list["HubUser"]] = relationship("HubUser", back_populates="school")
license: Mapped["License | None"] = relationship("License", back_populates="school", uselist=False)
sms_jobs: Mapped[list["SmsJob"]] = relationship("SmsJob", back_populates="school")
invoices: Mapped[list["Invoice"]] = relationship("Invoice", back_populates="school")
tickets: Mapped[list["SupportTicket"]] = relationship("SupportTicket", back_populates="school")
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="school")