Coverage for slidge/db/models.py: 98%
213 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 18:29 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 18:29 +0000
1import warnings
2from datetime import datetime
3from enum import IntEnum
4from typing import Any
6import sqlalchemy as sa
7from slixmpp import JID
8from slixmpp.types import MucAffiliation, MucRole
9from sqlalchemy import JSON, Column, ForeignKey, Index, Table, UniqueConstraint
10from sqlalchemy.orm import Mapped, mapped_column, relationship
12from ..core import config
13from ..util.types import ClientType, Hat, MucType
14from .meta import Base, JSONSerializable, JSONSerializableTypes
17class ArchivedMessageSource(IntEnum):
18 """
19 Whether an archived message comes from ``LegacyMUC.backfill()`` or was received
20 as a "live" message.
21 """
23 LIVE = 1
24 BACKFILL = 2
27class _JidMixin:
28 jid_localpart: Mapped[str]
30 @property
31 def jid(self) -> JID:
32 return JID(f"{self.jid_localpart}@{config.JID}")
35class GatewayUser(Base):
36 """
37 A user, registered to the gateway component.
38 """
40 __tablename__ = "user_account"
41 id: Mapped[int] = mapped_column(primary_key=True)
42 jid: Mapped[JID] = mapped_column(unique=True)
43 registration_date: Mapped[datetime] = mapped_column(
44 sa.DateTime, server_default=sa.func.now()
45 )
47 legacy_module_data: Mapped[JSONSerializable] = mapped_column(default={})
48 """
49 Arbitrary non-relational data that legacy modules can use
50 """
51 preferences: Mapped[JSONSerializable] = mapped_column(default={})
52 avatar_hash: Mapped[str | None] = mapped_column(default=None)
53 """
54 Hash of the user's avatar, to avoid re-publishing the same avatar on the
55 legacy network
56 """
58 contacts: Mapped[list["Contact"]] = relationship(
59 back_populates="user", cascade="all, delete-orphan"
60 )
61 rooms: Mapped[list["Room"]] = relationship(
62 back_populates="user", cascade="all, delete-orphan"
63 )
64 attachments: Mapped[list["Attachment"]] = relationship(cascade="all, delete-orphan")
65 spaces: Mapped[list["Space"]] = relationship(
66 back_populates="user", cascade="all, delete-orphan"
67 )
69 def __repr__(self) -> str:
70 return f"User(id={self.id!r}, jid={self.jid!r})"
72 def get(self, field: str, default: str = "") -> JSONSerializableTypes:
73 # """
74 # Get fields from the registration form (required to comply with slixmpp backend protocol)
75 #
76 # :param field: Name of the field
77 # :param default: Default value to return if the field is not present
78 #
79 # :return: Value of the field
80 # """
81 return self.legacy_module_data.get(field, default)
83 @property
84 def registration_form(self) -> dict[str, Any]:
85 # Kept for retrocompat, should be
86 # FIXME: delete me
87 warnings.warn(
88 "GatewayUser.registration_form is deprecated.", DeprecationWarning
89 )
90 return self.legacy_module_data
93class Avatar(Base):
94 """
95 Avatars of contacts, rooms and participants.
97 To comply with XEPs, we convert them all to PNG before storing them.
98 """
100 __tablename__ = "avatar"
102 id: Mapped[int] = mapped_column(primary_key=True)
104 hash: Mapped[str] = mapped_column(unique=True)
105 height: Mapped[int] = mapped_column()
106 width: Mapped[int] = mapped_column()
108 legacy_id: Mapped[str | None] = mapped_column(unique=True, nullable=True)
110 # this is only used when avatars are available as HTTP URLs and do not
111 # have a legacy_id
112 url: Mapped[str | None] = mapped_column(unique=True, default=None)
113 etag: Mapped[str | None] = mapped_column(default=None)
114 last_modified: Mapped[str | None] = mapped_column(default=None)
116 contacts: Mapped[list["Contact"]] = relationship(back_populates="avatar")
117 rooms: Mapped[list["Room"]] = relationship(back_populates="avatar")
120space_owner_association = Table(
121 "space_owner_association",
122 Base.metadata,
123 Column("space_id", ForeignKey("space.id"), primary_key=True),
124 Column("contact_id", ForeignKey("contact.id"), primary_key=True),
125)
128class Contact(Base, _JidMixin):
129 """
130 Legacy contacts
131 """
133 __tablename__ = "contact"
134 __table_args__ = (
135 UniqueConstraint(
136 "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id"
137 ),
138 UniqueConstraint(
139 "user_account_id",
140 "jid_localpart",
141 name="uq_contact_user_account_id_jid_localpart",
142 ),
143 )
145 id: Mapped[int] = mapped_column(primary_key=True)
146 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
147 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="contacts")
148 legacy_id: Mapped[str] = mapped_column(nullable=False)
150 jid_localpart: Mapped[str] = mapped_column(nullable=False)
152 avatar_id: Mapped[int | None] = mapped_column(
153 ForeignKey("avatar.id"), nullable=True
154 )
155 avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="contacts")
157 nick: Mapped[str | None] = mapped_column(nullable=True)
159 cached_presence: Mapped[bool] = mapped_column(default=False)
160 last_seen: Mapped[datetime | None] = mapped_column(nullable=True)
161 ptype: Mapped[str | None] = mapped_column(nullable=True)
162 pstatus: Mapped[str | None] = mapped_column(nullable=True)
163 pshow: Mapped[str | None] = mapped_column(nullable=True)
164 caps_ver: Mapped[str | None] = mapped_column(nullable=True)
166 is_friend: Mapped[bool] = mapped_column(default=False)
167 added_to_roster: Mapped[bool] = mapped_column(default=False)
168 sent_order: Mapped[list["ContactSent"]] = relationship(
169 back_populates="contact", cascade="all, delete-orphan"
170 )
172 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(
173 default=None, nullable=True
174 )
175 updated: Mapped[bool] = mapped_column(default=False)
177 vcard: Mapped[str | None] = mapped_column()
178 vcard_fetched: Mapped[bool] = mapped_column(default=False)
180 participants: Mapped[list["Participant"]] = relationship(back_populates="contact")
182 client_type: Mapped[ClientType] = mapped_column(nullable=False, default="pc")
184 messages: Mapped[list["DirectMessages"]] = relationship(
185 cascade="all, delete-orphan"
186 )
187 threads: Mapped[list["DirectThreads"]] = relationship(cascade="all, delete-orphan")
189 spaces_created: Mapped[list["Space"]] = relationship(
190 back_populates="creator",
191 cascade="all, delete-orphan",
192 )
193 spaces_owned: Mapped[list["Space"]] = relationship(
194 back_populates="owners",
195 secondary=space_owner_association,
196 )
199class ContactSent(Base):
200 """
201 Keep track of XMPP msg ids sent by a specific contact for networks in which
202 all messages need to be marked as read.
204 (XMPP displayed markers convey a "read up to here" semantic.)
205 """
207 __tablename__ = "contact_sent"
208 __table_args__ = (
209 UniqueConstraint(
210 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id"
211 ),
212 )
214 id: Mapped[int] = mapped_column(primary_key=True)
215 contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"))
216 contact: Mapped[Contact] = relationship(back_populates="sent_order")
217 msg_id: Mapped[str] = mapped_column()
220class Room(Base, _JidMixin):
221 """
222 Legacy room
223 """
225 __table_args__ = (
226 UniqueConstraint(
227 "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id"
228 ),
229 UniqueConstraint(
230 "user_account_id",
231 "jid_localpart",
232 name="uq_room_user_account_id_jid_localpart",
233 ),
234 )
236 __tablename__ = "room"
237 id: Mapped[int] = mapped_column(primary_key=True)
238 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
239 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="rooms")
240 legacy_id: Mapped[str] = mapped_column(nullable=False)
242 jid_localpart: Mapped[str] = mapped_column(nullable=False)
244 avatar_id: Mapped[int | None] = mapped_column(
245 ForeignKey("avatar.id"), nullable=True
246 )
247 avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="rooms")
249 name: Mapped[str | None] = mapped_column(nullable=True)
250 description: Mapped[str | None] = mapped_column(nullable=True)
251 subject: Mapped[str | None] = mapped_column(nullable=True)
252 subject_date: Mapped[datetime | None] = mapped_column(nullable=True)
253 subject_setter: Mapped[str | None] = mapped_column(nullable=True)
255 n_participants: Mapped[int | None] = mapped_column(default=None)
257 muc_type: Mapped[MucType] = mapped_column(default=MucType.CHANNEL)
259 user_nick: Mapped[str | None] = mapped_column()
260 user_resources: Mapped[str | None] = mapped_column(nullable=True)
262 participants_filled: Mapped[bool] = mapped_column(default=False)
263 history_filled: Mapped[bool] = mapped_column(default=False)
265 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
266 updated: Mapped[bool] = mapped_column(default=False)
268 participants: Mapped[list["Participant"]] = relationship(
269 back_populates="room",
270 primaryjoin="Participant.room_id == Room.id",
271 cascade="all, delete-orphan",
272 )
274 archive: Mapped[list["ArchivedMessage"]] = relationship(
275 cascade="all, delete-orphan"
276 )
278 messages: Mapped[list["GroupMessages"]] = relationship(cascade="all, delete-orphan")
279 threads: Mapped[list["GroupThreads"]] = relationship(cascade="all, delete-orphan")
281 space_id: Mapped[int | None] = mapped_column(ForeignKey("space.id"), nullable=True)
282 space: Mapped["Space"] = relationship(back_populates="rooms", lazy=False)
285class ArchivedMessage(Base):
286 """
287 Messages of rooms, that we store to act as a MAM server
288 """
290 __tablename__ = "mam"
291 __table_args__ = (
292 UniqueConstraint("room_id", "stanza_id", name="uq_mam_room_id_stanza_id"),
293 )
295 id: Mapped[int] = mapped_column(primary_key=True)
296 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
297 room: Mapped[Room] = relationship(lazy=True, back_populates="archive")
299 stanza_id: Mapped[str] = mapped_column(nullable=False)
300 timestamp: Mapped[datetime] = mapped_column(nullable=False)
301 author_jid: Mapped[JID] = mapped_column(nullable=False)
302 source: Mapped[ArchivedMessageSource] = mapped_column(nullable=False)
303 legacy_id: Mapped[str | None] = mapped_column(nullable=True)
305 stanza: Mapped[str] = mapped_column(nullable=False)
307 displayed_by_user: Mapped[bool] = mapped_column(default=False, nullable=True)
310class _LegacyToXmppIdsBase:
311 """
312 XMPP-client generated IDs, and mapping to the corresponding legacy IDs.
314 A single legacy ID can map to several XMPP ids.
315 """
317 id: Mapped[int] = mapped_column(primary_key=True)
318 legacy_id: Mapped[str] = mapped_column(nullable=False)
319 xmpp_id: Mapped[str] = mapped_column(nullable=False)
322class DirectMessages(_LegacyToXmppIdsBase, Base):
323 __tablename__ = "direct_msg"
324 __table_args__ = (Index("ix_direct_msg_legacy_id", "legacy_id", "foreign_key"),)
325 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
328class GroupMessages(_LegacyToXmppIdsBase, Base):
329 __tablename__ = "group_msg"
330 __table_args__ = (Index("ix_group_msg_legacy_id", "legacy_id", "foreign_key"),)
331 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
334class GroupMessagesOrigin(_LegacyToXmppIdsBase, Base):
335 """
336 This maps "origin ids" <message id=XXX> to legacy message IDs
337 We need that for message corrections and retractions, which do not reference
338 messages by their "Unique and Stable Stanza IDs (XEP-0359)"
339 """
341 __tablename__ = "group_msg_origin"
342 __table_args__ = (
343 Index("ix_group_msg_origin_legacy_id", "legacy_id", "foreign_key"),
344 )
345 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
348class DirectThreads(_LegacyToXmppIdsBase, Base):
349 __tablename__ = "direct_thread"
350 __table_args__ = (Index("ix_direct_direct_thread_id", "legacy_id", "foreign_key"),)
351 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
354class GroupThreads(_LegacyToXmppIdsBase, Base):
355 __tablename__ = "group_thread"
356 __table_args__ = (Index("ix_direct_group_thread_id", "legacy_id", "foreign_key"),)
357 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
360class Attachment(Base):
361 """
362 Legacy attachments
363 """
365 __tablename__ = "attachment"
366 __table_args__ = (
367 UniqueConstraint(
368 "user_account_id",
369 "legacy_file_id",
370 name="uq_attachment_user_account_id_legacy_file_id",
371 ),
372 )
374 id: Mapped[int] = mapped_column(primary_key=True)
375 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
376 user: Mapped[GatewayUser] = relationship(back_populates="attachments")
378 legacy_file_id: Mapped[str | None] = mapped_column(index=True, nullable=True)
379 url: Mapped[str] = mapped_column(index=True, nullable=False)
380 sims: Mapped[str | None] = mapped_column()
381 sfs: Mapped[str | None] = mapped_column()
384class Participant(Base):
385 __tablename__ = "participant"
386 __table_args__ = (
387 UniqueConstraint("room_id", "resource", name="uq_participant_room_id_resource"),
388 UniqueConstraint(
389 "room_id", "contact_id", name="uq_participant_room_id_contact_id"
390 ),
391 UniqueConstraint(
392 "room_id", "occupant_id", name="uq_participant_room_id_occupant_id"
393 ),
394 )
396 id: Mapped[int] = mapped_column(primary_key=True)
398 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
399 room: Mapped[Room] = relationship(
400 lazy=False, back_populates="participants", primaryjoin=Room.id == room_id
401 )
403 contact_id: Mapped[int | None] = mapped_column(
404 ForeignKey("contact.id"), nullable=True
405 )
406 contact: Mapped[Contact | None] = relationship(
407 lazy=False, back_populates="participants"
408 )
410 occupant_id: Mapped[str] = mapped_column(nullable=False)
412 is_user: Mapped[bool] = mapped_column(default=False)
414 affiliation: Mapped[MucAffiliation] = mapped_column(
415 default="member", nullable=False
416 )
417 role: Mapped[MucRole] = mapped_column(default="participant", nullable=False)
419 presence_sent: Mapped[bool] = mapped_column(default=False)
421 resource: Mapped[str] = mapped_column(nullable=False)
422 nickname: Mapped[str] = mapped_column(nullable=False, default=None)
423 nickname_no_illegal: Mapped[str] = mapped_column(nullable=False, default=None)
425 hats: Mapped[list[Hat]] = mapped_column(JSON, default=list)
427 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
429 def __init__(self, *args: object, **kwargs: object) -> None:
430 super().__init__(*args, **kwargs)
431 self.role = "participant"
432 self.affiliation = "member"
435class Bob(Base):
436 __tablename__ = "bob"
438 id: Mapped[int] = mapped_column(primary_key=True)
439 file_name: Mapped[str] = mapped_column(nullable=False)
441 sha_1: Mapped[str] = mapped_column(nullable=False, unique=True)
442 sha_256: Mapped[str] = mapped_column(nullable=False, unique=True)
443 sha_512: Mapped[str] = mapped_column(nullable=False, unique=True)
445 content_type: Mapped[str] = mapped_column(nullable=False)
448class Space(Base):
449 __tablename__ = "space"
450 __table_args__ = (
451 UniqueConstraint(
452 "user_account_id", "legacy_id", name="uq_space_user_account_id_legacy_id"
453 ),
454 )
456 id: Mapped[int] = mapped_column(primary_key=True)
458 updated: Mapped[bool] = mapped_column(default=False, nullable=False)
460 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
461 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="spaces")
463 legacy_id: Mapped[str] = mapped_column(nullable=False)
464 name: Mapped[str | None] = mapped_column(nullable=True)
465 description: Mapped[str | None] = mapped_column(nullable=True)
466 member_count: Mapped[int | None] = mapped_column(nullable=True)
468 creator_pk: Mapped[int | None] = mapped_column(
469 ForeignKey("contact.id"), nullable=True
470 )
471 creator: Mapped[Contact | None] = relationship(back_populates="spaces_created")
473 owners: Mapped[list[Contact]] = relationship(
474 back_populates="spaces_owned",
475 secondary=space_owner_association,
476 )
478 rooms: Mapped[list[Room]] = relationship(cascade="all, delete-orphan")