Coverage for src/local_deep_research/database/library_init.py: 100%

95 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Database initialization for Library - Unified Document Architecture. 

3 

4This module handles: 

5- Seeding source_types table with predefined types 

6- Creating the default "Library" collection 

7- Must be called on app startup for each user 

8""" 

9 

10import threading 

11import uuid 

12from loguru import logger 

13from sqlalchemy.exc import IntegrityError 

14 

15from .models import SourceType, Collection 

16from .session_context import get_user_db_session 

17from ..constants import ( 

18 RESEARCH_HISTORY_COLLECTION_NAME, 

19 RESEARCH_HISTORY_COLLECTION_DESCRIPTION, 

20) 

21 

22 

23# Per-user locks serialise the check-then-insert critical sections below. 

24# Under IMMEDIATE isolation this was unnecessary; under DEFERRED, two 

25# concurrent invocations (e.g. two logins of the same user from two 

26# browser tabs) could both see the absent row and both insert, creating 

27# duplicate default collections. An application-level lock is simpler 

28# than a migration adding a partial UNIQUE constraint, and cheap. 

29_user_init_locks: dict[str, threading.Lock] = {} 

30_user_init_locks_lock = threading.Lock() 

31 

32 

33def _get_user_init_lock(username: str) -> threading.Lock: 

34 """Get (or lazily create) the per-user lock used to serialise the 

35 check-then-insert idempotent collection initialisers. 

36 """ 

37 with _user_init_locks_lock: 

38 lock = _user_init_locks.get(username) 

39 if lock is None: 

40 lock = threading.Lock() 

41 _user_init_locks[username] = lock 

42 return lock 

43 

44 

45def pop_user_init_lock(username: str) -> None: 

46 """Remove the per-user init lock for ``username`` from the registry. 

47 

48 Called from the user-close path (``db_manager.close_user_database`` 

49 callers in ``web/auth/connection_cleanup.py`` and ``web/auth/routes.py``) 

50 so the module-level dict doesn't accumulate one entry per username 

51 across the process lifetime. The next login lazily re-creates the 

52 lock, which is fine — the lock has no state that needs to persist 

53 across login/logout. 

54 """ 

55 with _user_init_locks_lock: 

56 _user_init_locks.pop(username, None) 

57 

58 

59def seed_source_types(username: str, password: str = None) -> None: 

60 """ 

61 Seed the source_types table with predefined document source types. 

62 

63 Args: 

64 username: User to seed types for 

65 password: User's password (optional, uses session context) 

66 """ 

67 predefined_types = [ 

68 { 

69 "name": "research_download", 

70 "display_name": "Research Download", 

71 "description": "Documents downloaded from research sessions (arXiv, PubMed, etc.)", 

72 "icon": "download", 

73 }, 

74 { 

75 "name": "user_upload", 

76 "display_name": "User Upload", 

77 "description": "Documents manually uploaded by the user", 

78 "icon": "upload", 

79 }, 

80 { 

81 "name": "manual_entry", 

82 "display_name": "Manual Entry", 

83 "description": "Documents manually created or entered", 

84 "icon": "edit", 

85 }, 

86 { 

87 "name": "research_report", 

88 "display_name": "Research Report", 

89 "description": "Generated research reports (markdown) for semantic search", 

90 "icon": "file-alt", 

91 }, 

92 { 

93 "name": "research_source", 

94 "display_name": "Research Source", 

95 "description": "Sources discovered during research with content for semantic search", 

96 "icon": "link", 

97 }, 

98 { 

99 "name": "note", 

100 "display_name": "Note", 

101 "description": "User-created notes with AI-enhanced features", 

102 "icon": "sticky-note", 

103 }, 

104 { 

105 "name": "zotero", 

106 "display_name": "Zotero", 

107 "description": "Documents imported from a Zotero library or collection", 

108 "icon": "book", 

109 }, 

110 ] 

111 

112 try: 

113 with get_user_db_session(username, password) as session: 

114 for type_data in predefined_types: 

115 # Check if type already exists 

116 existing = ( 

117 session.query(SourceType) 

118 .filter_by(name=type_data["name"]) 

119 .first() 

120 ) 

121 

122 if not existing: 

123 source_type = SourceType(id=str(uuid.uuid4()), **type_data) 

124 session.add(source_type) 

125 logger.info(f"Created source type: {type_data['name']}") 

126 

127 session.commit() 

128 logger.info("Source types seeded successfully") 

129 

130 except IntegrityError: 

131 logger.warning("Source types may already exist") 

132 except Exception: 

133 logger.warning("Error seeding source types") 

134 raise 

135 

136 

137def ensure_default_library_collection( 

138 username: str, password: str = None 

139) -> str: 

140 """ 

141 Ensure the default "Library" collection exists for a user. 

142 Creates it if it doesn't exist. 

143 

144 Args: 

145 username: User to check/create library for 

146 password: User's password (optional, uses session context) 

147 

148 Returns: 

149 UUID of the Library collection 

150 """ 

151 try: 

152 with ( 

153 _get_user_init_lock(username), 

154 get_user_db_session(username, password) as session, 

155 ): 

156 # Check if default library exists 

157 library = ( 

158 session.query(Collection).filter_by(is_default=True).first() 

159 ) 

160 

161 if library: 

162 logger.debug(f"Default Library collection exists: {library.id}") 

163 return library.id 

164 

165 # Create default Library collection 

166 library_id = str(uuid.uuid4()) 

167 library = Collection( 

168 id=library_id, 

169 name="Library", 

170 description="Default collection for research downloads and documents", 

171 collection_type="default_library", 

172 is_default=True, 

173 ) 

174 session.add(library) 

175 session.commit() 

176 

177 logger.info(f"Created default Library collection: {library_id}") 

178 return library_id 

179 

180 except Exception: 

181 logger.warning("Error ensuring default Library collection") 

182 raise 

183 

184 

185def ensure_research_history_collection( 

186 username: str, password: str = None 

187) -> str: 

188 """ 

189 Ensure the "Research History" collection exists for a user. 

190 This collection is used for semantic search over research reports and sources. 

191 Creates it if it doesn't exist. 

192 

193 Args: 

194 username: User to check/create collection for 

195 password: User's password (optional, uses session context) 

196 

197 Returns: 

198 UUID of the Research History collection 

199 """ 

200 try: 

201 with ( 

202 _get_user_init_lock(username), 

203 get_user_db_session(username, password) as session, 

204 ): 

205 # Check if research history collection exists 

206 collection = ( 

207 session.query(Collection) 

208 .filter_by(collection_type="research_history") 

209 .first() 

210 ) 

211 

212 if collection: 

213 logger.debug( 

214 f"Research History collection exists: {collection.id}" 

215 ) 

216 return collection.id 

217 

218 # Create Research History collection 

219 collection_id = str(uuid.uuid4()) 

220 collection = Collection( 

221 id=collection_id, 

222 name=RESEARCH_HISTORY_COLLECTION_NAME, 

223 description=RESEARCH_HISTORY_COLLECTION_DESCRIPTION, 

224 collection_type="research_history", 

225 is_default=False, 

226 ) 

227 session.add(collection) 

228 session.commit() 

229 

230 logger.info(f"Created Research History collection: {collection_id}") 

231 return collection_id 

232 

233 except Exception: 

234 logger.warning("Error ensuring Research History collection") 

235 raise 

236 

237 

238def initialize_library_for_user(username: str, password: str = None) -> dict: 

239 """ 

240 Complete initialization of library system for a user. 

241 Seeds source types and ensures default Library and Research History collections exist. 

242 

243 Args: 

244 username: User to initialize for 

245 password: User's password (optional, uses session context) 

246 

247 Returns: 

248 Dict with initialization results 

249 """ 

250 results = { 

251 "source_types_seeded": False, 

252 "library_collection_id": None, 

253 "research_history_collection_id": None, 

254 "success": False, 

255 } 

256 

257 try: 

258 # Seed source types 

259 seed_source_types(username, password) 

260 results["source_types_seeded"] = True 

261 

262 # Ensure Library collection 

263 library_id = ensure_default_library_collection(username, password) 

264 results["library_collection_id"] = library_id 

265 

266 # Ensure Research History collection 

267 research_history_id = ensure_research_history_collection( 

268 username, password 

269 ) 

270 results["research_history_collection_id"] = research_history_id 

271 

272 results["success"] = True 

273 logger.info(f"Library initialization complete for user: {username}") 

274 

275 except Exception as e: 

276 logger.warning(f"Library initialization failed for {username}") 

277 results["error"] = str(e) 

278 

279 return results 

280 

281 

282def get_default_library_id(username: str, password: str = None) -> str: 

283 """ 

284 Get the ID of the default Library collection for a user. 

285 Creates it if it doesn't exist. 

286 

287 Args: 

288 username: User to get library for 

289 password: User's password (optional, uses session context) 

290 

291 Returns: 

292 UUID of the Library collection 

293 """ 

294 return ensure_default_library_collection(username, password) 

295 

296 

297def get_source_type_id( 

298 username: str, type_name: str, password: str = None 

299) -> str: 

300 """ 

301 Get the ID of a source type by name. 

302 

303 Args: 

304 username: User to query for 

305 type_name: Name of source type (e.g., 'research_download', 'user_upload') 

306 password: User's password (optional, uses session context) 

307 

308 Returns: 

309 UUID of the source type 

310 

311 Raises: 

312 ValueError: If source type not found 

313 """ 

314 try: 

315 with get_user_db_session(username, password) as session: 

316 source_type = ( 

317 session.query(SourceType).filter_by(name=type_name).first() 

318 ) 

319 

320 if not source_type: 

321 raise ValueError(f"Source type not found: {type_name}") # noqa: TRY301 — inside db session context, except logs and re-raises 

322 

323 return source_type.id 

324 

325 except Exception: 

326 logger.warning("Error getting source type ID") 

327 raise