Coverage for src/couchers/models/clusters.py: 98%
110 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
1import enum
2from datetime import datetime
4from geoalchemy2 import Geometry
5from sqlalchemy import (
6 BigInteger,
7 Boolean,
8 CheckConstraint,
9 DateTime,
10 Enum,
11 ForeignKey,
12 Index,
13 String,
14 UniqueConstraint,
15 func,
16 text,
17)
18from sqlalchemy.orm import Mapped, backref, column_property, deferred, mapped_column, relationship
19from sqlalchemy.sql import expression
21from couchers.models.base import Base, Geom, communities_seq
22from couchers.utils import get_coordinates
25class Node(Base):
26 """
27 Node, i.e., geographical subdivision of the world
29 Administered by the official cluster
30 """
32 __tablename__ = "nodes"
34 id: Mapped[int] = mapped_column(
35 BigInteger,
36 communities_seq,
37 primary_key=True,
38 server_default=communities_seq.next_value(),
39 )
41 # name and description come from the official cluster
42 parent_node_id: Mapped[int | None] = mapped_column(ForeignKey("nodes.id"), nullable=True, index=True)
43 geom: Mapped[Geom] = deferred(mapped_column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False))
44 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
46 parent_node = relationship("Node", backref="child_nodes", remote_side="Node.id")
49class Cluster(Base):
50 """
51 Cluster, administered grouping of content
52 """
54 __tablename__ = "clusters"
56 id: Mapped[int] = mapped_column(
57 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()
58 )
59 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
60 name: Mapped[str] = mapped_column(String)
61 # short description
62 description: Mapped[str] = mapped_column(String)
63 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
65 is_official_cluster: Mapped[bool] = mapped_column(Boolean, default=False)
67 discussions_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default=expression.true())
68 events_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default=expression.true())
70 slug = column_property(func.slugify(name))
72 official_cluster_for_node = relationship(
73 "Node",
74 primaryjoin="and_(Cluster.parent_node_id == Node.id, Cluster.is_official_cluster)",
75 backref=backref("official_cluster", uselist=False),
76 uselist=False,
77 viewonly=True,
78 )
80 parent_node = relationship(
81 "Node", backref="child_clusters", remote_side="Node.id", foreign_keys="Cluster.parent_node_id"
82 )
84 nodes = relationship("Cluster", backref="clusters", secondary="node_cluster_associations", viewonly=True)
85 # all pages
86 pages = relationship(
87 "Page", backref="clusters", secondary="cluster_page_associations", lazy="dynamic", viewonly=True
88 )
89 events = relationship("Event", backref="clusters", secondary="cluster_event_associations", viewonly=True)
90 discussions = relationship(
91 "Discussion", backref="clusters", secondary="cluster_discussion_associations", viewonly=True
92 )
94 # includes also admins
95 members = relationship(
96 "User",
97 lazy="dynamic",
98 backref="cluster_memberships",
99 secondary="cluster_subscriptions",
100 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
101 secondaryjoin="User.id == ClusterSubscription.user_id",
102 viewonly=True,
103 )
105 admins = relationship(
106 "User",
107 lazy="dynamic",
108 backref="cluster_adminships",
109 secondary="cluster_subscriptions",
110 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
111 secondaryjoin="and_(User.id == ClusterSubscription.user_id, ClusterSubscription.role == 'admin')",
112 viewonly=True,
113 )
115 main_page = relationship(
116 "Page",
117 primaryjoin="and_(Cluster.id == Page.owner_cluster_id, Page.type == 'main_page')",
118 viewonly=True,
119 uselist=False,
120 )
122 @property
123 def is_leaf(self) -> bool:
124 """Whether the cluster is a leaf node in the cluster hierarchy."""
125 return len(self.parent_node.child_nodes) == 0
127 __table_args__ = (
128 # Each node can have at most one official cluster
129 Index(
130 "ix_clusters_owner_parent_node_id_is_official_cluster",
131 parent_node_id,
132 is_official_cluster,
133 unique=True,
134 postgresql_where=is_official_cluster,
135 ),
136 # trigram index on unaccented name
137 # note that the function `unaccent` is not immutable so cannot be used in an index, that's why we wrap it
138 Index(
139 "idx_clusters_name_unaccented_trgm",
140 text("immutable_unaccent(name) gin_trgm_ops"),
141 postgresql_using="gin",
142 ),
143 )
146class NodeClusterAssociation(Base):
147 """
148 NodeClusterAssociation, grouping of nodes
149 """
151 __tablename__ = "node_cluster_associations"
152 __table_args__ = (UniqueConstraint("node_id", "cluster_id"),)
154 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
156 node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
157 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
159 node = relationship("Node", backref="node_cluster_associations")
160 cluster = relationship("Cluster", backref="node_cluster_associations")
163class ClusterRole(enum.Enum):
164 member = enum.auto()
165 admin = enum.auto()
168class ClusterSubscription(Base):
169 """
170 ClusterSubscription of a user
171 """
173 __tablename__ = "cluster_subscriptions"
175 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
177 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
178 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
179 role: Mapped[ClusterRole] = mapped_column(Enum(ClusterRole))
181 user = relationship("User", backref="cluster_subscriptions")
182 cluster = relationship("Cluster", backref="cluster_subscriptions")
184 __table_args__ = (
185 UniqueConstraint("user_id", "cluster_id"),
186 Index(
187 "ix_cluster_subscriptions_members",
188 cluster_id,
189 user_id,
190 ),
191 # For fast lookup of nodes this user is an admin of
192 Index(
193 "ix_cluster_subscriptions_admins",
194 user_id,
195 cluster_id,
196 postgresql_where=(role == ClusterRole.admin),
197 ),
198 )
201class ClusterPageAssociation(Base):
202 """
203 pages related to clusters
204 """
206 __tablename__ = "cluster_page_associations"
207 __table_args__ = (UniqueConstraint("page_id", "cluster_id"),)
209 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
211 page_id: Mapped[int] = mapped_column(ForeignKey("pages.id"), index=True)
212 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
214 page = relationship("Page", backref="cluster_page_associations")
215 cluster = relationship("Cluster", backref="cluster_page_associations")
218class PageType(enum.Enum):
219 main_page = enum.auto()
220 place = enum.auto()
221 guide = enum.auto()
224class Page(Base):
225 """
226 similar to a wiki page about a community, POI or guide
227 """
229 __tablename__ = "pages"
231 id: Mapped[int] = mapped_column(
232 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value()
233 )
235 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
236 type: Mapped[PageType] = mapped_column(Enum(PageType))
237 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
238 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
239 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), nullable=True, index=True)
241 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
243 parent_node = relationship("Node", backref="child_pages", remote_side="Node.id", foreign_keys="Page.parent_node_id")
245 thread = relationship("Thread", backref="page", uselist=False)
246 creator_user = relationship("User", backref="created_pages", foreign_keys="Page.creator_user_id")
247 owner_user = relationship("User", backref="owned_pages", foreign_keys="Page.owner_user_id")
248 owner_cluster = relationship(
249 "Cluster", backref=backref("owned_pages", lazy="dynamic"), uselist=False, foreign_keys="Page.owner_cluster_id"
250 )
252 editors = relationship("User", secondary="page_versions", viewonly=True)
254 __table_args__ = (
255 # Only one of owner_user and owner_cluster should be set
256 CheckConstraint(
257 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
258 name="one_owner",
259 ),
260 # Only clusters can own main pages
261 CheckConstraint(
262 "NOT (owner_cluster_id IS NULL AND type = 'main_page')",
263 name="main_page_owned_by_cluster",
264 ),
265 # Each cluster can have at most one main page
266 Index(
267 "ix_pages_owner_cluster_id_type",
268 owner_cluster_id,
269 type,
270 unique=True,
271 postgresql_where=(type == PageType.main_page),
272 ),
273 )
275 def __repr__(self) -> str:
276 return f"Page({self.id=})"
279class PageVersion(Base):
280 """
281 version of page content
282 """
284 __tablename__ = "page_versions"
286 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
288 page_id: Mapped[int] = mapped_column(ForeignKey("pages.id"), index=True)
289 editor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
290 title: Mapped[str] = mapped_column(String)
291 content: Mapped[str] = mapped_column(String) # CommonMark without images
292 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), nullable=True)
293 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
294 # the human-readable address
295 address: Mapped[str | None] = mapped_column(String, nullable=True)
296 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
298 slug = column_property(func.slugify(title))
300 page = relationship("Page", backref="versions", order_by="PageVersion.id")
301 editor_user = relationship("User", backref="edited_pages")
302 photo = relationship("Upload")
304 __table_args__ = (
305 # Geom and address must either both be null or both be set
306 CheckConstraint(
307 "(geom IS NULL) = (address IS NULL)",
308 name="geom_iff_address",
309 ),
310 )
312 @property
313 def coordinates(self) -> tuple[float, float] | None:
314 # returns (lat, lng) or None
315 return get_coordinates(self.geom)
317 def __repr__(self) -> str:
318 return f"PageVersion({self.id=}, {self.page_id=})"