models.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """
  2. app/models.py
  3. ─────────────
  4. SQLAlchemy ORM models (database schema) and Pydantic v2 schemas
  5. (request/response validation) for every PMDI domain entity.
  6. """
  7. from __future__ import annotations
  8. from datetime import date, datetime
  9. from typing import Any, Dict, List, Optional
  10. from pydantic import BaseModel, ConfigDict, EmailStr
  11. from sqlalchemy import (
  12. Boolean, Column, Date, DateTime, ForeignKey,
  13. Integer, String, Text,
  14. )
  15. from sqlalchemy.dialects.postgresql import ARRAY, JSONB
  16. from sqlalchemy.orm import relationship
  17. from app.database import Base
  18. # ═══════════════════════════════════════════════════════════════════════════════
  19. # Notification delivery-status constants
  20. # ═══════════════════════════════════════════════════════════════════════════════
  21. STATUS_DELIVERED = "Delivered from Server"
  22. STATUS_RELAY = "Sent to Mail Relay"
  23. STATUS_FAILED = "Failed"
  24. # ═══════════════════════════════════════════════════════════════════════════════
  25. # SQLAlchemy ORM Models
  26. # ═══════════════════════════════════════════════════════════════════════════════
  27. class AdminUser(Base):
  28. """Internal administrator account with optional TOTP MFA."""
  29. __tablename__ = "admin_users"
  30. id = Column(Integer, primary_key=True, index=True)
  31. username = Column(String(64), unique=True, nullable=False, index=True)
  32. hashed_password = Column(String(256), nullable=False)
  33. totp_secret = Column(String(64), nullable=True) # base32 TOTP secret
  34. mfa_enabled = Column(Boolean, default=False, nullable=False)
  35. created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
  36. class Client(Base):
  37. """Customer record referenced by invoices."""
  38. __tablename__ = "clients"
  39. id = Column(Integer, primary_key=True, index=True)
  40. client_id_name = Column(String(64), unique=True, nullable=False) # short slug
  41. client_name = Column(String(256), nullable=False)
  42. client_email = Column(String(256), nullable=False)
  43. created_at = Column(DateTime, default=datetime.utcnow)
  44. invoices = relationship("Invoice", back_populates="client")
  45. class EmailTemplate(Base):
  46. """Jinja2 email template with a human-readable name, subject, and body."""
  47. __tablename__ = "email_templates"
  48. id = Column(Integer, primary_key=True, index=True)
  49. name = Column(String(128), nullable=False) # dropdown label
  50. template_type = Column(String(32), nullable=False) # "Notification" | "Overdue"
  51. subject = Column(String(256), nullable=False) # Jinja2 subject line
  52. template_body = Column(Text, nullable=False) # Jinja2 body
  53. created_at = Column(DateTime, default=datetime.utcnow)
  54. invoices = relationship("Invoice", back_populates="template")
  55. class Invoice(Base):
  56. """Open receivable with scheduling metadata for automated reminders."""
  57. __tablename__ = "invoices"
  58. id = Column(Integer, primary_key=True, index=True)
  59. invoice_number = Column(String(64), nullable=False, unique=True)
  60. client_id = Column(Integer, ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False)
  61. pay_date = Column(Date, nullable=False)
  62. template_id = Column(Integer, ForeignKey("email_templates.id", ondelete="RESTRICT"), nullable=False)
  63. # Days-before-pay_date triggers, e.g. [30, 14, 7, 1]
  64. notification_dates = Column(ARRAY(Integer), nullable=True, default=[])
  65. # Send recurring overdue alert every N days past due (0 = disabled)
  66. overdue_recurring_days = Column(Integer, default=0, nullable=False)
  67. paid = Column(Boolean, default=False, nullable=False)
  68. created_at = Column(DateTime, default=datetime.utcnow)
  69. client = relationship("Client", back_populates="invoices")
  70. template = relationship("EmailTemplate", back_populates="invoices")
  71. logs = relationship(
  72. "NotificationLog",
  73. back_populates="invoice",
  74. cascade="all, delete-orphan",
  75. )
  76. class NotificationLog(Base):
  77. """Immutable audit record for every email dispatch attempt."""
  78. __tablename__ = "notification_logs"
  79. id = Column(Integer, primary_key=True, index=True)
  80. invoice_id = Column(Integer, ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False)
  81. status = Column(String(64), nullable=False) # STATUS_* constants
  82. sent_at = Column(DateTime, default=datetime.utcnow, nullable=False)
  83. context = Column(JSONB, nullable=True) # runtime snapshot for UI display
  84. error_message = Column(Text, nullable=True)
  85. invoice = relationship("Invoice", back_populates="logs")
  86. # ═══════════════════════════════════════════════════════════════════════════════
  87. # Pydantic v2 Schemas
  88. # ═══════════════════════════════════════════════════════════════════════════════
  89. _orm = ConfigDict(from_attributes=True)
  90. # ── Auth / JWT ─────────────────────────────────────────────────────────────────
  91. class LoginRequest(BaseModel):
  92. username: str
  93. password: str
  94. class TokenResponse(BaseModel):
  95. access_token: str
  96. token_type: str = "bearer"
  97. mfa_required: bool = False
  98. class MFAVerifyRequest(BaseModel):
  99. username: str
  100. totp_code: str
  101. class MFASetupResponse(BaseModel):
  102. secret: str
  103. qr_uri: str
  104. class UserRead(BaseModel):
  105. model_config = _orm
  106. id: int
  107. username: str
  108. mfa_enabled: bool
  109. # ── Client ─────────────────────────────────────────────────────────────────────
  110. class ClientCreate(BaseModel):
  111. client_id_name: str
  112. client_name: str
  113. client_email: EmailStr
  114. class ClientUpdate(ClientCreate):
  115. pass
  116. class ClientRead(BaseModel):
  117. model_config = _orm
  118. id: int
  119. client_id_name: str
  120. client_name: str
  121. client_email: str
  122. # ── EmailTemplate ──────────────────────────────────────────────────────────────
  123. class TemplateCreate(BaseModel):
  124. name: str
  125. template_type: str # "Notification" | "Overdue"
  126. subject: str
  127. template_body: str
  128. class TemplateUpdate(TemplateCreate):
  129. pass
  130. class TemplateRead(BaseModel):
  131. model_config = _orm
  132. id: int
  133. name: str
  134. template_type: str
  135. subject: str
  136. template_body: str
  137. # ── Invoice ────────────────────────────────────────────────────────────────────
  138. class InvoiceCreate(BaseModel):
  139. invoice_number: str
  140. client_id: int
  141. pay_date: date
  142. template_id: int
  143. notification_dates: Optional[List[int]] = []
  144. overdue_recurring_days: int = 0
  145. paid: bool = False
  146. class InvoiceUpdate(InvoiceCreate):
  147. pass
  148. class InvoiceRead(BaseModel):
  149. model_config = _orm
  150. id: int
  151. invoice_number: str
  152. client_id: int
  153. pay_date: date
  154. template_id: int
  155. notification_dates: Optional[List[int]] = []
  156. overdue_recurring_days: int
  157. paid: bool
  158. # Eagerly joined for the dashboard display
  159. client: Optional[ClientRead] = None
  160. template: Optional[TemplateRead] = None
  161. # ── Notification Log ───────────────────────────────────────────────────────────
  162. class NotificationLogRead(BaseModel):
  163. model_config = _orm
  164. id: int
  165. invoice_id: int
  166. status: str
  167. sent_at: datetime
  168. context: Optional[Dict[str, Any]] = None
  169. error_message: Optional[str] = None
  170. # ── Config ─────────────────────────────────────────────────────────────────────
  171. class ConfigEntry(BaseModel):
  172. key: str
  173. value: str
  174. masked: bool