| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 |
- """
- SQL Alchemy models declaration.
- https://docs.sqlalchemy.org/en/14/orm/declarative_styles.html#example-two-dataclasses-with-declarative-table
- Dataclass style for powerful autocompletion support.
- https://alembic.sqlalchemy.org/en/latest/tutorial.html
- Note, it is used by alembic migrations logic, see `alembic/env.py`
- Alembic shortcuts:
- # create migration
- alembic revision --autogenerate -m "migration_name"
- # apply all migrations
- alembic upgrade head
- """
- import enum
- import uuid
- from datetime import datetime
- from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table
- from sqlalchemy import Enum as SAEnum
- from sqlalchemy.dialects.postgresql import UUID
- from sqlalchemy.ext.hybrid import hybrid_property
- from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
- from sqlalchemy.sql import func
- class Base(DeclarativeBase):
- pass
- def uid_column() -> Mapped[str]:
- """Returns a postgreSQL UUID column for SQL ORM"""
- return mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4()))
- class GlobalRole(str, enum.Enum):
- SUPER_ADMIN = "super_admin"
- USER = "user"
- class OrgRole(str, enum.Enum):
- ORG_ADMIN = "org_admin"
- RESPO_BENEVOLE = "respo_benevole"
- RESPO_COMMISSION = "respo_commission"
- class User(Base):
- __tablename__ = "user_model"
- id: Mapped[UUID] = uid_column()
- email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
- hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
- name: Mapped[str] = mapped_column(String(128), default="")
- phone_number: Mapped[str | None] = mapped_column(String(24), nullable=True)
- global_role: Mapped[GlobalRole] = mapped_column(
- SAEnum(GlobalRole, name="global_role"), default=GlobalRole.USER, nullable=False
- )
- organizations: Mapped[list["UserOrganization"]] = relationship(
- back_populates="user", cascade="all, delete-orphan"
- )
- commissions: Mapped[list["CommissionMember"]] = relationship(
- back_populates="user", cascade="all, delete-orphan"
- )
- class Organization(Base):
- __tablename__ = "organizations"
- id: Mapped[UUID] = uid_column()
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, onupdate=func.now()
- )
- name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
- projects: Mapped[list["Project"]] = relationship(
- back_populates="organization", cascade="all, delete-orphan"
- )
- memberships: Mapped[list["UserOrganization"]] = relationship(
- back_populates="organization",
- cascade="all, delete-orphan",
- passive_deletes=True, # let the DB's ON DELETE CASCADE do the actual delete
- )
- class UserOrganization(Base):
- """Many-to-many: a user can belong to several orgs, one role per org."""
- __tablename__ = "user_organizations"
- user_id: Mapped[UUID] = mapped_column(
- ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True
- )
- organization_id: Mapped[UUID] = mapped_column(
- ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
- )
- role: Mapped[OrgRole] = mapped_column(SAEnum(OrgRole, name="org_role"), nullable=False)
- user: Mapped["User"] = relationship(back_populates="organizations")
- organization: Mapped["Organization"] = relationship(back_populates="memberships")
- class Project(Base):
- __tablename__ = "projects"
- id: Mapped[UUID] = uid_column()
- organization_id: Mapped[UUID] = mapped_column(
- ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
- )
- organization: Mapped["Organization"] = relationship(back_populates="projects")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, onupdate=func.now()
- )
- name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
- is_public: Mapped[bool] = mapped_column(Boolean())
- volunteers: Mapped[list["Volunteer"]] = relationship(
- back_populates="project", cascade="delete, delete-orphan"
- )
- slots: Mapped[list["Slot"]] = relationship(
- back_populates="project", cascade="delete, delete-orphan"
- )
- sms: Mapped[list["Sms"]] = relationship(
- back_populates="project", cascade="delete, delete-orphan"
- )
- templates: Mapped[list["SlotTemplate"]] = relationship(
- back_populates="project", cascade="delete, delete-orphan"
- )
- tags: Mapped[list["SlotTag"]] = relationship(
- back_populates="project", cascade="delete, delete-orphan"
- )
- commissions: Mapped[list["Commission"]] = relationship(
- back_populates="project", cascade="delete, delete-orphan"
- )
- groups: Mapped[list["VolunteerGroup"]] = relationship(
- back_populates="project", cascade="all, delete-orphan", passive_deletes=True
- )
- class Commission(Base):
- __tablename__ = "commissions"
- id: Mapped[UUID] = uid_column()
- project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
- project: Mapped["Project"] = relationship(back_populates="commissions")
- name: Mapped[str] = mapped_column(String(128), nullable=False) # e.g. "Bar", "Accueil"
- templates: Mapped[list["SlotTemplate"]] = relationship(back_populates="commission")
- members: Mapped[list["CommissionMember"]] = relationship(
- back_populates="commission", cascade="all, delete-orphan"
- )
- @hybrid_property
- def contacts(self) -> list[dict]:
- """Read-only, derived from members' own profile — never stored redundantly."""
- return [{"name": m.user.name, "phone_number": m.user.phone_number} for m in self.members]
- class CommissionMember(Base):
- __tablename__ = "commission_members"
- commission_id: Mapped[UUID] = mapped_column(
- ForeignKey("commissions.id", ondelete="CASCADE"), primary_key=True
- )
- user_id: Mapped[UUID] = mapped_column(
- ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True
- )
- commission: Mapped["Commission"] = relationship(back_populates="members")
- user: Mapped["User"] = relationship(back_populates="commissions")
- association_table_volunteer_slot = Table(
- "association_volunteer_slot",
- Base.metadata,
- Column(
- "volunteer_id",
- ForeignKey("volunteers.id", ondelete="CASCADE"),
- primary_key=True,
- ),
- Column("slot_id", ForeignKey("slots.id", ondelete="CASCADE"), primary_key=True),
- )
- association_table_volunteer_group = Table(
- "association_volunteer_group",
- Base.metadata,
- Column("volunteer_id", ForeignKey("volunteers.id", ondelete="CASCADE"), primary_key=True),
- Column("group_id", ForeignKey("volunteer_groups.id", ondelete="CASCADE"), primary_key=True),
- )
- class VolunteerGroup(Base):
- __tablename__ = "volunteer_groups"
- id: Mapped[UUID] = uid_column()
- project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
- project: Mapped["Project"] = relationship()
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, onupdate=func.now()
- )
- name: Mapped[str] = mapped_column(String(128), nullable=False)
- color: Mapped[str | None] = mapped_column(String(16), nullable=True) # for UI chips later
- volunteers: Mapped[list["Volunteer"]] = relationship(
- secondary=association_table_volunteer_group, back_populates="groups"
- )
- @hybrid_property
- def volunteers_id(self) -> list[str]:
- return [v.id for v in self.volunteers]
- class Volunteer(Base):
- __tablename__ = "volunteers"
- id: Mapped[UUID] = uid_column()
- project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
- project: Mapped["Project"] = relationship(back_populates="volunteers")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, onupdate=func.now()
- )
- name: Mapped[str] = mapped_column(String(128))
- surname: Mapped[str] = mapped_column(String(128))
- email: Mapped[str] = mapped_column(String(128))
- phone_number: Mapped[str] = mapped_column(String(128))
- automatic_sms: Mapped[bool] = mapped_column(Boolean(), default=False)
- slots: Mapped[list["Slot"]] = relationship(
- secondary=association_table_volunteer_slot, back_populates="volunteers"
- )
- comment: Mapped[str] = mapped_column(String(), default="")
- sms: Mapped[list["Sms"]] = relationship(back_populates="volunteer", cascade="all, delete")
- groups: Mapped[list["VolunteerGroup"]] = relationship(
- secondary=association_table_volunteer_group, back_populates="volunteers"
- )
- @hybrid_property
- def slots_id(self) -> list[str]:
- return [s.id for s in self.slots]
- class Slot(Base):
- __tablename__ = "slots"
- id: Mapped[UUID] = uid_column()
- project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
- project: Mapped["Project"] = relationship(back_populates="slots")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, onupdate=func.now()
- )
- title: Mapped[str] = mapped_column(String(128), nullable=False)
- starting_time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
- ending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
- required_volunteers: Mapped[int] = mapped_column(Integer, default=0)
- volunteers: Mapped[list[Volunteer]] = relationship(
- secondary=association_table_volunteer_slot, back_populates="slots"
- )
- template_id: Mapped[UUID | None] = mapped_column(
- ForeignKey("slot_templates.id", ondelete="SET NULL"), nullable=True
- )
- template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
- @hybrid_property
- def volunteers_id(self) -> list[str]:
- return [v.id for v in self.volunteers]
- association_table_template_tags = Table(
- "association_description_tag",
- Base.metadata,
- Column(
- "description_id",
- ForeignKey("slot_templates.id", ondelete="CASCADE"),
- primary_key=True,
- ),
- Column("tag_id", ForeignKey("slot_tags.id", ondelete="CASCADE"), primary_key=True),
- )
- class SlotTag(Base):
- __tablename__ = "slot_tags"
- id: Mapped[UUID] = uid_column()
- project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
- project: Mapped["Project"] = relationship(back_populates="tags")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, onupdate=func.now()
- )
- title: Mapped[str] = mapped_column(String(), default="")
- templates: Mapped[list["SlotTemplate"]] = relationship(
- secondary=association_table_template_tags, back_populates="tags"
- )
- @hybrid_property
- def templates_id(self) -> list[str]:
- return [s.id for s in self.templates]
- class SlotTemplate(Base):
- __tablename__ = "slot_templates"
- id: Mapped[UUID] = uid_column()
- project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
- project: Mapped["Project"] = relationship(back_populates="templates")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, onupdate=func.now()
- )
- title: Mapped[str] = mapped_column(String(), default="")
- description: Mapped[str] = mapped_column(String(), default="")
- place: Mapped[str] = mapped_column(String(), default="")
- commission_id: Mapped[str | None] = mapped_column(
- ForeignKey("commissions.id", ondelete="SET NULL"), nullable=True
- )
- commission: Mapped["Commission"] = relationship(back_populates="templates")
- responsible_override: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
- slots: Mapped[list[Slot]] = relationship(back_populates="template")
- tags: Mapped[list[SlotTag]] = relationship(
- secondary=association_table_template_tags, back_populates="templates"
- )
- comment: Mapped[str] = mapped_column(String(), default="")
- @hybrid_property
- def slots_id(self) -> list[str]:
- return [s.id for s in self.slots]
- @hybrid_property
- def tags_id(self) -> list[str]:
- return [s.id for s in self.tags]
- @hybrid_property
- def effective_responsible_contact(self) -> str:
- """What SMS/templates should actually display as {respo}."""
- if self.responsible_override:
- return self.responsible_override
- if self.commission is not None:
- contacts = self.commission.contacts # [{"name":..., "phone_number":...}, ...]
- return ", ".join(
- f"{c['name']} : {c['phone_number']}" for c in contacts if c.get("phone_number")
- )
- return ""
- class Sms(Base):
- __tablename__ = "sms"
- id: Mapped[str] = mapped_column(
- UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
- )
- project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
- project: Mapped["Project"] = relationship(back_populates="sms")
- volunteer_id: Mapped[UUID] = mapped_column(
- ForeignKey("volunteers.id", ondelete="CASCADE", onupdate="CASCADE"),
- nullable=True,
- )
- volunteer: Mapped["Volunteer"] = relationship(back_populates="sms", cascade="all, delete")
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True), default=datetime.now, onupdate=func.now()
- )
- content: Mapped[str] = mapped_column(String(), nullable=False)
- phone_number: Mapped[str] = mapped_column(String(24))
- sending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
- send_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
- class ServerStatus(Base):
- __tablename__ = "server_status"
- # Use a fixed ID to ensure we only ever have one row
- id = Column(Integer, primary_key=True, default=1)
- updated_at = Column(
- DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now()
- )
- host = Column(String, nullable=False)
- user_agent = Column(String)
|