pyflexweb 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyflexweb/__init__.py +13 -0
- pyflexweb/cli.py +213 -0
- pyflexweb/client.py +75 -0
- pyflexweb/database.py +293 -0
- pyflexweb/handlers.py +319 -0
- pyflexweb-0.1.2.dist-info/METADATA +297 -0
- pyflexweb-0.1.2.dist-info/RECORD +11 -0
- pyflexweb-0.1.2.dist-info/WHEEL +5 -0
- pyflexweb-0.1.2.dist-info/entry_points.txt +2 -0
- pyflexweb-0.1.2.dist-info/licenses/LICENSE +680 -0
- pyflexweb-0.1.2.dist-info/top_level.txt +1 -0
pyflexweb/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyFlexWeb - Interactive Brokers Flex Web Service Client
|
|
3
|
+
|
|
4
|
+
A command-line tool to easily download IBKR Flex Activity and Trade Confirmation reports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
except ImportError:
|
|
10
|
+
# Python < 3.8
|
|
11
|
+
from importlib_metadata import version # type: ignore
|
|
12
|
+
|
|
13
|
+
__version__ = version("pyflexweb")
|
pyflexweb/cli.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for PyFlexWeb.
|
|
3
|
+
|
|
4
|
+
This module provides the main entry point and argument parsing for the PyFlexWeb CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .database import FlexDatabase
|
|
12
|
+
from .handlers import (
|
|
13
|
+
handle_download_command,
|
|
14
|
+
handle_fetch_command,
|
|
15
|
+
handle_query_command,
|
|
16
|
+
handle_request_command,
|
|
17
|
+
handle_token_command,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Common options
|
|
22
|
+
def common_options(func):
|
|
23
|
+
"""Common options for commands that fetch reports."""
|
|
24
|
+
func = click.option("--output", help="Output filename (for single report downloads only)")(func)
|
|
25
|
+
func = click.option(
|
|
26
|
+
"--output-dir",
|
|
27
|
+
help="Directory to save reports (default: current directory)",
|
|
28
|
+
)(func)
|
|
29
|
+
func = click.option(
|
|
30
|
+
"--poll-interval",
|
|
31
|
+
type=int,
|
|
32
|
+
default=30,
|
|
33
|
+
help="Seconds to wait between polling attempts (default: 30)",
|
|
34
|
+
)(func)
|
|
35
|
+
func = click.option(
|
|
36
|
+
"--max-attempts",
|
|
37
|
+
type=int,
|
|
38
|
+
default=20,
|
|
39
|
+
help="Maximum number of polling attempts (default: 20)",
|
|
40
|
+
)(func)
|
|
41
|
+
return func
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@click.group(invoke_without_command=True)
|
|
45
|
+
@click.version_option(package_name="pyflexweb")
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def cli(ctx):
|
|
48
|
+
"""Download IBKR Flex reports using the Interactive Brokers flex web service."""
|
|
49
|
+
db = FlexDatabase()
|
|
50
|
+
ctx.ensure_object(dict)
|
|
51
|
+
ctx.obj["db"] = db
|
|
52
|
+
|
|
53
|
+
# If no command is provided, show help text
|
|
54
|
+
if ctx.invoked_subcommand is None:
|
|
55
|
+
click.echo(ctx.get_help())
|
|
56
|
+
exit(1)
|
|
57
|
+
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Token commands
|
|
62
|
+
@cli.group()
|
|
63
|
+
@click.pass_context
|
|
64
|
+
def token(ctx):
|
|
65
|
+
"""Manage IBKR Flex token."""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@token.command("set")
|
|
70
|
+
@click.argument("token_value")
|
|
71
|
+
@click.pass_context
|
|
72
|
+
def token_set(ctx, token_value):
|
|
73
|
+
"""Set your IBKR token."""
|
|
74
|
+
args = type("Args", (), {"subcommand": "set", "token": token_value})
|
|
75
|
+
return handle_token_command(args, ctx.obj["db"])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@token.command("get")
|
|
79
|
+
@click.pass_context
|
|
80
|
+
def token_get(ctx):
|
|
81
|
+
"""Display your stored token."""
|
|
82
|
+
args = type("Args", (), {"subcommand": "get"})
|
|
83
|
+
return handle_token_command(args, ctx.obj["db"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@token.command("unset")
|
|
87
|
+
@click.pass_context
|
|
88
|
+
def token_unset(ctx):
|
|
89
|
+
"""Remove your stored token."""
|
|
90
|
+
args = type("Args", (), {"subcommand": "unset"})
|
|
91
|
+
return handle_token_command(args, ctx.obj["db"])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Query commands
|
|
95
|
+
@cli.group(invoke_without_command=True)
|
|
96
|
+
@click.pass_context
|
|
97
|
+
def query(ctx):
|
|
98
|
+
"""Manage Flex query IDs."""
|
|
99
|
+
if ctx.invoked_subcommand is None:
|
|
100
|
+
# Default to 'list' if no subcommand is provided
|
|
101
|
+
args = type("Args", (), {"subcommand": "list"})
|
|
102
|
+
return handle_query_command(args, ctx.obj["db"])
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@query.command("add")
|
|
107
|
+
@click.argument("query_id")
|
|
108
|
+
@click.option("--name", required=True, help="A descriptive name for the query")
|
|
109
|
+
@click.pass_context
|
|
110
|
+
def query_add(ctx, query_id, name):
|
|
111
|
+
"""Add a new query ID."""
|
|
112
|
+
args = type("Args", (), {"subcommand": "add", "query_id": query_id, "name": name})
|
|
113
|
+
return handle_query_command(args, ctx.obj["db"])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@query.command("remove")
|
|
117
|
+
@click.argument("query_id")
|
|
118
|
+
@click.pass_context
|
|
119
|
+
def query_remove(ctx, query_id):
|
|
120
|
+
"""Remove a query ID."""
|
|
121
|
+
args = type("Args", (), {"subcommand": "remove", "query_id": query_id})
|
|
122
|
+
return handle_query_command(args, ctx.obj["db"])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@query.command("rename")
|
|
126
|
+
@click.argument("query_id")
|
|
127
|
+
@click.option("--name", required=True, help="The new name for the query")
|
|
128
|
+
@click.pass_context
|
|
129
|
+
def query_rename(ctx, query_id, name):
|
|
130
|
+
"""Rename a query."""
|
|
131
|
+
args = type("Args", (), {"subcommand": "rename", "query_id": query_id, "name": name})
|
|
132
|
+
return handle_query_command(args, ctx.obj["db"])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@query.command("list")
|
|
136
|
+
@click.pass_context
|
|
137
|
+
def query_list(ctx):
|
|
138
|
+
"""List all stored query IDs."""
|
|
139
|
+
args = type("Args", (), {"subcommand": "list"})
|
|
140
|
+
return handle_query_command(args, ctx.obj["db"])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Report request command
|
|
144
|
+
@cli.command("request")
|
|
145
|
+
@click.argument("query_id")
|
|
146
|
+
@click.pass_context
|
|
147
|
+
def request(ctx, query_id):
|
|
148
|
+
"""Request a Flex report."""
|
|
149
|
+
args = type("Args", (), {"query_id": query_id})
|
|
150
|
+
return handle_request_command(args, ctx.obj["db"])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Report fetch command
|
|
154
|
+
@cli.command("fetch")
|
|
155
|
+
@click.argument("request_id")
|
|
156
|
+
@common_options
|
|
157
|
+
@click.pass_context
|
|
158
|
+
def fetch(ctx, request_id, output, output_dir, poll_interval, max_attempts):
|
|
159
|
+
"""Fetch a requested report."""
|
|
160
|
+
args = type(
|
|
161
|
+
"Args",
|
|
162
|
+
(),
|
|
163
|
+
{
|
|
164
|
+
"request_id": request_id,
|
|
165
|
+
"output": output,
|
|
166
|
+
"output_dir": output_dir,
|
|
167
|
+
"poll_interval": poll_interval,
|
|
168
|
+
"max_attempts": max_attempts,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
return handle_fetch_command(args, ctx.obj["db"])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# All-in-one download command
|
|
175
|
+
@cli.command("download")
|
|
176
|
+
@click.option("--query", default="all", help="The query ID to download a report for (default: all)")
|
|
177
|
+
@click.option("--force", is_flag=True, help="Force download even if report was already downloaded today")
|
|
178
|
+
@common_options
|
|
179
|
+
@click.pass_context
|
|
180
|
+
def download(ctx, query, force, output, output_dir, poll_interval, max_attempts):
|
|
181
|
+
"""Request and download a report in one step.
|
|
182
|
+
|
|
183
|
+
If --query is not specified, downloads all queries not updated in 24 hours.
|
|
184
|
+
"""
|
|
185
|
+
args = type(
|
|
186
|
+
"Args",
|
|
187
|
+
(),
|
|
188
|
+
{
|
|
189
|
+
"query": query,
|
|
190
|
+
"force": force,
|
|
191
|
+
"output": output,
|
|
192
|
+
"output_dir": output_dir,
|
|
193
|
+
"poll_interval": poll_interval,
|
|
194
|
+
"max_attempts": max_attempts,
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
return handle_download_command(args, ctx.obj["db"])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main():
|
|
201
|
+
"""Main entry point for the CLI."""
|
|
202
|
+
try:
|
|
203
|
+
sys.exit(cli()) # pylint: disable=no-value-for-parameter
|
|
204
|
+
except Exception as e: # pylint: disable=broad-except
|
|
205
|
+
click.echo(f"Error: {e}", err=True)
|
|
206
|
+
return 1
|
|
207
|
+
finally:
|
|
208
|
+
# No need to close db here as it's managed within the cli context
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
sys.exit(main())
|
pyflexweb/client.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Client module for communicating with IBKR Flex Web Service."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IBKRFlexClient:
|
|
10
|
+
"""Handles communication with the IBKR Flex Web Service."""
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://gdcdyn.interactivebrokers.com/Universal/servlet"
|
|
13
|
+
REQUEST_URL = f"{BASE_URL}/FlexStatementService.SendRequest"
|
|
14
|
+
STATEMENT_URL = f"{BASE_URL}/FlexStatementService.GetStatement"
|
|
15
|
+
|
|
16
|
+
def __init__(self, token: str):
|
|
17
|
+
self.token = token
|
|
18
|
+
|
|
19
|
+
def request_report(self, query_id: str) -> str | None:
|
|
20
|
+
"""Request a report from IBKR and return the request ID if successful."""
|
|
21
|
+
url = f"{self.REQUEST_URL}?t={self.token}&q={query_id}&v=3"
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
response = requests.get(url)
|
|
25
|
+
response.raise_for_status()
|
|
26
|
+
|
|
27
|
+
# Parse the XML response
|
|
28
|
+
root = ET.fromstring(response.text)
|
|
29
|
+
status = root.find(".//Status").text
|
|
30
|
+
|
|
31
|
+
if status == "Success":
|
|
32
|
+
request_id = root.find(".//ReferenceCode").text
|
|
33
|
+
return request_id
|
|
34
|
+
else:
|
|
35
|
+
error = root.find(".//ErrorMessage").text
|
|
36
|
+
print(f"Error requesting report: {error}", file=sys.stderr)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
except requests.exceptions.RequestException as e:
|
|
40
|
+
print(f"Network error: {e}", file=sys.stderr)
|
|
41
|
+
return None
|
|
42
|
+
except ET.ParseError as e:
|
|
43
|
+
print(f"Error parsing response: {e}", file=sys.stderr)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def get_report(self, request_id: str) -> str | None:
|
|
47
|
+
"""Get a report using the request ID. Returns the XML content if successful."""
|
|
48
|
+
url = f"{self.STATEMENT_URL}?t={self.token}&q={request_id}&v=3"
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
response = requests.get(url)
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
|
|
54
|
+
# Check if this is an error response
|
|
55
|
+
if "<ErrorCode>" in response.text:
|
|
56
|
+
root = ET.fromstring(response.text)
|
|
57
|
+
status = root.find(".//Status").text
|
|
58
|
+
|
|
59
|
+
if status == "Pending":
|
|
60
|
+
return None # Report not ready yet
|
|
61
|
+
|
|
62
|
+
error = root.find(".//ErrorMessage")
|
|
63
|
+
if error is not None:
|
|
64
|
+
print(f"Error retrieving report: {error.text}", file=sys.stderr)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# If we got here, we have the actual report
|
|
68
|
+
return response.text
|
|
69
|
+
|
|
70
|
+
except requests.exceptions.RequestException as e:
|
|
71
|
+
print(f"Network error: {e}", file=sys.stderr)
|
|
72
|
+
return None
|
|
73
|
+
except ET.ParseError as e:
|
|
74
|
+
print(f"Error parsing response: {e}", file=sys.stderr)
|
|
75
|
+
return None
|
pyflexweb/database.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Database module for storing tokens, queries, and request history."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
import platformdirs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlexDatabase:
|
|
11
|
+
"""Manages the local database for tokens, queries, and request history."""
|
|
12
|
+
|
|
13
|
+
DB_VERSION = 2 # Increment when schema changes
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.db_dir = platformdirs.user_data_dir("pyflexweb")
|
|
17
|
+
os.makedirs(self.db_dir, exist_ok=True)
|
|
18
|
+
self.db_path = os.path.join(self.db_dir, "status.db")
|
|
19
|
+
self.conn = self._init_db()
|
|
20
|
+
|
|
21
|
+
def get_db_path(self) -> str:
|
|
22
|
+
"""Return the path to the database file."""
|
|
23
|
+
return self.db_path
|
|
24
|
+
|
|
25
|
+
def _init_db(self) -> sqlite3.Connection:
|
|
26
|
+
conn = sqlite3.connect(self.db_path)
|
|
27
|
+
cursor = conn.cursor()
|
|
28
|
+
|
|
29
|
+
# Create tables if they don't exist
|
|
30
|
+
cursor.execute(
|
|
31
|
+
"""
|
|
32
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
33
|
+
key TEXT PRIMARY KEY,
|
|
34
|
+
value TEXT NOT NULL
|
|
35
|
+
)
|
|
36
|
+
"""
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
cursor.execute(
|
|
40
|
+
"""
|
|
41
|
+
CREATE TABLE IF NOT EXISTS queries (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
name TEXT,
|
|
44
|
+
added_on DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
45
|
+
)
|
|
46
|
+
"""
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
cursor.execute(
|
|
50
|
+
"""
|
|
51
|
+
CREATE TABLE IF NOT EXISTS requests (
|
|
52
|
+
request_id TEXT PRIMARY KEY,
|
|
53
|
+
query_id TEXT,
|
|
54
|
+
status TEXT,
|
|
55
|
+
requested_at DATETIME,
|
|
56
|
+
completed_at DATETIME,
|
|
57
|
+
last_updated DATETIME,
|
|
58
|
+
output_path TEXT,
|
|
59
|
+
FOREIGN KEY (query_id) REFERENCES queries(id)
|
|
60
|
+
)
|
|
61
|
+
"""
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Check if migration needed
|
|
65
|
+
self._check_migration(conn)
|
|
66
|
+
|
|
67
|
+
return conn
|
|
68
|
+
|
|
69
|
+
def _check_migration(self, conn: sqlite3.Connection) -> None:
|
|
70
|
+
"""Check if database needs migration and perform if needed."""
|
|
71
|
+
cursor = conn.cursor()
|
|
72
|
+
|
|
73
|
+
# Get current database version
|
|
74
|
+
cursor.execute("SELECT value FROM config WHERE key = 'db_version' LIMIT 1")
|
|
75
|
+
result = cursor.fetchone()
|
|
76
|
+
current_version = int(result[0]) if result else 0
|
|
77
|
+
|
|
78
|
+
# If already at current version, no migration needed
|
|
79
|
+
if current_version >= self.DB_VERSION:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# Perform migrations
|
|
83
|
+
if current_version < 1:
|
|
84
|
+
# Add last_updated column to requests table
|
|
85
|
+
try:
|
|
86
|
+
cursor.execute("ALTER TABLE requests ADD COLUMN last_updated DATETIME")
|
|
87
|
+
conn.commit()
|
|
88
|
+
except sqlite3.OperationalError:
|
|
89
|
+
# Column might already exist
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
if current_version < 2:
|
|
93
|
+
# Remove report_type column from queries table
|
|
94
|
+
# First get all existing queries
|
|
95
|
+
cursor.execute("PRAGMA table_info(queries)")
|
|
96
|
+
columns = cursor.fetchall()
|
|
97
|
+
has_report_type = any(col[1] == "report_type" for col in columns)
|
|
98
|
+
|
|
99
|
+
if has_report_type:
|
|
100
|
+
# Get all existing data
|
|
101
|
+
cursor.execute("SELECT id, name FROM queries")
|
|
102
|
+
queries = cursor.fetchall()
|
|
103
|
+
|
|
104
|
+
# Drop and recreate the table
|
|
105
|
+
cursor.execute("DROP TABLE queries")
|
|
106
|
+
cursor.execute(
|
|
107
|
+
"""
|
|
108
|
+
CREATE TABLE queries (
|
|
109
|
+
id TEXT PRIMARY KEY,
|
|
110
|
+
name TEXT,
|
|
111
|
+
added_on DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
112
|
+
)
|
|
113
|
+
"""
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Reinsert the data
|
|
117
|
+
for query_id, name in queries:
|
|
118
|
+
cursor.execute("INSERT INTO queries (id, name) VALUES (?, ?)", (query_id, name))
|
|
119
|
+
|
|
120
|
+
# Update the version
|
|
121
|
+
cursor.execute(
|
|
122
|
+
"INSERT OR REPLACE INTO config VALUES (?, ?)",
|
|
123
|
+
("db_version", str(self.DB_VERSION)),
|
|
124
|
+
)
|
|
125
|
+
conn.commit()
|
|
126
|
+
|
|
127
|
+
def set_token(self, token: str) -> None:
|
|
128
|
+
cursor = self.conn.cursor()
|
|
129
|
+
cursor.execute("INSERT OR REPLACE INTO config VALUES (?, ?)", ("token", token))
|
|
130
|
+
self.conn.commit()
|
|
131
|
+
|
|
132
|
+
def get_token(self) -> str | None:
|
|
133
|
+
cursor = self.conn.cursor()
|
|
134
|
+
cursor.execute("SELECT value FROM config WHERE key = ?", ("token",))
|
|
135
|
+
result = cursor.fetchone()
|
|
136
|
+
return result[0] if result else None
|
|
137
|
+
|
|
138
|
+
def unset_token(self) -> None:
|
|
139
|
+
cursor = self.conn.cursor()
|
|
140
|
+
cursor.execute("DELETE FROM config WHERE key = ?", ("token",))
|
|
141
|
+
self.conn.commit()
|
|
142
|
+
|
|
143
|
+
def add_query(self, query_id: str, name: str) -> None:
|
|
144
|
+
cursor = self.conn.cursor()
|
|
145
|
+
cursor.execute("INSERT OR REPLACE INTO queries (id, name) VALUES (?, ?)", (query_id, name))
|
|
146
|
+
self.conn.commit()
|
|
147
|
+
|
|
148
|
+
def remove_query(self, query_id: str) -> bool:
|
|
149
|
+
cursor = self.conn.cursor()
|
|
150
|
+
cursor.execute("DELETE FROM queries WHERE id = ?", (query_id,))
|
|
151
|
+
self.conn.commit()
|
|
152
|
+
return cursor.rowcount > 0
|
|
153
|
+
|
|
154
|
+
def rename_query(self, query_id: str, new_name: str) -> bool:
|
|
155
|
+
cursor = self.conn.cursor()
|
|
156
|
+
cursor.execute("UPDATE queries SET name = ? WHERE id = ?", (new_name, query_id))
|
|
157
|
+
self.conn.commit()
|
|
158
|
+
return cursor.rowcount > 0
|
|
159
|
+
|
|
160
|
+
def list_queries(self) -> list[tuple[str, str]]:
|
|
161
|
+
cursor = self.conn.cursor()
|
|
162
|
+
cursor.execute("SELECT id, name FROM queries ORDER BY added_on")
|
|
163
|
+
return cursor.fetchall()
|
|
164
|
+
|
|
165
|
+
def add_request(self, request_id: str, query_id: str) -> None:
|
|
166
|
+
cursor = self.conn.cursor()
|
|
167
|
+
cursor.execute(
|
|
168
|
+
"INSERT INTO requests (request_id, query_id, status, requested_at) VALUES (?, ?, ?, ?)",
|
|
169
|
+
(request_id, query_id, "pending", datetime.now().isoformat()),
|
|
170
|
+
)
|
|
171
|
+
self.conn.commit()
|
|
172
|
+
|
|
173
|
+
def update_request_status(self, request_id: str, status: str, output_path: str | None = None) -> None:
|
|
174
|
+
cursor = self.conn.cursor()
|
|
175
|
+
now = datetime.now().isoformat()
|
|
176
|
+
|
|
177
|
+
if status == "completed":
|
|
178
|
+
cursor.execute(
|
|
179
|
+
"UPDATE requests SET status = ?, completed_at = ?, output_path = ?, last_updated = ? WHERE request_id = ?",
|
|
180
|
+
(status, now, output_path, now, request_id),
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
cursor.execute(
|
|
184
|
+
"UPDATE requests SET status = ?, last_updated = ? WHERE request_id = ?",
|
|
185
|
+
(status, now, request_id),
|
|
186
|
+
)
|
|
187
|
+
self.conn.commit()
|
|
188
|
+
|
|
189
|
+
def get_request_info(self, request_id: str) -> dict | None:
|
|
190
|
+
cursor = self.conn.cursor()
|
|
191
|
+
cursor.execute(
|
|
192
|
+
"SELECT request_id, query_id, status, requested_at, completed_at, output_path FROM requests WHERE request_id = ?",
|
|
193
|
+
(request_id,),
|
|
194
|
+
)
|
|
195
|
+
result = cursor.fetchone()
|
|
196
|
+
if not result:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
"request_id": result[0],
|
|
201
|
+
"query_id": result[1],
|
|
202
|
+
"status": result[2],
|
|
203
|
+
"requested_at": result[3],
|
|
204
|
+
"completed_at": result[4],
|
|
205
|
+
"output_path": result[5],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def get_query_info(self, query_id: str) -> dict | None:
|
|
209
|
+
cursor = self.conn.cursor()
|
|
210
|
+
cursor.execute("SELECT id, name FROM queries WHERE id = ?", (query_id,))
|
|
211
|
+
result = cursor.fetchone()
|
|
212
|
+
if not result:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
return {"id": result[0], "name": result[1]}
|
|
216
|
+
|
|
217
|
+
def get_latest_request(self, query_id: str) -> dict | None:
|
|
218
|
+
cursor = self.conn.cursor()
|
|
219
|
+
cursor.execute(
|
|
220
|
+
"SELECT request_id FROM requests WHERE query_id = ? ORDER BY requested_at DESC LIMIT 1",
|
|
221
|
+
(query_id,),
|
|
222
|
+
)
|
|
223
|
+
result = cursor.fetchone()
|
|
224
|
+
if not result:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
return self.get_request_info(result[0])
|
|
228
|
+
|
|
229
|
+
def get_queries_not_updated(self, hours: int = 24) -> list[dict]:
|
|
230
|
+
"""Get queries that haven't been updated in the specified hours."""
|
|
231
|
+
cursor = self.conn.cursor()
|
|
232
|
+
cutoff_time = (datetime.now() - timedelta(hours=hours)).isoformat()
|
|
233
|
+
|
|
234
|
+
# Get all queries
|
|
235
|
+
cursor.execute("SELECT id, name FROM queries")
|
|
236
|
+
all_queries = cursor.fetchall()
|
|
237
|
+
|
|
238
|
+
result = []
|
|
239
|
+
for query_id, name in all_queries:
|
|
240
|
+
# Check if this query has a successful request within the time period
|
|
241
|
+
cursor.execute(
|
|
242
|
+
"""
|
|
243
|
+
SELECT r.request_id FROM requests r
|
|
244
|
+
WHERE r.query_id = ?
|
|
245
|
+
AND r.status = 'completed'
|
|
246
|
+
AND (r.last_updated > ? OR r.completed_at > ?)
|
|
247
|
+
ORDER BY r.last_updated DESC
|
|
248
|
+
LIMIT 1
|
|
249
|
+
""",
|
|
250
|
+
(query_id, cutoff_time, cutoff_time),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
recent_request = cursor.fetchone()
|
|
254
|
+
|
|
255
|
+
if not recent_request:
|
|
256
|
+
# No recent successful request, include this query
|
|
257
|
+
result.append({"id": query_id, "name": name})
|
|
258
|
+
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
def get_query_with_last_request(self, query_id: str) -> dict | None:
|
|
262
|
+
"""Get query info along with its latest request."""
|
|
263
|
+
query_info = self.get_query_info(query_id)
|
|
264
|
+
if not query_info:
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
latest_request = self.get_latest_request(query_id)
|
|
268
|
+
if latest_request:
|
|
269
|
+
query_info["latest_request"] = latest_request
|
|
270
|
+
|
|
271
|
+
return query_info
|
|
272
|
+
|
|
273
|
+
def get_all_queries_with_status(self) -> list[dict]:
|
|
274
|
+
"""Get all queries with their latest request status."""
|
|
275
|
+
cursor = self.conn.cursor()
|
|
276
|
+
cursor.execute("SELECT id, name FROM queries ORDER BY added_on")
|
|
277
|
+
queries = cursor.fetchall()
|
|
278
|
+
|
|
279
|
+
result = []
|
|
280
|
+
for query_id, name in queries:
|
|
281
|
+
query_info = {"id": query_id, "name": name, "latest_request": None}
|
|
282
|
+
|
|
283
|
+
# Get latest request for this query
|
|
284
|
+
latest_request = self.get_latest_request(query_id)
|
|
285
|
+
if latest_request:
|
|
286
|
+
query_info["latest_request"] = latest_request
|
|
287
|
+
|
|
288
|
+
result.append(query_info)
|
|
289
|
+
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
def close(self) -> None:
|
|
293
|
+
self.conn.close()
|