Coverage for src/local_deep_research/research_library/routes/zotero_routes.py: 27%

105 statements  

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

1""" 

2Web routes for the Zotero integration. 

3 

4Exposes a small page plus JSON endpoints to test the connection, list the 

5user's Zotero collections, trigger a manual sync, and read sync status. 

6Credentials and options live in user settings (category ``zotero``); these 

7routes never accept or return the API key. 

8""" 

9 

10import threading 

11 

12from flask import Blueprint, current_app, jsonify, render_template, session 

13from loguru import logger 

14 

15from ...database.session_passwords import session_password_store 

16from ...database.thread_local_session import thread_cleanup 

17from ...security import sanitize_error_for_client 

18from ...web.auth.decorators import login_required 

19from ..utils import handle_api_error 

20from ..zotero import ( 

21 ZoteroAuthError, 

22 ZoteroError, 

23 ZoteroSyncService, 

24 ZoteroTransientError, 

25) 

26 

27zotero_bp = Blueprint("zotero", __name__, url_prefix="/library") 

28 

29 

30def _db_password() -> str | None: 

31 """Resolve the current session's database password.""" 

32 return session_password_store.get_session_password( 

33 session["username"], session.get("session_id") 

34 ) 

35 

36 

37def _zotero_error_status(exc: ZoteroError) -> int: 

38 """Map a Zotero client error to the matching HTTP status.""" 

39 if isinstance(exc, ZoteroAuthError): 

40 return 401 

41 if isinstance(exc, ZoteroTransientError): 

42 return 503 

43 return 400 

44 

45 

46def _session_expired_response(): 

47 """401 for a logged-in session whose cached DB password has expired. 

48 

49 Every endpoint below opens the user's encrypted database, so without the 

50 password the request cannot succeed — tell the client to re-authenticate 

51 instead of failing with a generic 500. 

52 """ 

53 return jsonify( 

54 { 

55 "success": False, 

56 "error": "Session expired — please sign in again.", 

57 } 

58 ), 401 

59 

60 

61@zotero_bp.route("/zotero") 

62@login_required 

63def zotero_page(): 

64 """Render the Zotero integration page.""" 

65 return render_template("pages/zotero.html", active_page="zotero") 

66 

67 

68@zotero_bp.route("/api/zotero/config", methods=["GET"]) 

69@login_required 

70def get_config(): 

71 """Return a non-secret summary of the Zotero configuration.""" 

72 password = _db_password() # gitleaks:allow 

73 if not password: 

74 return _session_expired_response() 

75 try: 

76 cfg = ZoteroSyncService(session["username"], password).get_config() 

77 return jsonify( 

78 { 

79 "success": True, 

80 "enabled": cfg.enabled, 

81 "configured": cfg.is_configured, 

82 "library_type": cfg.library_type, 

83 "library_id": cfg.library_id, 

84 "collection_keys": cfg.collection_keys, 

85 "import_tags": cfg.import_tags, 

86 "import_items_without_pdf": cfg.import_items_without_pdf, 

87 "import_annotations": cfg.import_annotations, 

88 "pdf_storage_mode": cfg.pdf_storage_mode, 

89 "auto_sync_enabled": cfg.auto_sync_enabled, 

90 "sync_interval_minutes": cfg.sync_interval_minutes, 

91 "use_local_api": cfg.use_local_api, 

92 "has_api_key": bool(cfg.api_key), 

93 } 

94 ) 

95 except Exception as e: 

96 return handle_api_error("getting Zotero config", e) 

97 

98 

99@zotero_bp.route("/api/zotero/test", methods=["POST"]) 

100@login_required 

101def test_connection(): 

102 """Validate the configured Zotero credentials.""" 

103 password = _db_password() # gitleaks:allow 

104 if not password: 

105 return _session_expired_response() 

106 try: 

107 result = ZoteroSyncService( 

108 session["username"], password 

109 ).test_connection() 

110 return jsonify(result), (200 if result.get("success") else 400) 

111 except Exception as e: 

112 return handle_api_error("testing Zotero connection", e) 

113 

114 

115@zotero_bp.route("/api/zotero/collections", methods=["GET"]) 

116@login_required 

117def list_collections(): 

118 """List the user's Zotero collections (key + name).""" 

119 password = _db_password() # gitleaks:allow 

120 if not password: 

121 return _session_expired_response() 

122 try: 

123 collections = ZoteroSyncService( 

124 session["username"], password 

125 ).list_collections() 

126 return jsonify({"success": True, "collections": collections}) 

127 except ZoteroError as e: 

128 # Zotero-side rejections carry actionable, static messages (bad 

129 # library ID, revoked key, …) — surface them instead of a 500, 

130 # with a status code matching the failure class. 

131 return jsonify( 

132 {"success": False, "error": sanitize_error_for_client(str(e))} 

133 ), _zotero_error_status(e) 

134 except Exception as e: 

135 return handle_api_error("listing Zotero collections", e) 

136 

137 

138@zotero_bp.route("/api/zotero/groups", methods=["GET"]) 

139@login_required 

140def list_groups(): 

141 """List the groups the configured API key can access (id + name).""" 

142 password = _db_password() # gitleaks:allow 

143 if not password: 

144 return _session_expired_response() 

145 try: 

146 groups = ZoteroSyncService(session["username"], password).list_groups() 

147 return jsonify({"success": True, "groups": groups}) 

148 except ZoteroError as e: 

149 return jsonify( 

150 {"success": False, "error": sanitize_error_for_client(str(e))} 

151 ), _zotero_error_status(e) 

152 except Exception as e: 

153 return handle_api_error("listing Zotero groups", e) 

154 

155 

156@zotero_bp.route("/api/zotero/sync", methods=["POST"]) 

157@login_required 

158def sync_now(): 

159 """Trigger a background sync of the configured collections. 

160 

161 Runs in a daemon thread (with an app context) using the current 

162 session's password, and returns immediately. Progress is observable via 

163 the ``/api/zotero/status`` endpoint. 

164 """ 

165 username = session["username"] 

166 password = _db_password() # gitleaks:allow 

167 if not password: 

168 return _session_expired_response() 

169 

170 cfg = ZoteroSyncService(username, password).get_config() 

171 if not cfg.is_configured: 

172 return jsonify( 

173 { 

174 "success": False, 

175 "error": "Zotero is not enabled/configured. Set your API key " 

176 "and library ID in Settings first.", 

177 } 

178 ), 400 

179 

180 # Fast feedback if a sync (manual OR scheduled) is already in flight. 

181 # The authoritative guard is the per-user lock inside sync_all(), which 

182 # serialises both entry points; this is just a best-effort early exit. 

183 if ZoteroSyncService.is_user_syncing(username): 

184 return jsonify( 

185 { 

186 "success": True, 

187 "message": "A Zotero sync is already running.", 

188 "already_running": True, 

189 } 

190 ) 

191 

192 app = current_app._get_current_object() 

193 

194 def _run(): 

195 # thread_cleanup() releases the thread-local DB session + cached 

196 # credentials when this daemon thread exits — without it each manual 

197 # sync would strand a pooled connection and the plaintext password. 

198 with app.app_context(), thread_cleanup(): 

199 try: 

200 # Manual syncs re-examine previously skipped items so 

201 # settings changes apply without waiting for the items to 

202 # change in Zotero (scheduled syncs stay cheap). 

203 result = ZoteroSyncService(username, password).sync_all( 

204 reprocess_skipped=True 

205 ) 

206 logger.info(f"Zotero manual sync finished: {result}") 

207 except Exception: 

208 logger.exception("Zotero manual sync failed") 

209 

210 threading.Thread( 

211 target=_run, name=f"zotero-sync-{username}", daemon=True 

212 ).start() 

213 return jsonify({"success": True, "message": "Zotero sync started."}) 

214 

215 

216@zotero_bp.route("/api/zotero/status", methods=["GET"]) 

217@login_required 

218def get_status(): 

219 """Return stored sync state for all configured collections.""" 

220 password = _db_password() # gitleaks:allow 

221 if not password: 

222 return _session_expired_response() 

223 try: 

224 status = ZoteroSyncService(session["username"], password).get_status() 

225 return jsonify( 

226 { 

227 "success": True, 

228 "collections": status, 

229 # Live counters while a sync runs (None when idle) — lets 

230 # the page render a real progress bar during long imports. 

231 "progress": ZoteroSyncService.get_sync_progress( 

232 session["username"] 

233 ), 

234 } 

235 ) 

236 except Exception as e: 

237 return handle_api_error("getting Zotero status", e)