models.py 15 KB

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