Coverage for slidge / db / models.py: 98%

186 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 05:07 +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, ForeignKey, Index, UniqueConstraint 

10from sqlalchemy.orm import Mapped, mapped_column, relationship 

11 

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

13from .meta import Base, JSONSerializable, JSONSerializableTypes 

14 

15 

16class ArchivedMessageSource(IntEnum): 

17 """ 

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

19 as a "live" message. 

20 """ 

21 

22 LIVE = 1 

23 BACKFILL = 2 

24 

25 

26class GatewayUser(Base): 

27 """ 

28 A user, registered to the gateway component. 

29 """ 

30 

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 ) 

37 

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

48 

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 

57 def __repr__(self) -> str: 

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

59 

60 def get(self, field: str, default: str = "") -> JSONSerializableTypes: 

61 # """ 

62 # Get fields from the registration form (required to comply with slixmpp backend protocol) 

63 # 

64 # :param field: Name of the field 

65 # :param default: Default value to return if the field is not present 

66 # 

67 # :return: Value of the field 

68 # """ 

69 return self.legacy_module_data.get(field, default) 

70 

71 @property 

72 def registration_form(self) -> dict[str, Any]: 

73 # Kept for retrocompat, should be 

74 # FIXME: delete me 

75 warnings.warn( 

76 "GatewayUser.registration_form is deprecated.", DeprecationWarning 

77 ) 

78 return self.legacy_module_data 

79 

80 

81class Avatar(Base): 

82 """ 

83 Avatars of contacts, rooms and participants. 

84 

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

86 """ 

87 

88 __tablename__ = "avatar" 

89 

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

91 

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

93 height: Mapped[int] = mapped_column() 

94 width: Mapped[int] = mapped_column() 

95 

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

97 

98 # this is only used when avatars are available as HTTP URLs and do not 

99 # have a legacy_id 

100 url: Mapped[str | None] = mapped_column(unique=True, default=None) 

101 etag: Mapped[str | None] = mapped_column(default=None) 

102 last_modified: Mapped[str | None] = mapped_column(default=None) 

103 

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

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

106 

107 

108class Contact(Base): 

109 """ 

110 Legacy contacts 

111 """ 

112 

113 __tablename__ = "contact" 

114 __table_args__ = ( 

115 UniqueConstraint( 

116 "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id" 

117 ), 

118 UniqueConstraint( 

119 "user_account_id", "jid", name="uq_contact_user_account_id_jid" 

120 ), 

121 ) 

122 

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

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

125 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="contacts") 

126 legacy_id: Mapped[str] = mapped_column(nullable=False) 

127 

128 jid: Mapped[JID] = mapped_column() 

129 

130 avatar_id: Mapped[int | None] = mapped_column( 

131 ForeignKey("avatar.id"), nullable=True 

132 ) 

133 avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="contacts") 

134 

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

136 

137 cached_presence: Mapped[bool] = mapped_column(default=False) 

138 last_seen: Mapped[datetime | None] = mapped_column(nullable=True) 

139 ptype: Mapped[str | None] = mapped_column(nullable=True) 

140 pstatus: Mapped[str | None] = mapped_column(nullable=True) 

141 pshow: Mapped[str | None] = mapped_column(nullable=True) 

142 caps_ver: Mapped[str | None] = mapped_column(nullable=True) 

143 

144 is_friend: Mapped[bool] = mapped_column(default=False) 

145 added_to_roster: Mapped[bool] = mapped_column(default=False) 

146 sent_order: Mapped[list["ContactSent"]] = relationship( 

147 back_populates="contact", cascade="all, delete-orphan" 

148 ) 

149 

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

151 default=None, nullable=True 

152 ) 

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

154 

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

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

157 

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

159 

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

161 

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

163 cascade="all, delete-orphan" 

164 ) 

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

166 

167 

168class ContactSent(Base): 

169 """ 

170 Keep track of XMPP msg ids sent by a specific contact for networks in which 

171 all messages need to be marked as read. 

172 

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

174 """ 

175 

176 __tablename__ = "contact_sent" 

177 __table_args__ = ( 

178 UniqueConstraint( 

179 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id" 

180 ), 

181 ) 

182 

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

184 contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id")) 

185 contact: Mapped[Contact] = relationship(back_populates="sent_order") 

186 msg_id: Mapped[str] = mapped_column() 

187 

188 

189class Room(Base): 

190 """ 

191 Legacy room 

192 """ 

193 

194 __table_args__ = ( 

195 UniqueConstraint( 

196 "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id" 

197 ), 

198 UniqueConstraint("user_account_id", "jid", name="uq_room_user_account_id_jid"), 

199 ) 

200 

201 __tablename__ = "room" 

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

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

204 user: Mapped[GatewayUser] = relationship(lazy=True, back_populates="rooms") 

205 legacy_id: Mapped[str] = mapped_column(nullable=False) 

206 

207 jid: Mapped[JID] = mapped_column(nullable=False) 

208 

209 avatar_id: Mapped[int | None] = mapped_column( 

210 ForeignKey("avatar.id"), nullable=True 

211 ) 

212 avatar: Mapped[Avatar | None] = relationship(lazy=False, back_populates="rooms") 

213 

214 name: Mapped[str | None] = mapped_column(nullable=True) 

215 description: Mapped[str | None] = mapped_column(nullable=True) 

216 subject: Mapped[str | None] = mapped_column(nullable=True) 

217 subject_date: Mapped[datetime | None] = mapped_column(nullable=True) 

218 subject_setter: Mapped[str | None] = mapped_column(nullable=True) 

219 

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

221 

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

223 

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

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

226 

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

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

229 

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

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

232 

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

234 back_populates="room", 

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

236 cascade="all, delete-orphan", 

237 ) 

238 

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

240 cascade="all, delete-orphan" 

241 ) 

242 

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

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

245 

246 

247class ArchivedMessage(Base): 

248 """ 

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

250 """ 

251 

252 __tablename__ = "mam" 

253 __table_args__ = ( 

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

255 ) 

256 

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

258 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) 

259 room: Mapped[Room] = relationship(lazy=True, back_populates="archive") 

260 

261 stanza_id: Mapped[str] = mapped_column(nullable=False) 

262 timestamp: Mapped[datetime] = mapped_column(nullable=False) 

263 author_jid: Mapped[JID] = mapped_column(nullable=False) 

264 source: Mapped[ArchivedMessageSource] = mapped_column(nullable=False) 

265 legacy_id: Mapped[str | None] = mapped_column(nullable=True) 

266 

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

268 

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

270 

271 

272class _LegacyToXmppIdsBase: 

273 """ 

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

275 

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

277 """ 

278 

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

280 legacy_id: Mapped[str] = mapped_column(nullable=False) 

281 xmpp_id: Mapped[str] = mapped_column(nullable=False) 

282 

283 

284class DirectMessages(_LegacyToXmppIdsBase, Base): 

285 __tablename__ = "direct_msg" 

286 __table_args__ = (Index("ix_direct_msg_legacy_id", "legacy_id", "foreign_key"),) 

287 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False) 

288 

289 

290class GroupMessages(_LegacyToXmppIdsBase, Base): 

291 __tablename__ = "group_msg" 

292 __table_args__ = (Index("ix_group_msg_legacy_id", "legacy_id", "foreign_key"),) 

293 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) 

294 

295 

296class GroupMessagesOrigin(_LegacyToXmppIdsBase, Base): 

297 """ 

298 This maps "origin ids" <message id=XXX> to legacy message IDs 

299 We need that for message corrections and retractions, which do not reference 

300 messages by their "Unique and Stable Stanza IDs (XEP-0359)" 

301 """ 

302 

303 __tablename__ = "group_msg_origin" 

304 __table_args__ = ( 

305 Index("ix_group_msg_origin_legacy_id", "legacy_id", "foreign_key"), 

306 ) 

307 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) 

308 

309 

310class DirectThreads(_LegacyToXmppIdsBase, Base): 

311 __tablename__ = "direct_thread" 

312 __table_args__ = (Index("ix_direct_direct_thread_id", "legacy_id", "foreign_key"),) 

313 foreign_key: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=False) 

314 

315 

316class GroupThreads(_LegacyToXmppIdsBase, Base): 

317 __tablename__ = "group_thread" 

318 __table_args__ = (Index("ix_direct_group_thread_id", "legacy_id", "foreign_key"),) 

319 foreign_key: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) 

320 

321 

322class Attachment(Base): 

323 """ 

324 Legacy attachments 

325 """ 

326 

327 __tablename__ = "attachment" 

328 __table_args__ = ( 

329 UniqueConstraint( 

330 "user_account_id", 

331 "legacy_file_id", 

332 name="uq_attachment_user_account_id_legacy_file_id", 

333 ), 

334 ) 

335 

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

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

338 user: Mapped[GatewayUser] = relationship(back_populates="attachments") 

339 

340 legacy_file_id: Mapped[str | None] = mapped_column(index=True, nullable=True) 

341 url: Mapped[str] = mapped_column(index=True, nullable=False) 

342 sims: Mapped[str | None] = mapped_column() 

343 sfs: Mapped[str | None] = mapped_column() 

344 

345 

346class Participant(Base): 

347 __tablename__ = "participant" 

348 __table_args__ = ( 

349 UniqueConstraint("room_id", "resource", name="uq_participant_room_id_resource"), 

350 UniqueConstraint( 

351 "room_id", "contact_id", name="uq_participant_room_id_contact_id" 

352 ), 

353 UniqueConstraint( 

354 "room_id", "occupant_id", name="uq_participant_room_id_occupant_id" 

355 ), 

356 ) 

357 

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

359 

360 room_id: Mapped[int] = mapped_column(ForeignKey("room.id"), nullable=False) 

361 room: Mapped[Room] = relationship( 

362 lazy=False, back_populates="participants", primaryjoin=Room.id == room_id 

363 ) 

364 

365 contact_id: Mapped[int | None] = mapped_column( 

366 ForeignKey("contact.id"), nullable=True 

367 ) 

368 contact: Mapped[Contact | None] = relationship( 

369 lazy=False, back_populates="participants" 

370 ) 

371 

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

373 

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

375 

376 affiliation: Mapped[MucAffiliation] = mapped_column( 

377 default="member", nullable=False 

378 ) 

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

380 

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

382 

383 resource: Mapped[str] = mapped_column(nullable=False) 

384 nickname: Mapped[str] = mapped_column(nullable=False, default=None) 

385 nickname_no_illegal: Mapped[str] = mapped_column(nullable=False, default=None) 

386 

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

388 

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

390 

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

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

393 self.role = "participant" 

394 self.affiliation = "member" 

395 

396 

397class Bob(Base): 

398 __tablename__ = "bob" 

399 

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

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

402 

403 sha_1: Mapped[str] = mapped_column(nullable=False, unique=True) 

404 sha_256: Mapped[str] = mapped_column(nullable=False, unique=True) 

405 sha_512: Mapped[str] = mapped_column(nullable=False, unique=True) 

406 

407 content_type: Mapped[str | None] = mapped_column(nullable=False)