Coverage for src/couchers/models/clusters.py: 98%
112 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
1import enum
3from geoalchemy2 import Geometry
4from sqlalchemy import (
5 BigInteger,
6 Boolean,
7 CheckConstraint,
8 Column,
9 DateTime,
10 Enum,
11 ForeignKey,
12 Index,
13 String,
14 UniqueConstraint,
15 func,
16 text,
17)
18from sqlalchemy.ext.associationproxy import association_proxy
19from sqlalchemy.orm import backref, column_property, deferred, relationship
20from sqlalchemy.sql import expression
22from couchers.models.base import Base, communities_seq
23from couchers.utils import get_coordinates
26class Node(Base):
27 """
28 Node, i.e., geographical subdivision of the world
30 Administered by the official cluster
31 """
33 __tablename__ = "nodes"
35 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
37 # name and description come from official cluster
38 parent_node_id = Column(ForeignKey("nodes.id"), nullable=True, index=True)
39 geom = deferred(Column(Geometry(geometry_type="MULTIPOLYGON", srid=4326), nullable=False))
40 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
42 parent_node = relationship("Node", backref="child_nodes", remote_side="Node.id")
44 contained_users = relationship(
45 "User",
46 primaryjoin="func.ST_Contains(foreign(Node.geom), User.geom).as_comparison(1, 2)",
47 viewonly=True,
48 uselist=True,
49 )
51 contained_user_ids = association_proxy("contained_users", "id")
54class Cluster(Base):
55 """
56 Cluster, administered grouping of content
57 """
59 __tablename__ = "clusters"
61 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
62 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True)
63 name = Column(String, nullable=False)
64 # short description
65 description = Column(String, nullable=False)
66 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
68 is_official_cluster = Column(Boolean, nullable=False, default=False)
70 discussions_enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true())
71 events_enabled = Column(Boolean, nullable=False, default=True, server_default=expression.true())
73 slug = column_property(func.slugify(name))
75 official_cluster_for_node = relationship(
76 "Node",
77 primaryjoin="and_(Cluster.parent_node_id == Node.id, Cluster.is_official_cluster)",
78 backref=backref("official_cluster", uselist=False),
79 uselist=False,
80 viewonly=True,
81 )
83 parent_node = relationship(
84 "Node", backref="child_clusters", remote_side="Node.id", foreign_keys="Cluster.parent_node_id"
85 )
87 nodes = relationship("Cluster", backref="clusters", secondary="node_cluster_associations", viewonly=True)
88 # all pages
89 pages = relationship(
90 "Page", backref="clusters", secondary="cluster_page_associations", lazy="dynamic", viewonly=True
91 )
92 events = relationship("Event", backref="clusters", secondary="cluster_event_associations", viewonly=True)
93 discussions = relationship(
94 "Discussion", backref="clusters", secondary="cluster_discussion_associations", viewonly=True
95 )
97 # includes also admins
98 members = relationship(
99 "User",
100 lazy="dynamic",
101 backref="cluster_memberships",
102 secondary="cluster_subscriptions",
103 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
104 secondaryjoin="User.id == ClusterSubscription.user_id",
105 viewonly=True,
106 )
108 admins = relationship(
109 "User",
110 lazy="dynamic",
111 backref="cluster_adminships",
112 secondary="cluster_subscriptions",
113 primaryjoin="Cluster.id == ClusterSubscription.cluster_id",
114 secondaryjoin="and_(User.id == ClusterSubscription.user_id, ClusterSubscription.role == 'admin')",
115 viewonly=True,
116 )
118 main_page = relationship(
119 "Page",
120 primaryjoin="and_(Cluster.id == Page.owner_cluster_id, Page.type == 'main_page')",
121 viewonly=True,
122 uselist=False,
123 )
125 @property
126 def is_leaf(self) -> bool:
127 """Whether the cluster is a leaf node in the cluster hierarchy."""
128 return len(self.parent_node.child_nodes) == 0
130 __table_args__ = (
131 # Each node can have at most one official cluster
132 Index(
133 "ix_clusters_owner_parent_node_id_is_official_cluster",
134 parent_node_id,
135 is_official_cluster,
136 unique=True,
137 postgresql_where=is_official_cluster,
138 ),
139 # trigram index on unaccented name
140 # note that the function `unaccent` is not immutable so cannot be used in an index, that's why we wrap it
141 Index(
142 "idx_clusters_name_unaccented_trgm",
143 text("immutable_unaccent(name) gin_trgm_ops"),
144 postgresql_using="gin",
145 ),
146 )
149class NodeClusterAssociation(Base):
150 """
151 NodeClusterAssociation, grouping of nodes
152 """
154 __tablename__ = "node_cluster_associations"
155 __table_args__ = (UniqueConstraint("node_id", "cluster_id"),)
157 id = Column(BigInteger, primary_key=True)
159 node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True)
160 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
162 node = relationship("Node", backref="node_cluster_associations")
163 cluster = relationship("Cluster", backref="node_cluster_associations")
166class ClusterRole(enum.Enum):
167 member = enum.auto()
168 admin = enum.auto()
171class ClusterSubscription(Base):
172 """
173 ClusterSubscription of a user
174 """
176 __tablename__ = "cluster_subscriptions"
178 id = Column(BigInteger, primary_key=True)
180 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
181 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
182 role = Column(Enum(ClusterRole), nullable=False)
184 user = relationship("User", backref="cluster_subscriptions")
185 cluster = relationship("Cluster", backref="cluster_subscriptions")
187 __table_args__ = (
188 UniqueConstraint("user_id", "cluster_id"),
189 Index(
190 "ix_cluster_subscriptions_members",
191 cluster_id,
192 user_id,
193 ),
194 # For fast lookup of nodes this user is an admin of
195 Index(
196 "ix_cluster_subscriptions_admins",
197 user_id,
198 cluster_id,
199 postgresql_where=(role == ClusterRole.admin),
200 ),
201 )
204class ClusterPageAssociation(Base):
205 """
206 pages related to clusters
207 """
209 __tablename__ = "cluster_page_associations"
210 __table_args__ = (UniqueConstraint("page_id", "cluster_id"),)
212 id = Column(BigInteger, primary_key=True)
214 page_id = Column(ForeignKey("pages.id"), nullable=False, index=True)
215 cluster_id = Column(ForeignKey("clusters.id"), nullable=False, index=True)
217 page = relationship("Page", backref="cluster_page_associations")
218 cluster = relationship("Cluster", backref="cluster_page_associations")
221class PageType(enum.Enum):
222 main_page = enum.auto()
223 place = enum.auto()
224 guide = enum.auto()
227class Page(Base):
228 """
229 similar to a wiki page about a community, POI or guide
230 """
232 __tablename__ = "pages"
234 id = Column(BigInteger, communities_seq, primary_key=True, server_default=communities_seq.next_value())
236 parent_node_id = Column(ForeignKey("nodes.id"), nullable=False, index=True)
237 type = Column(Enum(PageType), nullable=False)
238 creator_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
239 owner_user_id = Column(ForeignKey("users.id"), nullable=True, index=True)
240 owner_cluster_id = Column(ForeignKey("clusters.id"), nullable=True, index=True)
242 thread_id = Column(ForeignKey("threads.id"), nullable=False, unique=True)
244 parent_node = relationship("Node", backref="child_pages", remote_side="Node.id", foreign_keys="Page.parent_node_id")
246 thread = relationship("Thread", backref="page", uselist=False)
247 creator_user = relationship("User", backref="created_pages", foreign_keys="Page.creator_user_id")
248 owner_user = relationship("User", backref="owned_pages", foreign_keys="Page.owner_user_id")
249 owner_cluster = relationship(
250 "Cluster", backref=backref("owned_pages", lazy="dynamic"), uselist=False, foreign_keys="Page.owner_cluster_id"
251 )
253 editors = relationship("User", secondary="page_versions", viewonly=True)
255 __table_args__ = (
256 # Only one of owner_user and owner_cluster should be set
257 CheckConstraint(
258 "(owner_user_id IS NULL) <> (owner_cluster_id IS NULL)",
259 name="one_owner",
260 ),
261 # Only clusters can own main pages
262 CheckConstraint(
263 "NOT (owner_cluster_id IS NULL AND type = 'main_page')",
264 name="main_page_owned_by_cluster",
265 ),
266 # Each cluster can have at most one main page
267 Index(
268 "ix_pages_owner_cluster_id_type",
269 owner_cluster_id,
270 type,
271 unique=True,
272 postgresql_where=(type == PageType.main_page),
273 ),
274 )
276 def __repr__(self):
277 return f"Page({self.id=})"
280class PageVersion(Base):
281 """
282 version of page content
283 """
285 __tablename__ = "page_versions"
287 id = Column(BigInteger, primary_key=True)
289 page_id = Column(ForeignKey("pages.id"), nullable=False, index=True)
290 editor_user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
291 title = Column(String, nullable=False)
292 content = Column(String, nullable=False) # CommonMark without images
293 photo_key = Column(ForeignKey("uploads.key"), nullable=True)
294 geom = Column(Geometry(geometry_type="POINT", srid=4326), nullable=True)
295 # the human-readable address
296 address = Column(String, nullable=True)
297 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
299 slug = column_property(func.slugify(title))
301 page = relationship("Page", backref="versions", order_by="PageVersion.id")
302 editor_user = relationship("User", backref="edited_pages")
303 photo = relationship("Upload")
305 __table_args__ = (
306 # Geom and address must either both be null or both be set
307 CheckConstraint(
308 "(geom IS NULL) = (address IS NULL)",
309 name="geom_iff_address",
310 ),
311 )
313 @property
314 def coordinates(self):
315 # returns (lat, lng) or None
316 return get_coordinates(self.geom)
318 def __repr__(self):
319 return f"PageVersion({self.id=}, {self.page_id=})"