models.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. """
  2. SQL Alchemy models declaration.
  3. https://docs.sqlalchemy.org/en/14/orm/declarative_styles.html#example-two-dataclasses-with-declarative-table
  4. Dataclass style for powerful autocompletion support.
  5. https://alembic.sqlalchemy.org/en/latest/tutorial.html
  6. Note, it is used by alembic migrations logic, see `alembic/env.py`
  7. Alembic shortcuts:
  8. # create migration
  9. alembic revision --autogenerate -m "migration_name"
  10. # apply all migrations
  11. alembic upgrade head
  12. """
  13. import enum
  14. from typing import Optional
  15. import uuid
  16. from datetime import datetime
  17. from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table
  18. from sqlalchemy import Enum as SAEnum
  19. from sqlalchemy.dialects.postgresql import UUID
  20. from sqlalchemy.ext.hybrid import hybrid_property
  21. from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
  22. from sqlalchemy.sql import func
  23. class Base(DeclarativeBase):
  24. pass
  25. def uid_column() -> Mapped[str]:
  26. """Returns a postgreSQL UUID column for SQL ORM"""
  27. return mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4()))
  28. class GlobalRole(str, enum.Enum):
  29. SUPER_ADMIN = "super_admin"
  30. USER = "user"
  31. class OrgRole(str, enum.Enum):
  32. ORG_ADMIN = "org_admin"
  33. RESPO_BENEVOLE = "respo_benevole"
  34. RESPO_COMMISSION = "respo_commission"
  35. class User(Base):
  36. __tablename__ = "user_model"
  37. id: Mapped[UUID] = uid_column()
  38. email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
  39. hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
  40. name: Mapped[str] = mapped_column(String(128), default="")
  41. phone_number: Mapped[Optional[str]] = mapped_column(String(24), nullable=True)
  42. global_role: Mapped[GlobalRole] = mapped_column(
  43. SAEnum(GlobalRole, name="global_role"), default=GlobalRole.USER, nullable=False
  44. )
  45. organizations: Mapped[list["UserOrganization"]] = relationship(
  46. back_populates="user", cascade="all, delete-orphan"
  47. )
  48. commissions: Mapped[list["CommissionMember"]] = relationship(
  49. back_populates="user", cascade="all, delete-orphan"
  50. )
  51. class Organization(Base):
  52. __tablename__ = "organizations"
  53. id: Mapped[UUID] = uid_column()
  54. created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
  55. updated_at: Mapped[datetime] = mapped_column(
  56. DateTime(timezone=True), default=datetime.now, onupdate=func.now()
  57. )
  58. name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
  59. projects: Mapped[list["Project"]] = relationship(back_populates="organization", cascade="all, delete-orphan")
  60. memberships: Mapped[list["UserOrganization"]] = relationship(
  61. back_populates="organization", cascade="all, delete-orphan",
  62. passive_deletes=True, # let the DB's ON DELETE CASCADE do the actual delete
  63. )
  64. class UserOrganization(Base):
  65. """Many-to-many: a user can belong to several orgs, one role per org."""
  66. __tablename__ = "user_organizations"
  67. user_id: Mapped[UUID] = mapped_column(ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True)
  68. organization_id: Mapped[UUID] = mapped_column(
  69. ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
  70. )
  71. role: Mapped[OrgRole] = mapped_column(SAEnum(OrgRole, name="org_role"), nullable=False)
  72. user: Mapped["User"] = relationship(back_populates="organizations")
  73. organization: Mapped["Organization"] = relationship(back_populates="memberships")
  74. class Project(Base):
  75. __tablename__ = "projects"
  76. id: Mapped[UUID] = uid_column()
  77. organization_id: Mapped[str] = mapped_column(
  78. ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
  79. )
  80. organization: Mapped["Organization"] = relationship(back_populates="projects")
  81. created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
  82. updated_at: Mapped[datetime] = mapped_column(
  83. DateTime(timezone=True), default=datetime.now, onupdate=func.now()
  84. )
  85. name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
  86. is_public: Mapped[bool] = mapped_column(Boolean())
  87. volunteers: Mapped[list["Volunteer"]] = relationship(
  88. back_populates="project", cascade="delete, delete-orphan"
  89. )
  90. slots: Mapped[list["Slot"]] = relationship(
  91. back_populates="project", cascade="delete, delete-orphan"
  92. )
  93. sms: Mapped[list["Sms"]] = relationship(
  94. back_populates="project", cascade="delete, delete-orphan"
  95. )
  96. templates: Mapped[list["SlotTemplate"]] = relationship(
  97. back_populates="project", cascade="delete, delete-orphan"
  98. )
  99. tags: Mapped[list["SlotTag"]] = relationship(
  100. back_populates="project", cascade="delete, delete-orphan"
  101. )
  102. commissions: Mapped[list["Commission"]] = relationship(back_populates="project", cascade="delete, delete-orphan")
  103. groups: Mapped[list["VolunteerGroup"]] = relationship(
  104. back_populates="project", cascade="all, delete-orphan", passive_deletes=True
  105. )
  106. class Commission(Base):
  107. __tablename__ = "commissions"
  108. id: Mapped[UUID] = uid_column()
  109. project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
  110. project: Mapped["Project"] = relationship(back_populates="commissions")
  111. name: Mapped[str] = mapped_column(String(128), nullable=False) # e.g. "Bar", "Accueil"
  112. templates: Mapped[list["SlotTemplate"]] = relationship(back_populates="commission")
  113. members: Mapped[list["CommissionMember"]] = relationship(
  114. back_populates="commission", cascade="all, delete-orphan"
  115. )
  116. @hybrid_property
  117. def contacts(self) -> list[dict]:
  118. """Read-only, derived from members' own profile — never stored redundantly."""
  119. return [{"name": m.user.name, "phone_number": m.user.phone_number} for m in self.members]
  120. class CommissionMember(Base):
  121. __tablename__ = "commission_members"
  122. commission_id: Mapped[UUID] = mapped_column(ForeignKey("commissions.id", ondelete="CASCADE"), primary_key=True)
  123. user_id: Mapped[UUID] = mapped_column(ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True)
  124. commission: Mapped["Commission"] = relationship(back_populates="members")
  125. user: Mapped["User"] = relationship(back_populates="commissions")
  126. association_table_volunteer_slot = Table(
  127. "association_volunteer_slot",
  128. Base.metadata,
  129. Column(
  130. "volunteer_id",
  131. ForeignKey("volunteers.id", ondelete="CASCADE"),
  132. primary_key=True,
  133. ),
  134. Column("slot_id", ForeignKey("slots.id", ondelete="CASCADE"), primary_key=True),
  135. )
  136. association_table_volunteer_group = Table(
  137. "association_volunteer_group",
  138. Base.metadata,
  139. Column("volunteer_id", ForeignKey("volunteers.id", ondelete="CASCADE"), primary_key=True),
  140. Column("group_id", ForeignKey("volunteer_groups.id", ondelete="CASCADE"), primary_key=True),
  141. )
  142. class VolunteerGroup(Base):
  143. __tablename__ = "volunteer_groups"
  144. id: Mapped[UUID] = uid_column()
  145. project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
  146. project: Mapped["Project"] = relationship()
  147. created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
  148. updated_at: Mapped[datetime] = mapped_column(
  149. DateTime(timezone=True), default=datetime.now, onupdate=func.now()
  150. )
  151. name: Mapped[str] = mapped_column(String(128), nullable=False)
  152. color: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) # for UI chips later
  153. volunteers: Mapped[list["Volunteer"]] = relationship(
  154. secondary=association_table_volunteer_group, back_populates="groups"
  155. )
  156. @hybrid_property
  157. def volunteers_id(self) -> list[str]:
  158. return [v.id for v in self.volunteers]
  159. class Volunteer(Base):
  160. __tablename__ = "volunteers"
  161. id: Mapped[UUID] = uid_column()
  162. project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
  163. project: Mapped["Project"] = relationship(back_populates="volunteers")
  164. created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
  165. updated_at: Mapped[datetime] = mapped_column(
  166. DateTime(timezone=True), default=datetime.now, onupdate=func.now()
  167. )
  168. name: Mapped[str] = mapped_column(String(128))
  169. surname: Mapped[str] = mapped_column(String(128))
  170. email: Mapped[str] = mapped_column(String(128))
  171. phone_number: Mapped[str] = mapped_column(String(128))
  172. automatic_sms: Mapped[bool] = mapped_column(Boolean(), default=False)
  173. slots: Mapped[list["Slot"]] = relationship(
  174. secondary=association_table_volunteer_slot, back_populates="volunteers"
  175. )
  176. comment: Mapped[str] = mapped_column(String(), default="")
  177. sms: Mapped[list["Sms"]] = relationship(back_populates="volunteer", cascade="all, delete")
  178. groups: Mapped[list["VolunteerGroup"]] = relationship(
  179. secondary=association_table_volunteer_group, back_populates="volunteers"
  180. )
  181. @hybrid_property
  182. def slots_id(self) -> list[str]:
  183. return [s.id for s in self.slots]
  184. class Slot(Base):
  185. __tablename__ = "slots"
  186. id: Mapped[UUID] = uid_column()
  187. project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
  188. project: Mapped["Project"] = relationship(back_populates="slots")
  189. created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
  190. updated_at: Mapped[datetime] = mapped_column(
  191. DateTime(timezone=True), default=datetime.now, onupdate=func.now()
  192. )
  193. title: Mapped[str] = mapped_column(String(128), nullable=False)
  194. starting_time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
  195. ending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
  196. required_volunteers: Mapped[int] = mapped_column(Integer, default=0)
  197. volunteers: Mapped[list[Volunteer]] = relationship(
  198. secondary=association_table_volunteer_slot, back_populates="slots"
  199. )
  200. template_id: Mapped[Optional[UUID]] = mapped_column(
  201. ForeignKey("slot_templates.id", ondelete="SET NULL"), nullable=True
  202. )
  203. template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
  204. @hybrid_property
  205. def volunteers_id(self) -> list[str]:
  206. return [v.id for v in self.volunteers]
  207. association_table_template_tags = Table(
  208. "association_description_tag",
  209. Base.metadata,
  210. Column(
  211. "description_id",
  212. ForeignKey("slot_templates.id", ondelete="CASCADE"),
  213. primary_key=True,
  214. ),
  215. Column("tag_id", ForeignKey("slot_tags.id", ondelete="CASCADE"), primary_key=True),
  216. )
  217. class SlotTag(Base):
  218. __tablename__ = "slot_tags"
  219. id: Mapped[UUID] = uid_column()
  220. project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
  221. project: Mapped["Project"] = relationship(back_populates="tags")
  222. created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
  223. updated_at: Mapped[datetime] = mapped_column(
  224. DateTime(timezone=True), default=datetime.now, onupdate=func.now()
  225. )
  226. title: Mapped[str] = mapped_column(String(), default="")
  227. templates: Mapped[list["SlotTemplate"]] = relationship(
  228. secondary=association_table_template_tags, back_populates="tags"
  229. )
  230. @hybrid_property
  231. def templates_id(self) -> list[str]:
  232. return [s.id for s in self.templates]
  233. class SlotTemplate(Base):
  234. __tablename__ = "slot_templates"
  235. id: Mapped[UUID] = uid_column()
  236. project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
  237. project: Mapped["Project"] = relationship(back_populates="templates")
  238. created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
  239. updated_at: Mapped[datetime] = mapped_column(
  240. DateTime(timezone=True), default=datetime.now, onupdate=func.now()
  241. )
  242. title: Mapped[str] = mapped_column(String(), default="")
  243. description: Mapped[str] = mapped_column(String(), default="")
  244. place: Mapped[str] = mapped_column(String(), default="")
  245. commission_id: Mapped[str | None] = mapped_column(ForeignKey("commissions.id", ondelete="SET NULL"),nullable=True)
  246. commission: Mapped["Commission"] = relationship(back_populates="templates")
  247. responsible_override: Mapped[Optional[str]] = mapped_column(String(), nullable=True, default=None)
  248. slots: Mapped[list[Slot]] = relationship(back_populates="template")
  249. tags: Mapped[list[SlotTag]] = relationship(
  250. secondary=association_table_template_tags, back_populates="templates"
  251. )
  252. comment: Mapped[str] = mapped_column(String(), default="")
  253. @hybrid_property
  254. def slots_id(self) -> list[str]:
  255. return [s.id for s in self.slots]
  256. @hybrid_property
  257. def tags_id(self) -> list[str]:
  258. return [s.id for s in self.tags]
  259. @hybrid_property
  260. def effective_responsible_contact(self) -> str:
  261. """What SMS/templates should actually display as {respo}."""
  262. if self.responsible_override:
  263. return self.responsible_override
  264. if self.commission is not None:
  265. contacts = self.commission.contacts # [{"name":..., "phone_number":...}, ...]
  266. return ", ".join(
  267. f"{c['name']} : {c['phone_number']}" for c in contacts if c.get("phone_number")
  268. )
  269. return ""
  270. class Sms(Base):
  271. __tablename__ = "sms"
  272. id: Mapped[str] = mapped_column(
  273. UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
  274. )
  275. project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
  276. project: Mapped["Project"] = relationship(back_populates="sms")
  277. volunteer_id: Mapped[UUID] = mapped_column(
  278. ForeignKey("volunteers.id", ondelete="CASCADE", onupdate="CASCADE"),
  279. nullable=True,
  280. )
  281. volunteer: Mapped["Volunteer"] = relationship(back_populates="sms", cascade="all, delete")
  282. created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
  283. updated_at: Mapped[datetime] = mapped_column(
  284. DateTime(timezone=True), default=datetime.now, onupdate=func.now()
  285. )
  286. content: Mapped[str] = mapped_column(String(), nullable=False)
  287. phone_number: Mapped[str] = mapped_column(String(24))
  288. sending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
  289. send_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
  290. class ServerStatus(Base):
  291. __tablename__ = "server_status"
  292. # Use a fixed ID to ensure we only ever have one row
  293. id = Column(Integer, primary_key=True, default=1)
  294. updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now())
  295. host = Column(String, nullable=False)
  296. user_agent = Column(String)