Coverage for app / backend / src / couchers / models / clusters.py: 98%
127 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +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 text,
18)
19from sqlalchemy.orm import (
20 DynamicMapped,
21 Mapped,
22 column_property,
23 deferred,
24 mapped_column,
25 relationship,
26)
27from sqlalchemy.sql import expression
29from couchers.models.base import Base, Geom, communities_seq
30from couchers.utils import get_coordinates
32if TYPE_CHECKING:
33 from couchers.models import Discussion, Event, Thread, Upload, User
34 from couchers.models.public_trips import PublicTrip
37class NodeType(enum.Enum):
38 # Ordinal: lower values are broader geographic areas
39 world = 1
40 macroregion = 2
41 region = 3
42 subregion = 4
43 locality = 5
44 sublocality = 6
47class Node(Base, kw_only=True):
48 """
49 Node, i.e., geographical subdivision of the world
51 Administered by the official cluster
52 """
54 __tablename__ = "nodes"
56 id: Mapped[int] = mapped_column(
57 BigInteger,
58 communities_seq,
59 primary_key=True,
60 server_default=communities_seq.next_value(),
61 init=False,
62 )
64 node_type: Mapped[NodeType] = mapped_column(Enum(NodeType))
66 # name and description come from the official cluster
67 parent_node_id: Mapped[int | None] = mapped_column(ForeignKey("nodes.id"), default=None, index=True)
68 geom: Mapped[Geom] = deferred(mapped_column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False))
69 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
71 parent_node: Mapped[Node] = relationship(init=False, back_populates="child_nodes", remote_side="Node.id")
72 child_nodes: Mapped[list[Node]] = relationship(init=False)
73 child_clusters: Mapped[list[Cluster]] = relationship(
74 init=False, back_populates="parent_node", overlaps="official_cluster"
75 )
76 official_cluster: Mapped[Cluster] = relationship(
77 init=False,
78 primaryjoin="and_(Node.id == Cluster.parent_node_id, Cluster.is_official_cluster)",
79 foreign_keys="[Cluster.parent_node_id]",
80 uselist=False,
81 viewonly=True,
82 )
83 public_trips: Mapped[list[PublicTrip]] = relationship(init=False, back_populates="node")
86class Cluster(Base, kw_only=True):
87 """
88 Cluster, administered grouping of content
89 """
91 __tablename__ = "clusters"
93 id: Mapped[int] = mapped_column(
94 BigInteger,
95 communities_seq,
96 primary_key=True,
97 server_default=communities_seq.next_value(),
98 init=False,
99 )
100 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
101 name: Mapped[str] = mapped_column(String)
102 # short description
103 description: Mapped[str] = mapped_column(String)
104 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
106 is_official_cluster: Mapped[bool] = mapped_column(Boolean, default=False)
108 discussions_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default=expression.true())
109 events_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default=expression.true())
111 slug: Mapped[str] = column_property(func.slugify(name))
113 official_cluster_for_node: Mapped[Node] = relationship(
114 init=False,
115 primaryjoin="and_(Cluster.parent_node_id == Node.id, Cluster.is_official_cluster)",
116 back_populates="official_cluster",
117 uselist=False,
118 viewonly=True,
119 )
121 parent_node: Mapped[Node] = relationship(
122 init=False,
123 back_populates="child_clusters",
124 remote_side="Node.id",
125 foreign_keys="Cluster.parent_node_id",
126 overlaps="official_cluster",
127 )
129 nodes: Mapped[list[Cluster]] = relationship(
130 init=False, backref="clusters", secondary="node_cluster_associations", viewonly=True
131 )
132 # all pages
133 pages: DynamicMapped[Page] = relationship(
134 init=False, backref="clusters", secondary="cluster_page_associations", lazy="dynamic", viewonly=True
135 )
136 events: Mapped[Event] = relationship(
137 init=False, backref="clusters", secondary="cluster_event_associations", viewonly=True
138 )
139 discussions: Mapped[Discussion] = relationship(
140 init=False, backref="clusters", secondary="cluster_discussion_associations", viewonly=True
141 )
143 # includes also admins
144 members: DynamicMapped[User] = relationship(
145 init=False,
146 lazy="dynamic",
147 backref="cluster_memberships",
148 secondary="cluster_subscriptions",
149 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
150 secondaryjoin="User.id == ClusterSubscription.user_id",
151 viewonly=True,
152 )
154 admins: DynamicMapped[User] = relationship(
155 init=False,
156 lazy="dynamic",
157 backref="cluster_adminships",
158 secondary="cluster_subscriptions",
159 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
160 secondaryjoin="and_(User.id == ClusterSubscription.user_id, ClusterSubscription.role == 'admin')",
161 viewonly=True,
162 )
164 cluster_subscriptions: Mapped[list[ClusterSubscription]] = relationship(init=False)
165 owned_pages: DynamicMapped[Page] = relationship(init=False, lazy="dynamic")
166 owned_discussions: DynamicMapped[Discussion] = relationship(init=False, lazy="dynamic")
168 main_page: Mapped[Page] = relationship(
169 init=False,
170 primaryjoin="and_(Cluster.id == Page.owner_cluster_id, Page.type == 'main_page')",
171 viewonly=True,
172 uselist=False,
173 )
175 @property
176 def is_leaf(self) -> bool:
177 """Whether the cluster is a leaf node in the cluster hierarchy."""
178 return len(self.parent_node.child_nodes) == 0
180 __table_args__ = (
181 # Each node can have at most one official cluster
182 Index(
183 "ix_clusters_owner_parent_node_id_is_official_cluster",
184 parent_node_id,
185 is_official_cluster,
186 unique=True,
187 postgresql_where=is_official_cluster,
188 ),
189 # trigram index on unaccented name
190 # note that the function `unaccent` is not immutable so cannot be used in an index, that's why we wrap it
191 Index(
192 "idx_clusters_name_unaccented_trgm",
193 text("immutable_unaccent(name) gin_trgm_ops"),
194 postgresql_using="gin",
195 ),
196 )
199class NodeClusterAssociation(Base, kw_only=True):
200 """
201 NodeClusterAssociation, grouping of nodes
202 """
204 __tablename__ = "node_cluster_associations"
205 __table_args__ = (UniqueConstraint("node_id", "cluster_id"),)
207 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
209 node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
210 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
212 node: Mapped[Node] = relationship(init=False, backref="node_cluster_associations")
213 cluster: Mapped[Cluster] = relationship(init=False, backref="node_cluster_associations")
216class ClusterRole(enum.Enum):
217 member = enum.auto()
218 admin = enum.auto()
221class ClusterSubscription(Base, kw_only=True):
222 """
223 ClusterSubscription of a user
224 """
226 __tablename__ = "cluster_subscriptions"
228 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
230 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
231 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
232 role: Mapped[ClusterRole] = mapped_column(Enum(ClusterRole))
234 user: Mapped[User] = relationship(init=False, backref="cluster_subscriptions")
235 cluster: Mapped[Cluster] = relationship(init=False, back_populates="cluster_subscriptions")
237 __table_args__ = (
238 UniqueConstraint("user_id", "cluster_id"),
239 Index(
240 "ix_cluster_subscriptions_members",
241 cluster_id,
242 user_id,
243 ),
244 # For fast lookup of nodes this user is an admin of
245 Index(
246 "ix_cluster_subscriptions_admins",
247 user_id,
248 cluster_id,
249 postgresql_where=(role == ClusterRole.admin),
250 ),
251 )
254class ClusterPageAssociation(Base, kw_only=True):
255 """
256 pages related to clusters
257 """
259 __tablename__ = "cluster_page_associations"
260 __table_args__ = (UniqueConstraint("page_id", "cluster_id"),)
262 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
264 page_id: Mapped[int] = mapped_column(ForeignKey("pages.id"), index=True)
265 cluster_id: Mapped[int] = mapped_column(ForeignKey("clusters.id"), index=True)
267 page: Mapped[Page] = relationship(init=False, backref="cluster_page_associations")
268 cluster: Mapped[Cluster] = relationship(init=False, backref="cluster_page_associations")
271class PageType(enum.Enum):
272 main_page = enum.auto()
273 place = enum.auto()
274 guide = enum.auto()
277class Page(Base, kw_only=True):
278 """
279 similar to a wiki page about a community, POI or guide
280 """
282 __tablename__ = "pages"
284 id: Mapped[int] = mapped_column(
285 BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value(), init=False
286 )
288 parent_node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"), index=True)
289 type: Mapped[PageType] = mapped_column(Enum(PageType))
290 creator_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
291 owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None, index=True)
292 owner_cluster_id: Mapped[int | None] = mapped_column(ForeignKey("clusters.id"), default=None, index=True)
294 thread_id: Mapped[int] = mapped_column(ForeignKey("threads.id"), unique=True)
296 parent_node: Mapped[Node] = relationship(
297 init=False, backref="child_pages", remote_side="Node.id", foreign_keys="Page.parent_node_id"
298 )
300 thread: Mapped[Thread] = relationship(init=False, backref="page", uselist=False)
301 creator_user: Mapped[User] = relationship(init=False, backref="created_pages", foreign_keys="Page.creator_user_id")
302 owner_user: Mapped[User | None] = relationship(init=False, backref="owned_pages", foreign_keys="Page.owner_user_id")
303 owner_cluster: Mapped[Cluster | None] = relationship(
304 init=False, back_populates="owned_pages", uselist=False, foreign_keys="Page.owner_cluster_id"
305 )
307 editors: Mapped[list[User]] = relationship(init=False, secondary="page_versions", viewonly=True)
308 versions: Mapped[list[PageVersion]] = relationship(init=False, back_populates="page", order_by="PageVersion.id")
310 __table_args__ = (
311 # Only one of owner_user and owner_cluster should be set
312 CheckConstraint(
313 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
314 name="one_owner",
315 ),
316 # Only clusters can own main pages
317 CheckConstraint(
318 "NOT (owner_cluster_id IS NULL AND type = 'main_page')",
319 name="main_page_owned_by_cluster",
320 ),
321 # Each cluster can have at most one main page
322 Index(
323 "ix_pages_owner_cluster_id_type",
324 owner_cluster_id,
325 type,
326 unique=True,
327 postgresql_where=(type == PageType.main_page),
328 ),
329 )
331 def __repr__(self) -> str:
332 return f"Page({self.id=})"
335class PageVersion(Base, kw_only=True):
336 """
337 version of page content
338 """
340 __tablename__ = "page_versions"
342 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
344 page_id: Mapped[int] = mapped_column(ForeignKey("pages.id"), index=True)
345 editor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
346 title: Mapped[str] = mapped_column(String)
347 content: Mapped[str] = mapped_column(String) # CommonMark without images
348 photo_key: Mapped[str | None] = mapped_column(ForeignKey("uploads.key"), default=None)
349 geom: Mapped[Geom | None] = mapped_column(Geometry(geometry_type="POINT", srid=4326), default=None)
350 # the human-readable address
351 address: Mapped[str | None] = mapped_column(String, default=None)
352 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
354 slug: Mapped[str] = column_property(func.slugify(title))
356 page: Mapped[Page] = relationship(init=False, back_populates="versions")
357 editor_user: Mapped[User] = relationship(init=False, backref="edited_pages")
358 photo: Mapped[Upload] = relationship(init=False)
360 __table_args__ = (
361 # Geom and address must either both be null or both be set
362 CheckConstraint(
363 "(geom IS NULL) = (address IS NULL)",
364 name="geom_iff_address",
365 ),
366 )
368 @property
369 def coordinates(self) -> tuple[float, float] | None:
370 # returns (lat, lng) or None
371 return get_coordinates(self.geom)
373 def __repr__(self) -> str:
374 return f"PageVersion({self.id=}, {self.page_id=})"