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

1import warnings 

2from datetime import datetime 

3from enum import IntEnum 

4from typing import Any 

5 

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 

11 

12from ..core import config 

13from ..util.types import ClientType, Hat, MucType 

14from .meta import Base, JSONSerializable, JSONSerializableTypes 

15 

16 

17class ArchivedMessageSource(IntEnum): 

18 """ 

19 Whether an archived message comes from ``LegacyMUC.backfill()`` or was received 

20 as a "live" message. 

21 """ 

22 

23 LIVE = 1 

24 BACKFILL = 2 

25 

26 

27class _JidMixin: 

28 jid_localpart: Mapped[str] 

29 

30 @property 

31 def jid(self) -> JID: 

32 return JID(f"{self.jid_localpart}@{config.JID}") 

33 

34 

35class GatewayUser(Base): 

36 """ 

37 A user, registered to the gateway component. 

38 """ 

39 

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 ) 

46 

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 """ 

57 

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 ) 

68 

69 def __repr__(self) -> str: 

70 return f"User(id={self.id!r}, jid={self.jid!r})" 

71 

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) 

82 

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 

91 

92 

93class Avatar(Base): 

94 """ 

95 Avatars of contacts, rooms and participants. 

96 

97 To comply with XEPs, we convert them all to PNG before storing them. 

98 """ 

99 

100 __tablename__ = "avatar" 

101 

102 id: Mapped[int] = mapped_column(primary_key=True) 

103 

104 hash: Mapped[str] = mapped_column(unique=True) 

105 height: Mapped[int] = mapped_column() 

106 width: Mapped[int] = mapped_column() 

107 

108 legacy_id: Mapped[str | None] = mapped_column(unique=True, nullable=True) 

109 

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) 

115 

116 contacts: Mapped[list["Contact"]] = relationship(back_populates="avatar") 

117 rooms: Mapped[list["Room"]] = relationship(back_populates="avatar") 

118 

119 

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) 

126 

127 

128class Contact(Base, _JidMixin): 

129 """ 

130 Legacy contacts 

131 """ 

132 

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 ) 

144 

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) 

149 

150 jid_localpart: Mapped[str] = mapped_column(nullable=False) 

151 

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") 

156 

157 nick: Mapped[str | None] = mapped_column(nullable=True) 

158 

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) 

165 

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 ) 

171 

172 extra_attributes: Mapped[JSONSerializable | None] = mapped_column( 

173 default=None, nullable=True 

174 ) 

175 updated: Mapped[bool] = mapped_column(default=False) 

176 

177 vcard: Mapped[str | None] = mapped_column() 

178 vcard_fetched: Mapped[bool] = mapped_column(default=False) 

179 

180 participants: Mapped[list["Participant"]] = relationship(back_populates="contact") 

181 

182 client_type: Mapped[ClientType] = mapped_column(nullable=False, default="pc") 

183 

184 messages: Mapped[list["DirectMessages"]] = relationship( 

185 cascade="all, delete-orphan" 

186 ) 

187 threads: Mapped[list["DirectThreads"]] = relationship(cascade="all, delete-orphan") 

188 

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 ) 

197 

198 

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. 

203 

204 (XMPP displayed markers convey a "read up to here" semantic.) 

205 """ 

206 

207 __tablename__ = "contact_sent" 

208 __table_args__ = ( 

209 UniqueConstraint( 

210 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id" 

211 ), 

212 ) 

213 

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() 

218 

219 

220class Room(Base, _JidMixin): 

221 """ 

222 Legacy room 

223 """ 

224 

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 ) 

235 

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) 

241 

242 jid_localpart: Mapped[str] = mapped_column(nullable=False) 

243 

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") 

248 

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) 

254 

255 n_participants: Mapped[int | None] = mapped_column(default=None) 

256 

257 muc_type: Mapped[MucType] = mapped_column(default=MucType.CHANNEL) 

258 

259 user_nick: Mapped[str | None] = mapped_column() 

260 user_resources: Mapped[str | None] = mapped_column(nullable=True) 

261 

262 participants_filled: Mapped[bool] = mapped_column(default=False) 

263 history_filled: Mapped[bool] = mapped_column(default=False) 

264 

265 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None) 

266 updated: Mapped[bool] = mapped_column(default=False) 

267 

268 participants: Mapped[list["Participant"]] = relationship( 

269 back_populates="room", 

270 primaryjoin="Participant.room_id == Room.id", 

271 cascade="all, delete-orphan", 

272 ) 

273 

274 archive: Mapped[list["ArchivedMessage"]] = relationship( 

275 cascade="all, delete-orphan" 

276 ) 

277 

278 messages: Mapped[list["GroupMessages"]] = relationship(cascade="all, delete-orphan") 

279 threads: Mapped[list["GroupThreads"]] = relationship(cascade="all, delete-orphan") 

280 

281 space_id: Mapped[int | None] = mapped_column(ForeignKey("space.id"), nullable=True) 

282 space: Mapped["Space"] = relationship(back_populates="rooms", lazy=False) 

283 

284 

285class ArchivedMessage(Base): 

286 """ 

287 Messages of rooms, that we store to act as a MAM server 

288 """ 

289 

290 __tablename__ = "mam" 

291 __table_args__ = ( 

292 UniqueConstraint("room_id", "stanza_id", name="uq_mam_room_id_stanza_id"), 

293 ) 

294 

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") 

298 

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) 

304 

305 stanza: Mapped[str] = mapped_column(nullable=False) 

306 

307 displayed_by_user: Mapped[bool] = mapped_column(default=False, nullable=True) 

308 

309 

310class _LegacyToXmppIdsBase: 

311 """ 

312 XMPP-client generated IDs, and mapping to the corresponding legacy IDs. 

313 

314 A single legacy ID can map to several XMPP ids. 

315 """ 

316 

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) 

320 

321 

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) 

326 

327 

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) 

332 

333 

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 """ 

340 

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) 

346 

347 

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) 

352 

353 

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) 

358 

359 

360class Attachment(Base): 

361 """ 

362 Legacy attachments 

363 """ 

364 

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 ) 

373 

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") 

377 

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() 

382 

383 

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 ) 

395 

396 id: Mapped[int] = mapped_column(primary_key=True) 

397 

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 ) 

402 

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 ) 

409 

410 occupant_id: Mapped[str] = mapped_column(nullable=False) 

411 

412 is_user: Mapped[bool] = mapped_column(default=False) 

413 

414 affiliation: Mapped[MucAffiliation] = mapped_column( 

415 default="member", nullable=False 

416 ) 

417 role: Mapped[MucRole] = mapped_column(default="participant", nullable=False) 

418 

419 presence_sent: Mapped[bool] = mapped_column(default=False) 

420 

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) 

424 

425 hats: Mapped[list[Hat]] = mapped_column(JSON, default=list) 

426 

427 extra_attributes: Mapped[JSONSerializable | None] = mapped_column(default=None) 

428 

429 def __init__(self, *args: object, **kwargs: object) -> None: 

430 super().__init__(*args, **kwargs) 

431 self.role = "participant" 

432 self.affiliation = "member" 

433 

434 

435class Bob(Base): 

436 __tablename__ = "bob" 

437 

438 id: Mapped[int] = mapped_column(primary_key=True) 

439 file_name: Mapped[str] = mapped_column(nullable=False) 

440 

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) 

444 

445 content_type: Mapped[str] = mapped_column(nullable=False) 

446 

447 

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 ) 

455 

456 id: Mapped[int] = mapped_column(primary_key=True) 

457 

458 updated: Mapped[bool] = mapped_column(default=False, nullable=False) 

459 

460 user_account_id: Mapped[int] = mapped_column(ForeignKey("user_account.id")) 

461 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="spaces") 

462 

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) 

467 

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") 

472 

473 owners: Mapped[list[Contact]] = relationship( 

474 back_populates="spaces_owned", 

475 secondary=space_owner_association, 

476 ) 

477 

478 rooms: Mapped[list[Room]] = relationship(cascade="all, delete-orphan")