Coverage for app / backend / src / couchers / models / clusters.py: 98%
128 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
1import enum
2from datetime import datetime
3from typing import TYPE_CHECKING
5from geoalchemy2 import Geometry
6from sqlalchemy import (
7 BigInteger,
8 Boolean,
9 CheckConstraint,
10 DateTime,
11 Enum,
12 ForeignKey,
13 Index,
14 String,
15 UniqueConstraint,
16 func,
17 select,
18 text,
19)
20from sqlalchemy.orm import (
21 DynamicMapped,
22 Mapped,
23 column_property,
24 deferred,
25 mapped_column,
26 relationship,
27)
28from sqlalchemy.sql import expression
30from couchers.models.base import Base, Geom, communities_seq
31from couchers.models.static import TimezoneArea
32from couchers.utils import get_coordinates
34if TYPE_CHECKING:
35 from couchers.models import Discussion, Event, Thread, Upload, User
36 from couchers.models.public_trips import PublicTrip
39class NodeType(enum.Enum):
40 # Ordinal: lower values are broader geographic areas
41 world = 1
42 macroregion = 2
43 region = 3
44 subregion = 4
45 locality = 5
46 sublocality = 6
49class Node(Base, kw_only=True):
50 """
51 Node, i.e., geographical subdivision of the world
53 Administered by the official cluster
54 """
56 __tablename__ = "nodes"
58 id: Mapped[int] = mapped_column(
59 BigInteger,
60 communities_seq,
61 primary_key=True,
62 server_default=communities_seq.next_value(),
63 init=False,
64 )
66 node_type: Mapped[NodeType] = mapped_column(Enum(NodeType))
68 # name and description come from the official cluster
69 parent_node_id: Mapped[int | None] = mapped_column(ForeignKey("nodes.id"), default=None, index=True)
70 geom: Mapped[Geom] = deferred(mapped_column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False))
71 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
73 timezone = column_property(
74 select(TimezoneArea.tzid)
75 .where(func.ST_Contains(TimezoneArea.geom, func.ST_PointOnSurface(geom)))
76 .limit(1)
77 .scalar_subquery(),
78 deferred=True,
79 )
81 parent_node: Mapped[Node] = relationship(init=False, back_populates="child_nodes", remote_side="Node.id")
82 child_nodes: Mapped[list[Node]] = relationship(init=False)
83 child_clusters: Mapped[list[Cluster]] = relationship(
84 init=False, back_populates="parent_node", overlaps="official_cluster"
85 )
86 official_cluster: Mapped[Cluster] = relationship(
87 init=False,
88 primaryjoin="and_(Node.id == Cluster.parent_node_id, Cluster.is_official_cluster)",
89 foreign_keys="[Cluster.parent_node_id]",
90 uselist=False,
91 viewonly=True,
92 )
93 public_trips: Mapped[list[PublicTrip]] = relationship(init=False, back_populates="node")
96class Cluster(Base, kw_only=True):
97 """
98 Cluster, administered grouping of content
99 """
101 __tablename__ = "clusters"
103 id: Mapped[int] = mapped_column(
104 BigInteger,
105 communities_seq,
106 primary_key=True,
107 server_default=communities_seq.next_value(),
108 init=False,
109 )
110 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
111 name: Mapped[str] = mapped_column(String)
112 # short description
113 description: Mapped[str] = mapped_column(String)
114 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
116 is_official_cluster: Mapped[bool] = mapped_column(Boolean, default=False)
118 # Toggles the "small community" feature set: discussions, events, and public trips. These are
119 # grouped into a single flag because they're disabled together on large communities.
120 small_community_features_enabled: Mapped[bool] = mapped_column(
121 Boolean, default=True, server_default=expression.true()
122 )
124 slug: Mapped[str] = column_property(func.slugify(name))
126 official_cluster_for_node: Mapped[Node] = relationship(
127 init=False,
128 primaryjoin="and_(Cluster.parent_node_id == Node.id, Cluster.is_official_cluster)",
129 back_populates="official_cluster",
130 uselist=False,
131 viewonly=True,
132 )
134 parent_node: Mapped[Node] = relationship(
135 init=False,
136 back_populates="child_clusters",
137 remote_side="Node.id",
138 foreign_keys="Cluster.parent_node_id",
139 overlaps="official_cluster",
140 )
142 nodes: Mapped[list[Cluster]] = relationship(
143 init=False, backref="clusters", secondary="node_cluster_associations", viewonly=True
144 )
145 # all pages
146 pages: DynamicMapped[Page] = relationship(
147 init=False, backref="clusters", secondary="cluster_page_associations", lazy="dynamic", viewonly=True
148 )
149 events: Mapped[Event] = relationship(
150 init=False, backref="clusters", secondary="cluster_event_associations", viewonly=True
151 )
152 discussions: Mapped[Discussion] = relationship(
153 init=False, backref="clusters", secondary="cluster_discussion_associations", viewonly=True
154 )
156 # includes also admins
157 members: DynamicMapped[User] = relationship(
158 init=False,
159 lazy="dynamic",
160 backref="cluster_memberships",
161 secondary="cluster_subscriptions",
162 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
163 secondaryjoin="User.id == ClusterSubscription.user_id",
164 viewonly=True,
165 )
167 admins: DynamicMapped[User] = relationship(
168 init=False,
169 lazy="dynamic",
170 backref="cluster_adminships",
171 secondary="cluster_subscriptions",
172 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
173 secondaryjoin="and_(User.id == ClusterSubscription.user_id, ClusterSubscription.role == 'admin')",
174 viewonly=True,
175 )
177 cluster_subscriptions: Mapped[list[ClusterSubscription]] = relationship(init=False)
178 owned_pages: DynamicMapped[Page] = relationship(init=False, lazy="dynamic")
179 owned_discussions: DynamicMapped[Discussion] = relationship(init=False, lazy="dynamic")
181 main_page: Mapped[Page] = relationship(
182 init=False,
183 primaryjoin="and_(Cluster.id == Page.owner_cluster_id, Page.type == 'main_page')",
184 viewonly=True,
185 uselist=False,
186 )
188 @property
189 def is_leaf(self) -> bool:
190 """Whether the cluster is a leaf node in the cluster hierarchy."""
191 return len(self.parent_node.child_nodes) == 0
193 __table_args__ = (
194 # Each node can have at most one official cluster
195 Index(
196 "ix_clusters_owner_parent_node_id_is_official_cluster",
197 parent_node_id,
198 is_official_cluster,
199 unique=True,
200 postgresql_where=is_official_cluster,
201 ),
202 # trigram index on unaccented name
203 # note that the function `unaccent` is not immutable so cannot be used in an index, that's why we wrap it
204 Index(
205 "idx_clusters_name_unaccented_trgm",
206 text("immutable_unaccent(name) gin_trgm_ops"),
207 postgresql_using="gin",
208 ),
209 )
212class NodeClusterAssociation(Base, kw_only=True):
213 """
214 NodeClusterAssociation, grouping of nodes
215 """
217 __tablename__ = "node_cluster_associations"
218 __table_args__ = (UniqueConstraint("node_id", "cluster_id"),)
220 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
222 node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
223 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
225 node: Mapped[Node] = relationship(init=False, backref="node_cluster_associations")
226 cluster: Mapped[Cluster] = relationship(init=False, backref="node_cluster_associations")
229class ClusterRole(enum.Enum):
230 member = enum.auto()
231 admin = enum.auto()
234class ClusterSubscription(Base, kw_only=True):
235 """
236 ClusterSubscription of a user
237 """
239 __tablename__ = "cluster_subscriptions"
241 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
243 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
244 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
245 role: Mapped[ClusterRole] = mapped_column(Enum(ClusterRole))
247 user: Mapped[User] = relationship(init=False, backref="cluster_subscriptions")
248 cluster: Mapped[Cluster] = relationship(init=False, back_populates="cluster_subscriptions")
250 __table_args__ = (
251 UniqueConstraint("user_id", "cluster_id"),
252 Index(
253 "ix_cluster_subscriptions_members",
254 cluster_id,
255 user_id,
256 ),
257 # For fast lookup of nodes this user is an admin of
258 Index(
259 "ix_cluster_subscriptions_admins",
260 user_id,
261 cluster_id,
262 postgresql_where=(role == ClusterRole.admin),
263 ),
264 )
267class ClusterPageAssociation(Base, kw_only=True):
268 """
269 pages related to clusters
270 """
272 __tablename__ = "cluster_page_associations"
273 __table_args__ = (UniqueConstraint("page_id", "cluster_id"),)
275 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
277 page_id: Mapped[int] = mapped_column(ForeignKey("pages.id"), index=True)
278 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
280 page: Mapped[Page] = relationship(init=False, backref="cluster_page_associations")
281 cluster: Mapped[Cluster] = relationship(init=False, backref="cluster_page_associations")
284class PageType(enum.Enum):
285 main_page = enum.auto()
286 place = enum.auto()
287 guide = enum.auto()
290class Page(Base, kw_only=True):
291 """
292 similar to a wiki page about a community, POI or guide
293 """
295 __tablename__ = "pages"
297 id: Mapped[int] = mapped_column(
298 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
299 )
301 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
302 type: Mapped[PageType] = mapped_column(Enum(PageType))
303 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
304 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None, index=True)
305 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), default=None, index=True)
307 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
309 parent_node: Mapped[Node] = relationship(
310 init=False, backref="child_pages", remote_side="Node.id", foreign_keys="Page.parent_node_id"
311 )
313 thread: Mapped[Thread] = relationship(init=False, backref="page", uselist=False)
314 creator_user: Mapped[User] = relationship(init=False, backref="created_pages", foreign_keys="Page.creator_user_id")
315 owner_user: Mapped[User | None] = relationship(init=False, backref="owned_pages", foreign_keys="Page.owner_user_id")
316 owner_cluster: Mapped[Cluster | None] = relationship(
317 init=False, back_populates="owned_pages", uselist=False, foreign_keys="Page.owner_cluster_id"
318 )
320 editors: Mapped[list[User]] = relationship(init=False, secondary="page_versions", viewonly=True)
321 versions: Mapped[list[PageVersion]] = relationship(init=False, back_populates="page", order_by="PageVersion.id")
323 __table_args__ = (
324 # Only one of owner_user and owner_cluster should be set
325 CheckConstraint(
326 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
327 name="one_owner",
328 ),
329 # Only clusters can own main pages
330 CheckConstraint(
331 "NOT (owner_cluster_id IS NULL AND type = 'main_page')",
332 name="main_page_owned_by_cluster",
333 ),
334 # Each cluster can have at most one main page
335 Index(
336 "ix_pages_owner_cluster_id_type",
337 owner_cluster_id,
338 type,
339 unique=True,
340 postgresql_where=(type == PageType.main_page),
341 ),
342 )
344 def __repr__(self) -> str:
345 return f"Page({self.id=})"
348class PageVersion(Base, kw_only=True):
349 """
350 version of page content
351 """
353 __tablename__ = "page_versions"
355 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
357 page_id: Mapped[int] = mapped_column(ForeignKey("pages.id"), index=True)
358 editor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
359 title: Mapped[str] = mapped_column(String)
360 content: Mapped[str] = mapped_column(String) # CommonMark without images
361 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), default=None)
362 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
363 # the human-readable address
364 address: Mapped[str | None] = mapped_column(String, default=None)
365 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
367 slug: Mapped[str] = column_property(func.slugify(title))
369 page: Mapped[Page] = relationship(init=False, back_populates="versions")
370 editor_user: Mapped[User] = relationship(init=False, backref="edited_pages")
371 photo: Mapped[Upload] = relationship(init=False)
373 __table_args__ = (
374 # Geom and address must either both be null or both be set
375 CheckConstraint(
376 "(geom IS NULL) = (address IS NULL)",
377 name="geom_iff_address",
378 ),
379 )
381 @property
382 def coordinates(self) -> tuple[float, float] | None:
383 # returns (lat, lng) or None
384 return get_coordinates(self.geom)
386 def __repr__(self) -> str:
387 return f"PageVersion({self.id=}, {self.page_id=})"