Coverage for slidge/db/models.py: 95%
444 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-29 05:05 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-29 05:05 +0000
1import logging
2import warnings
3from collections.abc import Callable
4from datetime import UTC, date, datetime
5from enum import IntEnum
6from functools import lru_cache
7from pathlib import Path
8from typing import Any, Literal
9from xml.etree import ElementTree as ET
11import slixmpp.plugins.xep_0234.stanza
12import slixmpp.plugins.xep_0264.stanza
13import slixmpp.plugins.xep_0300.stanza
14import slixmpp.plugins.xep_0372.stanza
15import slixmpp.plugins.xep_0385.stanza
16import slixmpp.plugins.xep_0447.stanza
17import sqlalchemy as sa
18import sqlalchemy.orm
19from slixmpp import JID, Message
20from slixmpp.plugins.xep_0292.stanza import VCard4
21from slixmpp.plugins.xep_0297 import Forwarded
22from slixmpp.plugins.xep_0444 import XEP_0444
23from slixmpp.types import MucAffiliation, MucRole
24from sqlalchemy import JSON, Column, ForeignKey, Index, Table, UniqueConstraint
25from sqlalchemy.orm import Mapped, mapped_column, relationship, sessionmaker
27from ..core import config
28from ..util.types import AvatarMetadata, ClientType, Hat, MucType
29from .meta import Base, JSONSerializable, JSONSerializableTypes
32class ArchivedMessageSource(IntEnum):
33 """
34 Whether an archived message comes from ``LegacyMUC.backfill()`` or was received
35 as a "live" message.
36 """
38 LIVE = 1
39 BACKFILL = 2
42class _JidMixin:
43 jid_localpart: Mapped[str]
45 @property
46 def jid(self) -> JID:
47 return JID(f"{self.jid_localpart}@{config.JID}")
50class _EnsureLoadedMixin:
51 id: int | None
53 def ensure_loaded(
54 self,
55 group: str,
56 session_maker: sessionmaker[Any] | Callable[[], sa.orm.Session],
57 ) -> None:
58 if self.id is None:
59 return
61 if sa.orm.object_session(self) is not None:
62 return
64 state = sa.inspect(self)
65 if state is None:
66 return
68 group_keys = _group_keys(
69 self.__class__, # type:ignore[arg-type]
70 group,
71 )
73 unloaded = group_keys & state.unloaded
75 if not unloaded:
76 return
78 cls = self.__class__
79 stmt = sa.select(*(getattr(cls, key) for key in unloaded)).where(
80 cls.id == self.id # type:ignore[arg-type]
81 )
82 with session_maker() as session, session.no_autoflush:
83 row = session.execute(stmt).one()
85 for key, value in zip(unloaded, row):
86 sa.orm.attributes.set_committed_value(self, key, value)
89@lru_cache
90def _group_keys(cls: type[Base], group: str) -> frozenset[str]:
91 keys = frozenset(a.key for a in sa.inspect(cls).column_attrs if a.group == group)
92 if not keys:
93 raise ValueError(f"{cls.__name__} has no deferred group {group!r}")
94 return keys
97class GatewayUser(Base):
98 """
99 A user, registered to the gateway component.
100 """
102 __tablename__ = "user_account"
103 id: Mapped[int] = mapped_column(primary_key=True)
104 jid: Mapped[JID] = mapped_column(unique=True)
105 registration_date: Mapped[datetime] = mapped_column(
106 sa.DateTime, server_default=sa.func.now()
107 )
109 legacy_module_data: Mapped[JSONSerializable] = mapped_column(default={})
110 """
111 Arbitrary non-relational data that legacy modules can use
112 """
113 preferences: Mapped[JSONSerializable] = mapped_column(default={})
114 avatar_hash: Mapped[str | None] = mapped_column(default=None)
115 """
116 Hash of the user's avatar, to avoid re-publishing the same avatar on the
117 legacy network
118 """
120 contacts: Mapped[list["Contact"]] = relationship(
121 back_populates="user", cascade="all, delete-orphan"
122 )
123 rooms: Mapped[list["Room"]] = relationship(
124 back_populates="user", cascade="all, delete-orphan"
125 )
126 attachments: Mapped[list["Attachment"]] = relationship(cascade="all, delete-orphan")
127 spaces: Mapped[list["Space"]] = relationship(
128 back_populates="user", cascade="all, delete-orphan"
129 )
131 def __repr__(self) -> str:
132 return f"User(id={self.id!r}, jid={self.jid!r})"
134 def get(self, field: str, default: str = "") -> JSONSerializableTypes:
135 # """
136 # Get fields from the registration form (required to comply with slixmpp backend protocol)
137 #
138 # :param field: Name of the field
139 # :param default: Default value to return if the field is not present
140 #
141 # :return: Value of the field
142 # """
143 return self.legacy_module_data.get(field, default)
145 @property
146 def registration_form(self) -> dict[str, Any]:
147 # Kept for retrocompat, should be
148 # FIXME: delete me
149 warnings.warn(
150 "GatewayUser.registration_form is deprecated.", DeprecationWarning
151 )
152 return self.legacy_module_data
155class Avatar(Base):
156 """
157 Avatars of contacts, rooms and participants.
159 To comply with XEPs, we convert them all to PNG before storing them.
160 """
162 __tablename__ = "avatar"
164 id: Mapped[int] = mapped_column(primary_key=True)
166 hash: Mapped[str | None] = mapped_column(unique=True, nullable=True)
167 height: Mapped[int] = mapped_column()
168 width: Mapped[int] = mapped_column()
170 legacy_id: Mapped[str | None] = mapped_column(unique=True, nullable=True)
172 http_id: Mapped[str | None] = mapped_column(nullable=True)
173 http_type: Mapped[str | None] = mapped_column(nullable=True)
174 http_bytes: Mapped[int | None] = mapped_column(nullable=True)
175 http_url: Mapped[str | None] = mapped_column(nullable=True)
176 http_height: Mapped[int | None] = mapped_column(nullable=True)
177 http_width: Mapped[int | None] = mapped_column(nullable=True)
179 # this is only used when avatars are available as HTTP URLs and do not
180 # have a legacy_id
181 url: Mapped[str | None] = mapped_column(unique=True, default=None)
182 etag: Mapped[str | None] = mapped_column(default=None)
183 last_modified: Mapped[str | None] = mapped_column(default=None)
185 contacts: Mapped[list["Contact"]] = relationship(back_populates="avatar")
186 rooms: Mapped[list["Room"]] = relationship(back_populates="avatar")
188 def set_http_metadata(self, meta: AvatarMetadata | None) -> None:
189 if meta is None:
190 return
191 self.http_id = meta.id
192 self.http_bytes = meta.bytes
193 self.http_type = f"image/{meta.type}"
194 self.http_url = meta.url
195 self.http_height = meta.height
196 self.http_width = meta.width
199space_owner_association = Table(
200 "space_owner_association",
201 Base.metadata,
202 Column("space_id", ForeignKey("space.id"), primary_key=True),
203 Column("contact_id", ForeignKey("contact.id"), primary_key=True),
204)
207class Contact(Base, _EnsureLoadedMixin, _JidMixin):
208 """
209 Legacy contacts
210 """
212 __tablename__ = "contact"
213 __table_args__ = (
214 UniqueConstraint(
215 "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id"
216 ),
217 UniqueConstraint(
218 "user_account_id",
219 "jid_localpart",
220 name="uq_contact_user_account_id_jid_localpart",
221 ),
222 )
224 id: Mapped[int] = mapped_column(primary_key=True)
225 user_account_id: Mapped[int] = mapped_column(
226 ForeignKey("user_account.id"), deferred=True
227 )
228 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="contacts")
229 legacy_id: Mapped[str] = mapped_column(nullable=False)
231 jid_localpart: Mapped[str] = mapped_column(nullable=False)
233 avatar_id: Mapped[int | None] = mapped_column(
234 ForeignKey("avatar.id"), nullable=True
235 )
236 avatar: Mapped[Avatar | None] = relationship(back_populates="contacts")
238 nick: Mapped[str | None] = mapped_column(nullable=True)
240 cached_presence: Mapped[bool] = mapped_column(
241 default=False, deferred=True, deferred_group="presence"
242 )
243 last_seen: Mapped[datetime | None] = mapped_column(
244 nullable=True, deferred=True, deferred_group="presence"
245 )
246 ptype: Mapped[str | None] = mapped_column(
247 nullable=True, deferred=True, deferred_group="presence"
248 )
249 pstatus: Mapped[str | None] = mapped_column(
250 nullable=True, deferred=True, deferred_group="presence"
251 )
252 pshow: Mapped[str | None] = mapped_column(
253 nullable=True, deferred=True, deferred_group="presence"
254 )
256 is_friend: Mapped[bool] = mapped_column(default=False)
257 added_to_roster: Mapped[bool] = mapped_column(default=False)
258 sent_order: Mapped[list["ContactSent"]] = relationship(
259 back_populates="contact", cascade="all, delete-orphan"
260 )
262 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(
263 default=None, nullable=True
264 )
265 updated: Mapped[bool] = mapped_column(default=False)
267 vcard_fetched: Mapped[bool] = mapped_column(default=False)
268 # vcard fields
269 full_name: Mapped[str | None] = mapped_column(
270 nullable=True, deferred=True, deferred_group="vcard"
271 )
272 birthday: Mapped[date | None] = mapped_column(
273 nullable=True, deferred=True, deferred_group="vcard"
274 )
275 phones: Mapped[list[str] | None] = mapped_column(
276 JSON, nullable=True, deferred=True, deferred_group="vcard"
277 )
278 note: Mapped[str | None] = mapped_column(
279 nullable=True, deferred=True, deferred_group="vcard"
280 )
281 url: Mapped[str | None] = mapped_column(
282 nullable=True, deferred=True, deferred_group="vcard"
283 )
284 email: Mapped[str | None] = mapped_column(
285 nullable=True, deferred=True, deferred_group="vcard"
286 )
287 country: Mapped[str | None] = mapped_column(
288 nullable=True, deferred=True, deferred_group="vcard"
289 )
290 locality: Mapped[str | None] = mapped_column(
291 nullable=True, deferred=True, deferred_group="vcard"
292 )
293 pronouns: Mapped[str | None] = mapped_column(
294 nullable=True, deferred=True, deferred_group="vcard"
295 )
297 # No "merge" cascade: committing a Contact must never write its Participants.
298 # Otherwise a stale, transient Participant left in this (loaded) collection
299 # by the back_populates of Participant.contact gets INSERTed a second time on
300 # the next Contact.commit().
301 participants: Mapped[list["Participant"]] = relationship(
302 back_populates="contact", cascade="save-update"
303 )
305 client_type: Mapped[ClientType] = mapped_column(nullable=False, default="pc")
307 messages: Mapped[list["DirectMessages"]] = relationship(
308 cascade="all, delete-orphan"
309 )
310 threads: Mapped[list["DirectThreads"]] = relationship(cascade="all, delete-orphan")
312 spaces_created: Mapped[list["Space"]] = relationship(
313 back_populates="creator",
314 cascade="all, delete-orphan",
315 )
316 spaces_owned: Mapped[list["Space"]] = relationship(
317 back_populates="owners",
318 secondary=space_owner_association,
319 )
321 last_sent_msg_legacy_id: Mapped[str | None] = mapped_column(
322 deferred=True, deferred_group="last_msg"
323 )
324 last_sent_msg_date: Mapped[datetime | None] = mapped_column(
325 deferred=True, deferred_group="last_msg"
326 )
328 def vcard(self, domain: str) -> VCard4:
329 vcard = VCard4()
330 vcard.add_impp(f"xmpp:{self.jid_localpart}@{domain}")
332 if n := self.nick:
333 vcard.add_nickname(n)
334 if self.full_name:
335 vcard["full_name"] = self.full_name
336 elif n:
337 vcard["full_name"] = n
339 if self.birthday:
340 vcard["birthday"] = self.birthday
342 if self.note:
343 vcard.add_note(self.note)
344 if self.url:
345 vcard.add_url(self.url)
346 if self.email:
347 vcard.add_email(self.email)
349 if self.phones:
350 for p in self.phones:
351 vcard.add_tel(p)
352 if self.country or self.locality:
353 vcard.add_address(self.country, self.locality)
354 if self.pronouns:
355 vcard["pronouns"]["text"] = self.pronouns
357 return vcard
360class ContactSent(Base):
361 """
362 Keep track of XMPP msg ids sent by a specific contact for networks in which
363 all messages need to be marked as read.
365 (XMPP displayed markers convey a "read up to here" semantic.)
366 """
368 __tablename__ = "contact_sent"
369 __table_args__ = (
370 UniqueConstraint(
371 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id"
372 ),
373 )
375 id: Mapped[int] = mapped_column(primary_key=True)
376 contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"))
377 contact: Mapped[Contact] = relationship(back_populates="sent_order")
378 msg_id: Mapped[str] = mapped_column()
381class Room(Base, _EnsureLoadedMixin, _JidMixin):
382 """
383 Legacy room
384 """
386 __table_args__ = (
387 UniqueConstraint(
388 "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id"
389 ),
390 UniqueConstraint(
391 "user_account_id",
392 "jid_localpart",
393 name="uq_room_user_account_id_jid_localpart",
394 ),
395 )
397 __tablename__ = "room"
398 id: Mapped[int] = mapped_column(primary_key=True)
399 user_account_id: Mapped[int] = mapped_column(
400 ForeignKey("user_account.id"), deferred=True
401 )
402 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="rooms")
403 legacy_id: Mapped[str] = mapped_column(nullable=False)
405 jid_localpart: Mapped[str] = mapped_column(nullable=False)
407 avatar_id: Mapped[int | None] = mapped_column(
408 ForeignKey("avatar.id"), nullable=True
409 )
410 avatar: Mapped[Avatar | None] = relationship(back_populates="rooms")
412 name: Mapped[str | None] = mapped_column(nullable=True)
414 description: Mapped[str | None] = mapped_column(
415 nullable=True, deferred=True, deferred_group="disco"
416 )
417 n_participants: Mapped[int | None] = mapped_column(
418 default=None, deferred=True, deferred_group="disco"
419 )
421 subject: Mapped[str | None] = mapped_column(
422 nullable=True, deferred=True, deferred_group="subject"
423 )
424 subject_date: Mapped[datetime | None] = mapped_column(
425 nullable=True, deferred=True, deferred_group="subject"
426 )
427 subject_setter: Mapped[str | None] = mapped_column(
428 nullable=True, deferred=True, deferred_group="subject"
429 )
431 muc_type: Mapped[MucType] = mapped_column(default=MucType.CHANNEL)
433 user_nick: Mapped[str | None] = mapped_column()
434 user_resources: Mapped[str | None] = mapped_column(nullable=True)
436 participants_filled: Mapped[bool] = mapped_column(default=False)
437 history_filled: Mapped[bool] = mapped_column(default=False)
439 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
440 updated: Mapped[bool] = mapped_column(default=False)
442 participants: Mapped[list["Participant"]] = relationship(
443 back_populates="room",
444 primaryjoin="Participant.room_id == Room.id",
445 cascade="all, delete-orphan",
446 )
448 archive: Mapped[list["ArchivedMessage"]] = relationship(
449 cascade="all, delete-orphan"
450 )
452 space_id: Mapped[int | None] = mapped_column(ForeignKey("space.id"), nullable=True)
453 space: Mapped["Space | None"] = relationship(back_populates="rooms")
455 def load_participants(
456 self, orm: sessionmaker[Any] | Callable[[], sa.orm.Session], with_presence: bool
457 ) -> None:
458 if self.id is None:
459 return
461 stmt = (
462 _STMT_ROOM_LOAD_PARTICIPANTS_WITH_PRESENCE
463 if with_presence
464 else _STMT_ROOM_LOAD_PARTICIPANTS
465 ).where(Room.id == self.id)
467 if (existing_session := sa.orm.object_session(self)) is None:
468 with orm() as session, session.no_autoflush:
469 session.add(self)
470 session.execute(stmt).all()
471 else:
472 existing_session.execute(stmt).all()
475class ArchivedMessage(Base):
476 """
477 Messages of rooms, that we store to act as a MAM server
478 """
480 __tablename__ = "mam"
481 __table_args__ = (
482 UniqueConstraint("room_id", "stanza_id", name="uq_mam_room_id_stanza_id"),
483 Index("ix_mam_room_id_legacy_id", "room_id", "legacy_id"),
484 Index("ix_mam_room_id_timestamp", "room_id", "timestamp"),
485 )
487 id: Mapped[int] = mapped_column(primary_key=True)
488 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
489 room: Mapped[Room] = relationship(lazy=True, back_populates="archive")
491 stanza_id: Mapped[str] = mapped_column(nullable=False)
492 origin_id: Mapped[str | None] = mapped_column(nullable=True)
493 timestamp: Mapped[datetime] = mapped_column(nullable=False)
495 author_resource: Mapped[str | None] = mapped_column(nullable=True)
496 author_affiliation: Mapped[str | None] = mapped_column()
497 author_role: Mapped[str | None] = mapped_column()
498 author_jid_localpart: Mapped[str | None] = mapped_column()
500 from_user: Mapped[bool] = mapped_column()
502 occupant_id: Mapped[str | None] = mapped_column()
504 source: Mapped[ArchivedMessageSource] = mapped_column(nullable=False)
505 legacy_id: Mapped[str | None] = mapped_column(nullable=True)
506 thread_legacy_id: Mapped[str | None] = mapped_column(nullable=True)
508 # payload is for XML we don't have dedicated columns for
509 payload: Mapped[str | None] = mapped_column(nullable=True)
511 displayed_id: Mapped[str | None] = mapped_column(nullable=True)
512 reaction_id: Mapped[str | None] = mapped_column(nullable=True)
513 reaction_emojis: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
514 body: Mapped[str | None] = mapped_column(nullable=True)
515 thread: Mapped[str | None] = mapped_column(nullable=True)
517 displayed_by_user: Mapped[bool] = mapped_column(default=False, nullable=True)
519 def stanza(
520 self, ns: str = "jabber:client", user_jid: JID | str | None = None
521 ) -> Message:
522 msg = Message(
523 stype="groupchat",
524 sfrom=f"{self.room.jid}/{self.author_resource}"
525 if self.author_resource
526 else self.room.jid,
527 )
528 if ns != "jabber:client":
529 msg.xml.tag = f"{{{ns}}}message"
530 msg.namespace = ns
532 msg["stanza_id"]["id"] = self.stanza_id
533 msg["stanza_id"]["by"] = self.room.jid
535 if self.origin_id:
536 msg["id"] = msg["origin_id"]["id"] = self.origin_id
537 else:
538 msg["id"] = self.stanza_id
540 if self.occupant_id:
541 msg["occupant-id"]["id"] = self.occupant_id
542 elif self.author_jid_localpart:
543 msg["occupant-id"]["id"] = f"{self.author_jid_localpart}@{config.JID}"
544 else:
545 log.warning("Cannot set an occupant ID for this archived message")
547 if self.author_affiliation:
548 msg["muc"]["affiliation"] = self.author_affiliation
549 if self.author_role:
550 msg["muc"]["role"] = self.author_role
551 if self.from_user:
552 msg["muc"]["jid"] = str(user_jid)
553 elif self.author_jid_localpart:
554 msg["muc"]["jid"] = JID(f"{self.author_jid_localpart}@{config.JID}")
556 if self.payload:
557 for child in ET.fromstring(f'<x xmlns="{ns}">{self.payload}</x>'):
558 msg.append(child)
559 if self.displayed_id:
560 msg["displayed"]["id"] = self.displayed_id
561 if self.reaction_id:
562 XEP_0444.set_reactions(msg, self.reaction_id, self.reaction_emojis or [])
563 if self.body:
564 msg["body"] = self.body
565 if self.thread:
566 msg["thread"] = self.thread
568 return msg
570 def forwarded(self, user_jid: JID | str) -> Forwarded:
571 forwarded = Forwarded()
572 forwarded["delay"]["stamp"] = self.timestamp.replace(tzinfo=UTC)
573 forwarded.append(self.stanza(user_jid=user_jid))
574 return forwarded
577class _LegacyToXmppIdsBase:
578 """
579 XMPP-client generated IDs, and mapping to the corresponding legacy IDs.
581 A single legacy ID can map to several XMPP ids.
582 """
584 id: Mapped[int] = mapped_column(primary_key=True)
585 legacy_id: Mapped[str] = mapped_column(nullable=False)
586 xmpp_id: Mapped[str] = mapped_column(nullable=False)
589class DirectMessages(_LegacyToXmppIdsBase, Base):
590 __tablename__ = "direct_msg"
591 __table_args__ = (Index("ix_direct_msg_legacy_id", "legacy_id", "foreign_key"),)
592 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
595class DirectThreads(_LegacyToXmppIdsBase, Base):
596 __tablename__ = "direct_thread"
597 __table_args__ = (Index("ix_direct_direct_thread_id", "legacy_id", "foreign_key"),)
598 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False)
601class Attachment(Base):
602 """
603 Legacy attachments
604 """
606 __tablename__ = "attachment"
607 __table_args__ = (
608 UniqueConstraint(
609 "user_account_id",
610 "legacy_file_id",
611 name="uq_attachment_user_account_id_legacy_file_id",
612 ),
613 )
615 id: Mapped[int] = mapped_column(primary_key=True)
616 user_account_id: Mapped[int] = mapped_column(
617 ForeignKey("user_account.id"), deferred=True
618 )
619 user: Mapped[GatewayUser] = relationship(back_populates="attachments")
621 legacy_file_id: Mapped[str | None] = mapped_column(index=True, nullable=True)
622 url: Mapped[str] = mapped_column(index=True, nullable=False)
624 size: Mapped[int | None] = mapped_column(nullable=True)
625 sha256: Mapped[str | None] = mapped_column(nullable=True)
627 thumbhash: Mapped[str | None] = mapped_column(nullable=True)
628 width: Mapped[int | None] = mapped_column(nullable=True)
629 height: Mapped[int | None] = mapped_column(nullable=True)
630 content_type: Mapped[str | None] = mapped_column(nullable=True)
631 desc: Mapped[str | None] = mapped_column(nullable=True)
632 name: Mapped[str | None] = mapped_column(nullable=True)
633 date: Mapped[datetime | None] = mapped_column(nullable=True)
634 disposition: Mapped[Literal["inline", "attachment"] | None] = mapped_column(
635 nullable=True
636 )
638 def _hash(self) -> slixmpp.plugins.xep_0300.stanza.Hash | None:
639 if self.sha256 is None:
640 return None
641 hash_elem = slixmpp.plugins.xep_0300.stanza.Hash()
642 hash_elem["algo"] = "sha-256"
643 hash_elem["value"] = self.sha256
644 return hash_elem
646 def _thumbnail(self) -> slixmpp.plugins.xep_0264.stanza.Thumbnail | None:
647 if self.thumbhash is None:
648 return None
649 thumbnail = slixmpp.plugins.xep_0264.stanza.Thumbnail()
650 thumbnail["width"] = self.width
651 thumbnail["height"] = self.height
652 thumbnail["media-type"] = "image/thumbhash"
653 thumbnail["uri"] = "data:image/thumbhash;base64," + self.thumbhash
654 return thumbnail
656 def sims(self) -> slixmpp.plugins.xep_0372.stanza.Reference:
657 ref = slixmpp.plugins.xep_0372.stanza.Reference()
658 ref["type"] = "data"
659 ref["uri"] = self.url
661 sims = slixmpp.plugins.xep_0385.stanza.Sims()
662 sims["sources"].append(ref)
663 sims.enable("file")
664 if self.content_type is not None:
665 sims["file"]["media-type"] = self.content_type
666 if self.desc is not None:
667 sims["file"]["desc"] = self.desc
668 if self.size is not None:
669 sims["file"]["size"] = self.size
670 if self.name is not None:
671 sims["file"]["name"] = self.name
672 if self.date is not None:
673 sims["file"]["date"] = self.date
674 if self.thumbhash is not None:
675 sims["file"].append(self._thumbnail())
676 if self.sha256 is not None:
677 sims["file"].append(self._hash())
679 wrapper = slixmpp.plugins.xep_0372.stanza.Reference()
680 wrapper["type"] = "data"
681 wrapper.append(sims)
683 return wrapper
685 def sfs(self) -> slixmpp.plugins.xep_0447.stanza.StatelessFileSharing:
686 ref = slixmpp.plugins.xep_0447.stanza.UrlData()
687 ref["target"] = self.url
689 sfs = slixmpp.plugins.xep_0447.stanza.StatelessFileSharing()
690 sfs["sources"].append(ref)
691 sfs.enable("file")
692 if self.content_type is not None:
693 sfs["file"]["media-type"] = self.content_type
694 if self.name is not None:
695 sfs["file"]["name"] = self.name
696 if self.desc is not None:
697 sfs["file"]["desc"] = self.desc
698 if self.disposition is not None:
699 sfs["disposition"] = self.disposition
700 else:
701 del sfs["disposition"]
702 if self.date is not None:
703 sfs["file"]["date"] = self.date
704 if self.size is not None:
705 sfs["file"]["size"] = self.size
706 if self.width is not None:
707 sfs["file"]["width"] = self.width
708 if self.height is not None:
709 sfs["file"]["height"] = self.height
710 if self.thumbhash is not None:
711 sfs["file"].append(self._thumbnail())
712 if self.sha256 is not None:
713 sfs["file"].append(self._hash())
715 return sfs
717 @property
718 def local_path(self) -> Path:
719 assert config.NO_UPLOAD_PATH is not None
720 assert config.NO_UPLOAD_URL_PREFIX is not None
721 normalized_prefix = config.NO_UPLOAD_URL_PREFIX.rstrip("/")
722 relative = self.url.removeprefix(normalized_prefix).lstrip("/")
723 return Path(config.NO_UPLOAD_PATH) / relative
726class Participant(Base):
727 __tablename__ = "participant"
728 __table_args__ = (
729 UniqueConstraint("room_id", "resource", name="uq_participant_room_id_resource"),
730 UniqueConstraint(
731 "room_id", "contact_id", name="uq_participant_room_id_contact_id"
732 ),
733 UniqueConstraint(
734 "room_id", "occupant_id", name="uq_participant_room_id_occupant_id"
735 ),
736 )
738 id: Mapped[int] = mapped_column(primary_key=True)
740 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False)
741 room: Mapped[Room] = relationship(
742 back_populates="participants", primaryjoin=Room.id == room_id
743 )
745 contact_id: Mapped[int | None] = mapped_column(
746 ForeignKey("contact.id"), nullable=True
747 )
748 contact: Mapped[Contact | None] = relationship(
749 lazy=True, back_populates="participants"
750 )
752 occupant_id: Mapped[str] = mapped_column(nullable=False)
754 is_user: Mapped[bool] = mapped_column(default=False)
756 affiliation: Mapped[MucAffiliation] = mapped_column(
757 default="member", nullable=False
758 )
759 role: Mapped[MucRole] = mapped_column(default="participant", nullable=False)
761 presence_sent: Mapped[bool] = mapped_column(default=False)
763 resource: Mapped[str] = mapped_column(nullable=False)
764 nickname: Mapped[str] = mapped_column(nullable=False, default=None)
765 nickname_no_illegal: Mapped[str] = mapped_column(nullable=False, default=None)
767 hats: Mapped[list[Hat]] = mapped_column(JSON, default=list)
769 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None)
771 def __init__(self, *args: object, **kwargs: object) -> None:
772 super().__init__(*args, **kwargs)
773 self.role = "participant"
774 self.affiliation = "member"
777class Bob(Base):
778 __tablename__ = "bob"
780 id: Mapped[int] = mapped_column(primary_key=True)
781 file_name: Mapped[str] = mapped_column(nullable=False)
783 sha_1: Mapped[str] = mapped_column(nullable=False, unique=True)
784 sha_256: Mapped[str] = mapped_column(nullable=False, unique=True)
785 sha_512: Mapped[str] = mapped_column(nullable=False, unique=True)
787 content_type: Mapped[str] = mapped_column(nullable=False)
790class Space(Base):
791 __tablename__ = "space"
792 __table_args__ = (
793 UniqueConstraint(
794 "user_account_id", "legacy_id", name="uq_space_user_account_id_legacy_id"
795 ),
796 )
798 id: Mapped[int] = mapped_column(primary_key=True)
800 updated: Mapped[bool] = mapped_column(default=False, nullable=False)
802 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
803 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="spaces")
805 legacy_id: Mapped[str] = mapped_column(nullable=False)
806 name: Mapped[str | None] = mapped_column(nullable=True)
807 description: Mapped[str | None] = mapped_column(nullable=True)
808 member_count: Mapped[int | None] = mapped_column(nullable=True)
810 creator_pk: Mapped[int | None] = mapped_column(
811 ForeignKey("contact.id"), nullable=True
812 )
813 creator: Mapped[Contact | None] = relationship(back_populates="spaces_created")
815 owners: Mapped[list[Contact]] = relationship(
816 back_populates="spaces_owned",
817 secondary=space_owner_association,
818 )
820 rooms: Mapped[list[Room]] = relationship(cascade="all, delete-orphan")
822 avatar_id: Mapped[int | None] = mapped_column(
823 ForeignKey("avatar.id"), nullable=True
824 )
825 avatar: Mapped[Avatar | None] = relationship(Avatar, foreign_keys=avatar_id)
827 banner_id: Mapped[int | None] = mapped_column(
828 ForeignKey("avatar.id"), nullable=True
829 )
830 banner: Mapped[Avatar | None] = relationship(Avatar, foreign_keys=banner_id)
833def _room_load_participants_stmt(with_presence: bool = False) -> sa.Select[tuple[Room]]:
834 loader = sa.orm.selectinload(Room.participants).selectinload(Participant.contact)
835 if with_presence:
836 loader = loader.undefer_group("presence")
837 return sa.select(Room).options(loader).execution_options(populate_existing=True)
840_STMT_ROOM_LOAD_PARTICIPANTS = _room_load_participants_stmt()
841_STMT_ROOM_LOAD_PARTICIPANTS_WITH_PRESENCE = _room_load_participants_stmt(True)
843log = logging.getLogger(__name__)