Coverage for src/local_deep_research/web/models/database.py: 98%
82 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1from datetime import datetime, UTC
3from loguru import logger
5from ...config.paths import get_data_directory
6from ...database.models import ResearchLog
7from ...database.session_context import get_user_db_session
9# Database paths using new centralized configuration
10_raw_data_dir = get_data_directory()
11DATA_DIR: str | None = None
12if _raw_data_dir: 12 ↛ 23line 12 didn't jump to line 23 because the condition on line 12 was always true
13 DATA_DIR = str(_raw_data_dir)
14 # Lazy import: this module executes at import time (early bootstrap),
15 # so avoid a top-level security -> ... -> web import cycle.
16 from ...security.directory_creation import create_directory
18 create_directory(DATA_DIR, context="application data directory")
20# DB_PATH removed - use per-user encrypted databases instead
23def get_db_connection():
24 """
25 Get a connection to the SQLite database.
26 DEPRECATED: This uses the shared database which should not be used.
27 Use get_db_session() instead for per-user databases.
28 """
29 raise RuntimeError(
30 "Shared database access is deprecated. Use get_db_session() for per-user databases."
31 )
34def calculate_duration(created_at_str, completed_at_str=None):
35 """
36 Calculate duration in seconds between created_at timestamp and completed_at or now.
37 Handles various timestamp formats and returns None if calculation fails.
39 Args:
40 created_at_str: The start timestamp
41 completed_at_str: Optional end timestamp, defaults to current time if None
43 Returns:
44 Duration in seconds or None if calculation fails
45 """
46 if not created_at_str:
47 return None
49 end_time = None
50 if completed_at_str:
51 # Use completed_at time if provided
52 try:
53 if "T" in completed_at_str: # ISO format with T separator
54 end_time = datetime.fromisoformat(completed_at_str)
55 else: # Older format without T
56 # Try different formats
57 try:
58 end_time = datetime.strptime(
59 completed_at_str, "%Y-%m-%d %H:%M:%S.%f"
60 )
61 except ValueError:
62 try:
63 end_time = datetime.strptime(
64 completed_at_str, "%Y-%m-%d %H:%M:%S"
65 )
66 except ValueError:
67 # Last resort fallback
68 end_time = datetime.fromisoformat(
69 completed_at_str.replace(" ", "T")
70 )
71 except Exception:
72 logger.exception("Error parsing completed_at timestamp")
73 try:
74 from dateutil import parser # type: ignore[import-untyped]
76 end_time = parser.parse(completed_at_str)
77 except Exception:
78 logger.exception(
79 f"Fallback parsing also failed for completed_at: {completed_at_str}"
80 )
81 # Fall back to current time
82 end_time = datetime.now(UTC)
83 else:
84 # Use current time if no completed_at provided
85 end_time = datetime.now(UTC)
86 # Ensure end_time is UTC.
87 end_time = end_time.astimezone(UTC)
89 start_time = None
90 try:
91 # Proper parsing of ISO format
92 if "T" in created_at_str: # ISO format with T separator
93 start_time = datetime.fromisoformat(created_at_str)
94 else: # Older format without T
95 # Try different formats
96 try:
97 start_time = datetime.strptime(
98 created_at_str, "%Y-%m-%d %H:%M:%S.%f"
99 )
100 except ValueError:
101 try:
102 start_time = datetime.strptime(
103 created_at_str, "%Y-%m-%d %H:%M:%S"
104 )
105 except ValueError:
106 # Last resort fallback
107 start_time = datetime.fromisoformat(
108 created_at_str.replace(" ", "T")
109 )
110 except Exception:
111 logger.exception("Error parsing created_at timestamp")
112 # Fallback method if parsing fails
113 try:
114 from dateutil import parser
116 start_time = parser.parse(created_at_str)
117 except Exception:
118 logger.exception(
119 f"Fallback parsing also failed for created_at: {created_at_str}"
120 )
121 return None
123 # Calculate duration if both timestamps are valid
124 if start_time and end_time: 124 ↛ 130line 124 didn't jump to line 130 because the condition on line 124 was always true
125 try:
126 return int((end_time - start_time).total_seconds())
127 except Exception:
128 logger.exception("Error calculating duration")
130 return None
133def get_logs_for_research(research_id, username, limit: int | None = None):
134 """
135 Retrieve logs for a specific research ID, oldest-first.
137 Args:
138 research_id: ID of the research
139 username: Authenticated user whose encrypted DB to open.
140 limit: If set, return only the most recent ``limit`` entries
141 (still oldest-first in the returned list). Used to bound the
142 response size of ``/history/logs/<id>`` — long langgraph
143 researches can produce thousands of 10 KB rows that the
144 frontend prunes to 500 anyway.
146 Returns:
147 List of log entries as dictionaries
148 """
149 try:
150 with get_user_db_session(username) as session:
151 query = session.query(ResearchLog).filter(
152 ResearchLog.research_id == research_id
153 )
154 if limit is not None:
155 # Take the newest ``limit`` rows from the DB, then flip back
156 # to oldest-first ordering for the caller. Avoids loading
157 # the entire table when only the tail is wanted. ``id`` is the
158 # tie-break: timestamps are not unique, so without it the rows
159 # surviving ``.limit()`` at a shared-timestamp boundary would
160 # be SQL-undefined.
161 log_results = list(
162 reversed(
163 query.order_by(
164 ResearchLog.timestamp.desc(),
165 ResearchLog.id.desc(),
166 )
167 .limit(limit)
168 .all()
169 )
170 )
171 else:
172 log_results = query.order_by(
173 ResearchLog.timestamp.asc(), ResearchLog.id.asc()
174 ).all()
176 logs = []
177 for result in log_results:
178 # Convert entry for frontend consumption
179 formatted_entry = {
180 "time": result.timestamp,
181 "message": result.message,
182 "type": result.level,
183 "module": result.module,
184 "line_no": result.line_no,
185 }
186 logs.append(formatted_entry)
188 return logs
189 except Exception:
190 logger.exception("Error retrieving logs from database")
191 return []
194@logger.catch
195def get_total_logs_for_research(research_id, username):
196 """
197 Returns the total number of logs for a given `research_id`.
199 Args:
200 research_id (int): The ID of the research.
201 username (str): Authenticated user whose encrypted DB to open.
203 Returns:
204 int: Total number of logs for the specified research ID.
205 """
206 with get_user_db_session(username) as session:
207 return (
208 session.query(ResearchLog)
209 .filter(ResearchLog.research_id == research_id)
210 .count()
211 )