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

185 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-02-15 09:02 +0000

1import warnings 

2from datetime import datetime 

3from enum import IntEnum 

4 

5import sqlalchemy as sa 

6from slixmpp import JID 

7from slixmpp.types import MucAffiliation, MucRole 

8from sqlalchemy import JSON, ForeignKey, Index, UniqueConstraint 

9from sqlalchemy.orm import Mapped, mapped_column, relationship 

10 

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

12from .meta import Base, JSONSerializable, JSONSerializableTypes 

13 

14 

15class ArchivedMessageSource(IntEnum): 

16 """ 

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

18 as a "live" message. 

19 """ 

20 

21 LIVE = 1 

22 BACKFILL = 2 

23 

24 

25class GatewayUser(Base): 

26 """ 

27 A user, registered to the gateway component. 

28 """ 

29 

30 __tablename__ = "user_account" 

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

32 jid: Mapped[JID] = mapped_column(unique=True) 

33 registration_date: Mapped[datetime] = mapped_column( 

34 sa.DateTime, server_default=sa.func.now() 

35 ) 

36 

37 legacy_module_data: Mapped[JSONSerializable] = mapped_column(default={}) 

38 """ 

39 Arbitrary non-relational data that legacy modules can use 

40 """ 

41 preferences: Mapped[JSONSerializable] = mapped_column(default={}) 

42 avatar_hash: Mapped[str | None] = mapped_column(default=None) 

43 """ 

44 Hash of the user's avatar, to avoid re-publishing the same avatar on the 

45 legacy network 

46 """ 

47 

48 contacts: Mapped[list["Contact"]] = relationship( 

49 back_populates="user", cascade="all, delete-orphan" 

50 ) 

51 rooms: Mapped[list["Room"]] = relationship( 

52 back_populates="user", cascade="all, delete-orphan" 

53 ) 

54 attachments: Mapped[list["Attachment"]] = relationship(cascade="all, delete-orphan") 

55 

56 def __repr__(self) -> str: 

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

58 

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

60 # """ 

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

62 # 

63 # :param field: Name of the field 

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

65 # 

66 # :return: Value of the field 

67 # """ 

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

69 

70 @property 

71 def registration_form(self) -> dict: 

72 # Kept for retrocompat, should be 

73 # FIXME: delete me 

74 warnings.warn( 

75 "GatewayUser.registration_form is deprecated.", DeprecationWarning 

76 ) 

77 return self.legacy_module_data 

78 

79 

80class Avatar(Base): 

81 """ 

82 Avatars of contacts, rooms and participants. 

83 

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

85 """ 

86 

87 __tablename__ = "avatar" 

88 

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

90 

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

92 height: Mapped[int] = mapped_column() 

93 width: Mapped[int] = mapped_column() 

94 

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

96 

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

98 # have a legacy_id 

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

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

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

102 

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

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

105 

106 

107class Contact(Base): 

108 """ 

109 Legacy contacts 

110 """ 

111 

112 __tablename__ = "contact" 

113 __table_args__ = ( 

114 UniqueConstraint( 

115 "user_account_id", "legacy_id", name="uq_contact_user_account_id_legacy_id" 

116 ), 

117 UniqueConstraint( 

118 "user_account_id", "jid", name="uq_contact_user_account_id_jid" 

119 ), 

120 ) 

121 

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

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

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

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

126 

127 jid: Mapped[JID] = mapped_column() 

128 

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

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

131 ) 

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

133 

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

135 

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

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

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

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

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

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

142 

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

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

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

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

147 ) 

148 

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

150 default=None, nullable=True 

151 ) 

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

153 

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

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

156 

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

158 

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

160 

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

162 cascade="all, delete-orphan" 

163 ) 

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

165 

166 

167class ContactSent(Base): 

168 """ 

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

170 all messages need to be marked as read. 

171 

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

173 """ 

174 

175 __tablename__ = "contact_sent" 

176 __table_args__ = ( 

177 UniqueConstraint( 

178 "contact_id", "msg_id", name="uq_contact_sent_contact_id_msg_id" 

179 ), 

180 ) 

181 

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

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

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

185 msg_id: Mapped[str] = mapped_column() 

186 

187 

188class Room(Base): 

189 """ 

190 Legacy room 

191 """ 

192 

193 __table_args__ = ( 

194 UniqueConstraint( 

195 "user_account_id", "legacy_id", name="uq_room_user_account_id_legacy_id" 

196 ), 

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

198 ) 

199 

200 __tablename__ = "room" 

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

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

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

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

205 

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

207 

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

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

210 ) 

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

212 

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

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

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

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

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

218 

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

220 

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

222 

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

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

225 

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

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

228 

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

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

231 

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

233 back_populates="room", 

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

235 cascade="all, delete-orphan", 

236 ) 

237 

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

239 cascade="all, delete-orphan" 

240 ) 

241 

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

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

244 

245 

246class ArchivedMessage(Base): 

247 """ 

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

249 """ 

250 

251 __tablename__ = "mam" 

252 __table_args__ = ( 

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

254 ) 

255 

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

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

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

259 

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

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

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

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

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

265 

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

267 

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

269 

270 

271class _LegacyToXmppIdsBase: 

272 """ 

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

274 

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

276 """ 

277 

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

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

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

281 

282 

283class DirectMessages(_LegacyToXmppIdsBase, Base): 

284 __tablename__ = "direct_msg" 

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

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

287 

288 

289class GroupMessages(_LegacyToXmppIdsBase, Base): 

290 __tablename__ = "group_msg" 

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

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

293 

294 

295class GroupMessagesOrigin(_LegacyToXmppIdsBase, Base): 

296 """ 

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

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

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

300 """ 

301 

302 __tablename__ = "group_msg_origin" 

303 __table_args__ = ( 

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

305 ) 

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

307 

308 

309class DirectThreads(_LegacyToXmppIdsBase, Base): 

310 __tablename__ = "direct_thread" 

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

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

313 

314 

315class GroupThreads(_LegacyToXmppIdsBase, Base): 

316 __tablename__ = "group_thread" 

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

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

319 

320 

321class Attachment(Base): 

322 """ 

323 Legacy attachments 

324 """ 

325 

326 __tablename__ = "attachment" 

327 __table_args__ = ( 

328 UniqueConstraint( 

329 "user_account_id", 

330 "legacy_file_id", 

331 name="uq_attachment_user_account_id_legacy_file_id", 

332 ), 

333 ) 

334 

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

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

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

338 

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

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

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

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

343 

344 

345class Participant(Base): 

346 __tablename__ = "participant" 

347 __table_args__ = ( 

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

349 UniqueConstraint( 

350 "room_id", "contact_id", name="uq_participant_room_id_contact_id" 

351 ), 

352 UniqueConstraint( 

353 "room_id", "occupant_id", name="uq_participant_room_id_occupant_id" 

354 ), 

355 ) 

356 

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

358 

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

360 room: Mapped[Room] = relationship( 

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

362 ) 

363 

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

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

366 ) 

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

368 lazy=False, back_populates="participants" 

369 ) 

370 

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

372 

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

374 

375 affiliation: Mapped[MucAffiliation] = mapped_column( 

376 default="member", nullable=False 

377 ) 

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

379 

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

381 

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

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

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

385 

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

387 

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

389 

390 def __init__(self, *args, **kwargs): 

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

392 self.role = "participant" 

393 self.affiliation = "member" 

394 

395 

396class Bob(Base): 

397 __tablename__ = "bob" 

398 

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

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

401 

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

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

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

405 

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