Coverage for slidge / db / models.py: 98%
207 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-20 19:56 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-20 19:56 +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 ..util.types import ClientType, Hat, MucType
13from .meta import Base, JSONSerializable, JSONSerializableTypes
16class ArchivedMessageSource(IntEnum):
17 """
18 Whether an archived message comes from ``LegacyMUC.backfill()`` or was received
19 as a "live" message.
20 """
22 LIVE = 1
23 BACKFILL = 2
26class GatewayUser(Base):
27 """
28 A user, registered to the gateway component.
29 """
31 __tablename__ = "user_account"
32 id: Mapped[int] = mapped_column(primary_key=True)
33 jid: Mapped[JID] = mapped_column(unique=True)
34 registration_date: Mapped[datetime] = mapped_column(
35 sa.DateTime, server_default=sa.func.now()
36 )
38 legacy_module_data: Mapped[JSONSerializable] = mapped_column(default={})
39 """
40 Arbitrary non-relational data that legacy modules can use
41 """
42 preferences: Mapped[JSONSerializable] = mapped_column(default={})
43 avatar_hash: Mapped[str | None] = mapped_column(default=None)
44 """
45 Hash of the user's avatar, to avoid re-publishing the same avatar on the
46 legacy network
47 """
49 contacts: Mapped[list["Contact"]] = relationship(
50 back_populates="user", cascade="all, delete-orphan"
51 )
52 rooms: Mapped[list["Room"]] = relationship(
53 back_populates="user", cascade="all, delete-orphan"
54 )
55 attachments: Mapped[list["Attachment"]] = relationship(cascade="all, delete-orphan")
56 spaces: Mapped[list["Space"]] = relationship(back_populates="user")
58 def __repr__(self) -> str:
59 return f"User(id={self.id!r}, jid={self.jid!r})"
61 def get(self, field: str, default: str = "") -> JSONSerializableTypes:
62 # """
63 # Get fields from the registration form (required to comply with slixmpp backend protocol)
64 #
65 # :param field: Name of the field
66 # :param default: Default value to return if the field is not present
67 #
68 # :return: Value of the field
69 # """
70 return self.legacy_module_data.get(field, default)
72 @property
73 def registration_form(self) -> dict[str, Any]:
74 # Kept for retrocompat, should be
75 # FIXME: delete me
76 warnings.warn(
77 "GatewayUser.registration_form is deprecated.", DeprecationWarning
78 )
79 return self.legacy_module_data
82class Avatar(Base):
83 """
84 Avatars of contacts, rooms and participants.
86 To comply with XEPs, we convert them all to PNG before storing them.
87 """
89 __tablename__ = "avatar"
91 id: Mapped[int] = mapped_column(primary_key=True)
93 hash: Mapped[str] = mapped_column(unique=True)
94 height: Mapped[int] = mapped_column()
95 width: Mapped[int] = mapped_column()
97 legacy_id: Mapped[str | None] = mapped_column(unique=True, nullable=True)
99 # this is only used when avatars are available as HTTP URLs and do not
100 # have a legacy_id
101 url: Mapped[str | None] = mapped_column(unique=True, default=None)
102 etag: Mapped[str | None] = mapped_column(default=None)
103 last_modified: Mapped[str | None] = mapped_column(default=None)
105 contacts: Mapped[list["Contact"]] = relationship(back_populates="avatar")
106 rooms: Mapped[list["Room"]] = relationship(back_populates="avatar")
109space_owner_association = Table(
110 "space_owner_association",
111 Base.metadata,
112 Column("space_id", ForeignKey("space.id"), primary_key=True),
113 Column("contact_id", ForeignKey("contact.id"), primary_key=True),
114)
117class Contact(Base):
118 """
119 Legacy contacts
120 """
122 __tablename__ = "contact"
123 __table_args__ = (
124 UniqueConstraint(
125 "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id"
126 ),
127 UniqueConstraint(
128 "user_account_id", "jid", name="uq_contact_user_account_id_jid"
129 ),
130 )
132 id: Mapped[int] = mapped_column(primary_key=True)
133 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
134 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="contacts")
135 legacy_id: Mapped[str] = mapped_column(nullable=False)
137 jid: Mapped[JID] = mapped_column()
139 avatar_id: Mapped[int | None] = mapped_column(
140 ForeignKey("avatar.id"), nullable=True
141 )
142 avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="contacts")
144 nick: Mapped[str | None] = mapped_column(nullable=True)
146 cached_presence: Mapped[bool] = mapped_column(default=False)
147 last_seen: Mapped[datetime | None] = mapped_column(nullable=True)
148 ptype: Mapped[str | None] = mapped_column(nullable=True)
149 pstatus: Mapped[str | None] = mapped_column(nullable=True)
150 pshow: Mapped[str | None] = mapped_column(nullable=True)
151 caps_ver: Mapped[str | None] = mapped_column(nullable=True)
153 is_friend: Mapped[bool] = mapped_column(default=False)
154 added_to_roster: Mapped[bool] = mapped_column(default=False)
155 sent_order: Mapped[list["ContactSent"]] = relationship(
156 back_populates="contact", cascade="all, delete-orphan"
157 )
159 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(
160 default=None, nullable=True
161 )
162 updated: Mapped[bool] = mapped_column(default=False)
164 vcard: Mapped[str | None] = mapped_column()
165 vcard_fetched: Mapped[bool] = mapped_column(default=False)
167 participants: Mapped[list["Participant"]] = relationship(back_populates="contact")
169 client_type: Mapped[ClientType] = mapped_column(nullable=False, default="pc")
171 messages: Mapped[list["DirectMessages"]] = relationship(
172 cascade="all, delete-orphan"
173 )
174 threads: Mapped[list["DirectThreads"]] = relationship(cascade="all, delete-orphan")
176 spaces_created: Mapped[list["Space"]] = relationship(
177 back_populates="creator",
178 cascade="all, delete-orphan",
179 )
180 spaces_owned: Mapped[list["Space"]] = relationship(
181 back_populates="owners",
182 secondary=space_owner_association,
183 )
186class ContactSent(Base):
187 """
188 Keep track of XMPP msg ids sent by a specific contact for networks in which
189 all messages need to be marked as read.
191 (XMPP displayed markers convey a "read up to here" semantic.)
192 """
194 __tablename__ = "contact_sent"
195 __table_args__ = (
196 UniqueConstraint(
197 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id"
198 ),
199 )
201 id: Mapped[int] = mapped_column(primary_key=True)
202 contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"))
203 contact: Mapped[Contact] = relationship(back_populates="sent_order")
204 msg_id: Mapped[str] = mapped_column()
207class Room(Base):
208 """
209 Legacy room
210 """
212 __table_args__ = (
213 UniqueConstraint(
214 "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id"
215 ),
216 UniqueConstraint("user_account_id", "jid", name="uq_room_user_account_id_jid"),
217 )
219 __tablename__ = "room"
220 id: Mapped[int] = mapped_column(primary_key=True)
221 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
222 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="rooms")
223 legacy_id: Mapped[str] = mapped_column(nullable=False)
225 jid: Mapped[JID] = mapped_column(nullable=False)
227 avatar_id: Mapped[int | None] = mapped_column(
228 ForeignKey("avatar.id"), nullable=True
229 )
230 avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="rooms")
232 name: Mapped[str | None] = mapped_column(nullable=True)
233 description: Mapped[str | None] = mapped_column(nullable=True)
234 subject: Mapped[str | None] = mapped_column(nullable=True)
235 subject_date: Mapped[datetime | None] = mapped_column(nullable=True)
236 subject_setter: Mapped[str | None] = mapped_column(nullable=True)
238 n_participants: Mapped[int | None] = mapped_column(default=None)
240 muc_type: Mapped[MucType] = mapped_column(default=MucType.CHANNEL)
242 user_nick: Mapped[str | None] = mapped_column()
243 user_resources: Mapped[str | None] = mapped_column(nullable=True)
245 participants_filled: Mapped[bool] = mapped_column(default=False)
246 history_filled: Mapped[bool] = mapped_column(default=False)
248 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
249 updated: Mapped[bool] = mapped_column(default=False)
251 participants: Mapped[list["Participant"]] = relationship(
252 back_populates="room",
253 primaryjoin="Participant.room_id == Room.id",
254 cascade="all, delete-orphan",
255 )
257 archive: Mapped[list["ArchivedMessage"]] = relationship(
258 cascade="all, delete-orphan"
259 )
261 messages: Mapped[list["GroupMessages"]] = relationship(cascade="all, delete-orphan")
262 threads: Mapped[list["GroupThreads"]] = relationship(cascade="all, delete-orphan")
264 space_id: Mapped[int | None] = mapped_column(ForeignKey("space.id"), nullable=True)
265 space: Mapped["Space"] = relationship(back_populates="rooms", lazy=False)
268class ArchivedMessage(Base):
269 """
270 Messages of rooms, that we store to act as a MAM server
271 """
273 __tablename__ = "mam"
274 __table_args__ = (
275 UniqueConstraint("room_id", "stanza_id", name="uq_mam_room_id_stanza_id"),
276 )
278 id: Mapped[int] = mapped_column(primary_key=True)
279 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
280 room: Mapped[Room] = relationship(lazy=True, back_populates="archive")
282 stanza_id: Mapped[str] = mapped_column(nullable=False)
283 timestamp: Mapped[datetime] = mapped_column(nullable=False)
284 author_jid: Mapped[JID] = mapped_column(nullable=False)
285 source: Mapped[ArchivedMessageSource] = mapped_column(nullable=False)
286 legacy_id: Mapped[str | None] = mapped_column(nullable=True)
288 stanza: Mapped[str] = mapped_column(nullable=False)
290 displayed_by_user: Mapped[bool] = mapped_column(default=False, nullable=True)
293class _LegacyToXmppIdsBase:
294 """
295 XMPP-client generated IDs, and mapping to the corresponding legacy IDs.
297 A single legacy ID can map to several XMPP ids.
298 """
300 id: Mapped[int] = mapped_column(primary_key=True)
301 legacy_id: Mapped[str] = mapped_column(nullable=False)
302 xmpp_id: Mapped[str] = mapped_column(nullable=False)
305class DirectMessages(_LegacyToXmppIdsBase, Base):
306 __tablename__ = "direct_msg"
307 __table_args__ = (Index("ix_direct_msg_legacy_id", "legacy_id", "foreign_key"),)
308 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
311class GroupMessages(_LegacyToXmppIdsBase, Base):
312 __tablename__ = "group_msg"
313 __table_args__ = (Index("ix_group_msg_legacy_id", "legacy_id", "foreign_key"),)
314 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
317class GroupMessagesOrigin(_LegacyToXmppIdsBase, Base):
318 """
319 This maps "origin ids" <message id=XXX> to legacy message IDs
320 We need that for message corrections and retractions, which do not reference
321 messages by their "Unique and Stable Stanza IDs (XEP-0359)"
322 """
324 __tablename__ = "group_msg_origin"
325 __table_args__ = (
326 Index("ix_group_msg_origin_legacy_id", "legacy_id", "foreign_key"),
327 )
328 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
331class DirectThreads(_LegacyToXmppIdsBase, Base):
332 __tablename__ = "direct_thread"
333 __table_args__ = (Index("ix_direct_direct_thread_id", "legacy_id", "foreign_key"),)
334 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
337class GroupThreads(_LegacyToXmppIdsBase, Base):
338 __tablename__ = "group_thread"
339 __table_args__ = (Index("ix_direct_group_thread_id", "legacy_id", "foreign_key"),)
340 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
343class Attachment(Base):
344 """
345 Legacy attachments
346 """
348 __tablename__ = "attachment"
349 __table_args__ = (
350 UniqueConstraint(
351 "user_account_id",
352 "legacy_file_id",
353 name="uq_attachment_user_account_id_legacy_file_id",
354 ),
355 )
357 id: Mapped[int] = mapped_column(primary_key=True)
358 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
359 user: Mapped[GatewayUser] = relationship(back_populates="attachments")
361 legacy_file_id: Mapped[str | None] = mapped_column(index=True, nullable=True)
362 url: Mapped[str] = mapped_column(index=True, nullable=False)
363 sims: Mapped[str | None] = mapped_column()
364 sfs: Mapped[str | None] = mapped_column()
367class Participant(Base):
368 __tablename__ = "participant"
369 __table_args__ = (
370 UniqueConstraint("room_id", "resource", name="uq_participant_room_id_resource"),
371 UniqueConstraint(
372 "room_id", "contact_id", name="uq_participant_room_id_contact_id"
373 ),
374 UniqueConstraint(
375 "room_id", "occupant_id", name="uq_participant_room_id_occupant_id"
376 ),
377 )
379 id: Mapped[int] = mapped_column(primary_key=True)
381 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
382 room: Mapped[Room] = relationship(
383 lazy=False, back_populates="participants", primaryjoin=Room.id == room_id
384 )
386 contact_id: Mapped[int | None] = mapped_column(
387 ForeignKey("contact.id"), nullable=True
388 )
389 contact: Mapped[Contact | None] = relationship(
390 lazy=False, back_populates="participants"
391 )
393 occupant_id: Mapped[str] = mapped_column(nullable=False)
395 is_user: Mapped[bool] = mapped_column(default=False)
397 affiliation: Mapped[MucAffiliation] = mapped_column(
398 default="member", nullable=False
399 )
400 role: Mapped[MucRole] = mapped_column(default="participant", nullable=False)
402 presence_sent: Mapped[bool] = mapped_column(default=False)
404 resource: Mapped[str] = mapped_column(nullable=False)
405 nickname: Mapped[str] = mapped_column(nullable=False, default=None)
406 nickname_no_illegal: Mapped[str] = mapped_column(nullable=False, default=None)
408 hats: Mapped[list[Hat]] = mapped_column(JSON, default=list)
410 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
412 def __init__(self, *args: object, **kwargs: object) -> None:
413 super().__init__(*args, **kwargs)
414 self.role = "participant"
415 self.affiliation = "member"
418class Bob(Base):
419 __tablename__ = "bob"
421 id: Mapped[int] = mapped_column(primary_key=True)
422 file_name: Mapped[str] = mapped_column(nullable=False)
424 sha_1: Mapped[str] = mapped_column(nullable=False, unique=True)
425 sha_256: Mapped[str] = mapped_column(nullable=False, unique=True)
426 sha_512: Mapped[str] = mapped_column(nullable=False, unique=True)
428 content_type: Mapped[str] = mapped_column(nullable=False)
431class Space(Base):
432 __tablename__ = "space"
433 __table_args__ = (
434 UniqueConstraint(
435 "user_account_id", "legacy_id", name="uq_space_user_account_id_legacy_id"
436 ),
437 )
439 id: Mapped[int] = mapped_column(primary_key=True)
441 updated: Mapped[bool] = mapped_column(default=False, nullable=False)
443 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
444 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="spaces")
446 legacy_id: Mapped[str] = mapped_column(nullable=False)
447 name: Mapped[str | None] = mapped_column(nullable=True)
448 description: Mapped[str | None] = mapped_column(nullable=True)
449 member_count: Mapped[int | None] = mapped_column(nullable=True)
451 creator_pk: Mapped[int | None] = mapped_column(
452 ForeignKey("contact.id"), nullable=True
453 )
454 creator: Mapped[Contact | None] = relationship(back_populates="spaces_created")
456 owners: Mapped[list[Contact]] = relationship(
457 back_populates="spaces_owned",
458 secondary=space_owner_association,
459 )
461 rooms: Mapped[list[Room]] = relationship(cascade="all, delete-orphan")