Coverage for slidge / db / models.py: 98%
207 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-13 04:38 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-13 04:38 +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(
57 back_populates="user", cascade="all, delete-orphan"
58 )
60 def __repr__(self) -> str:
61 return f"User(id={self.id!r}, jid={self.jid!r})"
63 def get(self, field: str, default: str = "") -> JSONSerializableTypes:
64 # """
65 # Get fields from the registration form (required to comply with slixmpp backend protocol)
66 #
67 # :param field: Name of the field
68 # :param default: Default value to return if the field is not present
69 #
70 # :return: Value of the field
71 # """
72 return self.legacy_module_data.get(field, default)
74 @property
75 def registration_form(self) -> dict[str, Any]:
76 # Kept for retrocompat, should be
77 # FIXME: delete me
78 warnings.warn(
79 "GatewayUser.registration_form is deprecated.", DeprecationWarning
80 )
81 return self.legacy_module_data
84class Avatar(Base):
85 """
86 Avatars of contacts, rooms and participants.
88 To comply with XEPs, we convert them all to PNG before storing them.
89 """
91 __tablename__ = "avatar"
93 id: Mapped[int] = mapped_column(primary_key=True)
95 hash: Mapped[str] = mapped_column(unique=True)
96 height: Mapped[int] = mapped_column()
97 width: Mapped[int] = mapped_column()
99 legacy_id: Mapped[str | None] = mapped_column(unique=True, nullable=True)
101 # this is only used when avatars are available as HTTP URLs and do not
102 # have a legacy_id
103 url: Mapped[str | None] = mapped_column(unique=True, default=None)
104 etag: Mapped[str | None] = mapped_column(default=None)
105 last_modified: Mapped[str | None] = mapped_column(default=None)
107 contacts: Mapped[list["Contact"]] = relationship(back_populates="avatar")
108 rooms: Mapped[list["Room"]] = relationship(back_populates="avatar")
111space_owner_association = Table(
112 "space_owner_association",
113 Base.metadata,
114 Column("space_id", ForeignKey("space.id"), primary_key=True),
115 Column("contact_id", ForeignKey("contact.id"), primary_key=True),
116)
119class Contact(Base):
120 """
121 Legacy contacts
122 """
124 __tablename__ = "contact"
125 __table_args__ = (
126 UniqueConstraint(
127 "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id"
128 ),
129 UniqueConstraint(
130 "user_account_id", "jid", name="uq_contact_user_account_id_jid"
131 ),
132 )
134 id: Mapped[int] = mapped_column(primary_key=True)
135 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
136 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="contacts")
137 legacy_id: Mapped[str] = mapped_column(nullable=False)
139 jid: Mapped[JID] = mapped_column()
141 avatar_id: Mapped[int | None] = mapped_column(
142 ForeignKey("avatar.id"), nullable=True
143 )
144 avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="contacts")
146 nick: Mapped[str | None] = mapped_column(nullable=True)
148 cached_presence: Mapped[bool] = mapped_column(default=False)
149 last_seen: Mapped[datetime | None] = mapped_column(nullable=True)
150 ptype: Mapped[str | None] = mapped_column(nullable=True)
151 pstatus: Mapped[str | None] = mapped_column(nullable=True)
152 pshow: Mapped[str | None] = mapped_column(nullable=True)
153 caps_ver: Mapped[str | None] = mapped_column(nullable=True)
155 is_friend: Mapped[bool] = mapped_column(default=False)
156 added_to_roster: Mapped[bool] = mapped_column(default=False)
157 sent_order: Mapped[list["ContactSent"]] = relationship(
158 back_populates="contact", cascade="all, delete-orphan"
159 )
161 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(
162 default=None, nullable=True
163 )
164 updated: Mapped[bool] = mapped_column(default=False)
166 vcard: Mapped[str | None] = mapped_column()
167 vcard_fetched: Mapped[bool] = mapped_column(default=False)
169 participants: Mapped[list["Participant"]] = relationship(back_populates="contact")
171 client_type: Mapped[ClientType] = mapped_column(nullable=False, default="pc")
173 messages: Mapped[list["DirectMessages"]] = relationship(
174 cascade="all, delete-orphan"
175 )
176 threads: Mapped[list["DirectThreads"]] = relationship(cascade="all, delete-orphan")
178 spaces_created: Mapped[list["Space"]] = relationship(
179 back_populates="creator",
180 cascade="all, delete-orphan",
181 )
182 spaces_owned: Mapped[list["Space"]] = relationship(
183 back_populates="owners",
184 secondary=space_owner_association,
185 )
188class ContactSent(Base):
189 """
190 Keep track of XMPP msg ids sent by a specific contact for networks in which
191 all messages need to be marked as read.
193 (XMPP displayed markers convey a "read up to here" semantic.)
194 """
196 __tablename__ = "contact_sent"
197 __table_args__ = (
198 UniqueConstraint(
199 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id"
200 ),
201 )
203 id: Mapped[int] = mapped_column(primary_key=True)
204 contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"))
205 contact: Mapped[Contact] = relationship(back_populates="sent_order")
206 msg_id: Mapped[str] = mapped_column()
209class Room(Base):
210 """
211 Legacy room
212 """
214 __table_args__ = (
215 UniqueConstraint(
216 "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id"
217 ),
218 UniqueConstraint("user_account_id", "jid", name="uq_room_user_account_id_jid"),
219 )
221 __tablename__ = "room"
222 id: Mapped[int] = mapped_column(primary_key=True)
223 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
224 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="rooms")
225 legacy_id: Mapped[str] = mapped_column(nullable=False)
227 jid: Mapped[JID] = mapped_column(nullable=False)
229 avatar_id: Mapped[int | None] = mapped_column(
230 ForeignKey("avatar.id"), nullable=True
231 )
232 avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="rooms")
234 name: Mapped[str | None] = mapped_column(nullable=True)
235 description: Mapped[str | None] = mapped_column(nullable=True)
236 subject: Mapped[str | None] = mapped_column(nullable=True)
237 subject_date: Mapped[datetime | None] = mapped_column(nullable=True)
238 subject_setter: Mapped[str | None] = mapped_column(nullable=True)
240 n_participants: Mapped[int | None] = mapped_column(default=None)
242 muc_type: Mapped[MucType] = mapped_column(default=MucType.CHANNEL)
244 user_nick: Mapped[str | None] = mapped_column()
245 user_resources: Mapped[str | None] = mapped_column(nullable=True)
247 participants_filled: Mapped[bool] = mapped_column(default=False)
248 history_filled: Mapped[bool] = mapped_column(default=False)
250 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
251 updated: Mapped[bool] = mapped_column(default=False)
253 participants: Mapped[list["Participant"]] = relationship(
254 back_populates="room",
255 primaryjoin="Participant.room_id == Room.id",
256 cascade="all, delete-orphan",
257 )
259 archive: Mapped[list["ArchivedMessage"]] = relationship(
260 cascade="all, delete-orphan"
261 )
263 messages: Mapped[list["GroupMessages"]] = relationship(cascade="all, delete-orphan")
264 threads: Mapped[list["GroupThreads"]] = relationship(cascade="all, delete-orphan")
266 space_id: Mapped[int | None] = mapped_column(ForeignKey("space.id"), nullable=True)
267 space: Mapped["Space"] = relationship(back_populates="rooms", lazy=False)
270class ArchivedMessage(Base):
271 """
272 Messages of rooms, that we store to act as a MAM server
273 """
275 __tablename__ = "mam"
276 __table_args__ = (
277 UniqueConstraint("room_id", "stanza_id", name="uq_mam_room_id_stanza_id"),
278 )
280 id: Mapped[int] = mapped_column(primary_key=True)
281 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
282 room: Mapped[Room] = relationship(lazy=True, back_populates="archive")
284 stanza_id: Mapped[str] = mapped_column(nullable=False)
285 timestamp: Mapped[datetime] = mapped_column(nullable=False)
286 author_jid: Mapped[JID] = mapped_column(nullable=False)
287 source: Mapped[ArchivedMessageSource] = mapped_column(nullable=False)
288 legacy_id: Mapped[str | None] = mapped_column(nullable=True)
290 stanza: Mapped[str] = mapped_column(nullable=False)
292 displayed_by_user: Mapped[bool] = mapped_column(default=False, nullable=True)
295class _LegacyToXmppIdsBase:
296 """
297 XMPP-client generated IDs, and mapping to the corresponding legacy IDs.
299 A single legacy ID can map to several XMPP ids.
300 """
302 id: Mapped[int] = mapped_column(primary_key=True)
303 legacy_id: Mapped[str] = mapped_column(nullable=False)
304 xmpp_id: Mapped[str] = mapped_column(nullable=False)
307class DirectMessages(_LegacyToXmppIdsBase, Base):
308 __tablename__ = "direct_msg"
309 __table_args__ = (Index("ix_direct_msg_legacy_id", "legacy_id", "foreign_key"),)
310 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
313class GroupMessages(_LegacyToXmppIdsBase, Base):
314 __tablename__ = "group_msg"
315 __table_args__ = (Index("ix_group_msg_legacy_id", "legacy_id", "foreign_key"),)
316 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
319class GroupMessagesOrigin(_LegacyToXmppIdsBase, Base):
320 """
321 This maps "origin ids" <message id=XXX> to legacy message IDs
322 We need that for message corrections and retractions, which do not reference
323 messages by their "Unique and Stable Stanza IDs (XEP-0359)"
324 """
326 __tablename__ = "group_msg_origin"
327 __table_args__ = (
328 Index("ix_group_msg_origin_legacy_id", "legacy_id", "foreign_key"),
329 )
330 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
333class DirectThreads(_LegacyToXmppIdsBase, Base):
334 __tablename__ = "direct_thread"
335 __table_args__ = (Index("ix_direct_direct_thread_id", "legacy_id", "foreign_key"),)
336 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
339class GroupThreads(_LegacyToXmppIdsBase, Base):
340 __tablename__ = "group_thread"
341 __table_args__ = (Index("ix_direct_group_thread_id", "legacy_id", "foreign_key"),)
342 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
345class Attachment(Base):
346 """
347 Legacy attachments
348 """
350 __tablename__ = "attachment"
351 __table_args__ = (
352 UniqueConstraint(
353 "user_account_id",
354 "legacy_file_id",
355 name="uq_attachment_user_account_id_legacy_file_id",
356 ),
357 )
359 id: Mapped[int] = mapped_column(primary_key=True)
360 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
361 user: Mapped[GatewayUser] = relationship(back_populates="attachments")
363 legacy_file_id: Mapped[str | None] = mapped_column(index=True, nullable=True)
364 url: Mapped[str] = mapped_column(index=True, nullable=False)
365 sims: Mapped[str | None] = mapped_column()
366 sfs: Mapped[str | None] = mapped_column()
369class Participant(Base):
370 __tablename__ = "participant"
371 __table_args__ = (
372 UniqueConstraint("room_id", "resource", name="uq_participant_room_id_resource"),
373 UniqueConstraint(
374 "room_id", "contact_id", name="uq_participant_room_id_contact_id"
375 ),
376 UniqueConstraint(
377 "room_id", "occupant_id", name="uq_participant_room_id_occupant_id"
378 ),
379 )
381 id: Mapped[int] = mapped_column(primary_key=True)
383 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
384 room: Mapped[Room] = relationship(
385 lazy=False, back_populates="participants", primaryjoin=Room.id == room_id
386 )
388 contact_id: Mapped[int | None] = mapped_column(
389 ForeignKey("contact.id"), nullable=True
390 )
391 contact: Mapped[Contact | None] = relationship(
392 lazy=False, back_populates="participants"
393 )
395 occupant_id: Mapped[str] = mapped_column(nullable=False)
397 is_user: Mapped[bool] = mapped_column(default=False)
399 affiliation: Mapped[MucAffiliation] = mapped_column(
400 default="member", nullable=False
401 )
402 role: Mapped[MucRole] = mapped_column(default="participant", nullable=False)
404 presence_sent: Mapped[bool] = mapped_column(default=False)
406 resource: Mapped[str] = mapped_column(nullable=False)
407 nickname: Mapped[str] = mapped_column(nullable=False, default=None)
408 nickname_no_illegal: Mapped[str] = mapped_column(nullable=False, default=None)
410 hats: Mapped[list[Hat]] = mapped_column(JSON, default=list)
412 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
414 def __init__(self, *args: object, **kwargs: object) -> None:
415 super().__init__(*args, **kwargs)
416 self.role = "participant"
417 self.affiliation = "member"
420class Bob(Base):
421 __tablename__ = "bob"
423 id: Mapped[int] = mapped_column(primary_key=True)
424 file_name: Mapped[str] = mapped_column(nullable=False)
426 sha_1: Mapped[str] = mapped_column(nullable=False, unique=True)
427 sha_256: Mapped[str] = mapped_column(nullable=False, unique=True)
428 sha_512: Mapped[str] = mapped_column(nullable=False, unique=True)
430 content_type: Mapped[str] = mapped_column(nullable=False)
433class Space(Base):
434 __tablename__ = "space"
435 __table_args__ = (
436 UniqueConstraint(
437 "user_account_id", "legacy_id", name="uq_space_user_account_id_legacy_id"
438 ),
439 )
441 id: Mapped[int] = mapped_column(primary_key=True)
443 updated: Mapped[bool] = mapped_column(default=False, nullable=False)
445 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
446 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="spaces")
448 legacy_id: Mapped[str] = mapped_column(nullable=False)
449 name: Mapped[str | None] = mapped_column(nullable=True)
450 description: Mapped[str | None] = mapped_column(nullable=True)
451 member_count: Mapped[int | None] = mapped_column(nullable=True)
453 creator_pk: Mapped[int | None] = mapped_column(
454 ForeignKey("contact.id"), nullable=True
455 )
456 creator: Mapped[Contact | None] = relationship(back_populates="spaces_created")
458 owners: Mapped[list[Contact]] = relationship(
459 back_populates="spaces_owned",
460 secondary=space_owner_association,
461 )
463 rooms: Mapped[list[Room]] = relationship(cascade="all, delete-orphan")