Skip to content

Commit 03f2e0d

Browse files
authored
grzctl db tui: add --quarter/--year selection for “Detailed QC by LE” overview (closes #502) (#541)
## Summary Adds optional `--quarter` and `--year` flags to `grzctl db tui` to control which quarter/year is used for the **“Detailed QC by LE”** overview panel. ## Behavior - If `--quarter/--year` are **provided**: the “Detailed QC by LE” panel shows the counts/ratio for that selected quarter/year. - If **omitted**: the panel defaults to the current quarter/year (as before). - The other overview panels (**Submission count by state**, **Submission count by consent**) remain **all-time totals** (unchanged). ## Why Makes it easier to inspect detailed QC coverage for a specific reporting quarter directly in the TUI, without changing the existing default overview behavior. Closes #502.
1 parent f5a5d69 commit 03f2e0d

3 files changed

Lines changed: 148 additions & 16 deletions

File tree

packages/grzctl/src/grzctl/commands/db/cli.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,19 @@ def list_change_requests(ctx: click.Context, output_json: bool = False):
326326

327327
@db.command("tui")
328328
@click.pass_context
329-
def tui(ctx: click.Context):
329+
@click.option(
330+
"--quarter",
331+
type=click.IntRange(min=1, max=4),
332+
default=None,
333+
help="Quarter (1-4) for the 'Detailed QC by LE' overview panel (default: current quarter).",
334+
)
335+
@click.option(
336+
"--year",
337+
type=click.IntRange(min=2024, max=9999),
338+
default=None,
339+
help="Year for the selected --quarter in the 'Detailed QC by LE' overview panel (default: current year).",
340+
)
341+
def tui(ctx: click.Context, quarter: int | None, year: int | None):
330342
"""Starts the interactive terminal user interface to the database."""
331343
db_url = ctx.obj["db_url"]
332344
public_keys = ctx.obj["public_keys"]
@@ -344,7 +356,7 @@ def tui(ctx: click.Context):
344356
textual_handler.setFormatter(logging.Formatter(fmt=LOGGING_FORMAT, datefmt=LOGGING_DATEFMT))
345357
root_logger.addHandler(textual_handler)
346358

347-
app = DatabaseBrowser(database=database, public_keys=public_keys)
359+
app = DatabaseBrowser(database=database, public_keys=public_keys, quarter=quarter, year=year)
348360
app.run()
349361

350362

packages/grzctl/src/grzctl/commands/db/tui.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import calendar
21
import datetime
32
import itertools
43
import logging
@@ -11,6 +10,7 @@
1110
import textual
1211
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
1312
from grz_db.models.submission import Submission, SubmissionDb, SubmissionStateLog
13+
from grz_pydantic_models.dates import date_to_quarter_year, quarter_date_bounds
1414
from grz_pydantic_models.submission.metadata import SubmissionType
1515
from sqlalchemy import func as sqlfn
1616
from sqlalchemy.orm import selectinload
@@ -27,6 +27,13 @@
2727
_DEFAULT_SEARCH_LIMIT = 20
2828

2929

30+
def _resolve_quarter_year(today: datetime.date, quarter: int | None, year: int | None) -> tuple[int, int]:
31+
default_quarter, default_year = date_to_quarter_year(today)
32+
resolved_quarter = quarter if quarter is not None else default_quarter
33+
resolved_year = year if year is not None else default_year
34+
return resolved_quarter, resolved_year
35+
36+
3037
class SubmissionCountByStateTable(Static):
3138
def on_mount(self) -> None:
3239
self.loading = True
@@ -108,7 +115,7 @@ def on_mount(self) -> None:
108115
self.styles.border = ("round", self.app.theme_variables["foreground"])
109116

110117
@textual.work
111-
async def load(self, database: SubmissionDb) -> None:
118+
async def load(self, database: SubmissionDb, quarter: int | None = None, year: int | None = None) -> None:
112119
self.loading = True
113120
with database._get_session() as session:
114121
statement = (
@@ -121,13 +128,8 @@ async def load(self, database: SubmissionDb) -> None:
121128
submission_qc_states = session.exec(statement).all()
122129

123130
today = datetime.date.today()
124-
quarter = ((today.month - 1) // 3) + 1
125-
quarter_start_date = datetime.date(year=today.year, month=((quarter - 1) * 3) + 1, day=1)
126-
quarter_end_month = quarter_start_date.month + 2
127-
_quarter_end_month_first_weekday, days_in_quarter_end_month = calendar.monthrange(
128-
quarter_start_date.year, quarter_end_month
129-
)
130-
quarter_end_date = quarter_start_date.replace(month=quarter_end_month, day=days_in_quarter_end_month)
131+
resolved_quarter, resolved_year = _resolve_quarter_year(today, quarter, year)
132+
quarter_start_date, quarter_end_date = quarter_date_bounds(year=resolved_year, quarter=resolved_quarter)
131133
logger.debug("Quarter: %s to %s", quarter_start_date, quarter_end_date)
132134
rows = []
133135
for submitter_id, group in itertools.groupby(
@@ -142,10 +144,10 @@ async def load(self, database: SubmissionDb) -> None:
142144
for _, submission_date, detailed_qc_passed in group:
143145
if detailed_qc_passed is not None:
144146
qced += 1
145-
# current quarter
147+
# resolved quarter
146148
if (
147149
(submission_date is not None)
148-
and (submission_date.year == today.year)
150+
and (submission_date.year == resolved_year)
149151
and (quarter_start_date <= submission_date <= quarter_end_date)
150152
):
151153
if detailed_qc_passed is not None:
@@ -162,7 +164,7 @@ async def load(self, database: SubmissionDb) -> None:
162164
table = rich.table.Table(
163165
rich.table.Column(header="Submitter", justify="center"),
164166
rich.table.Column(header="Total", justify="center"),
165-
rich.table.Column(header=f"Q{quarter} {today.year}", justify="center"),
167+
rich.table.Column(header=f"Q{resolved_quarter} {resolved_year}", justify="center"),
166168
rich.table.Column(header=today.strftime("%B"), justify="center"),
167169
)
168170
for submitter_id, qced, total, qced_quarter, total_quarter, qced_month, total_month in rows:
@@ -265,10 +267,19 @@ class DatabaseBrowser(App):
265267
]
266268
CSS_PATH = "tui.css"
267269

268-
def __init__(self, database: SubmissionDb, public_keys: dict[str, Ed25519PublicKey], **kwargs) -> None:
270+
def __init__(
271+
self,
272+
database: SubmissionDb,
273+
public_keys: dict[str, Ed25519PublicKey],
274+
quarter: int | None = None,
275+
year: int | None = None,
276+
**kwargs,
277+
) -> None:
269278
super().__init__(**kwargs)
270279
self._database = database
271280
self._public_keys = public_keys
281+
self._quarter = quarter
282+
self._year = year
272283

273284
def compose(self) -> ComposeResult:
274285
with TabbedContent():
@@ -320,7 +331,9 @@ def on_mount(self) -> None:
320331
async def _refresh_overview(self) -> None:
321332
self.query_exactly_one(SubmissionCountByStateTable).load(self._database)
322333
self.query_exactly_one(SubmissionCountByConsentTable).load(self._database)
323-
self.query_exactly_one(SubmissionCountByDetailedQCByLETable).load(self._database)
334+
self.query_exactly_one(SubmissionCountByDetailedQCByLETable).load(
335+
self._database, quarter=self._quarter, year=self._year
336+
)
324337
table_states_latest = self.query_exactly_one("#table-states-latest", DataTable)
325338
table_states_latest.loading = True
326339
with self._database._get_session() as session:
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import datetime
2+
import logging
3+
4+
import click.testing
5+
import grzctl.cli
6+
import grzctl.commands.db.tui as db_tui
7+
import pytest
8+
9+
10+
@pytest.fixture
11+
def captured_browser_args(monkeypatch):
12+
captured: dict[str, object] = {}
13+
14+
class DummyBrowser:
15+
def __init__(self, *, database, public_keys, quarter=None, year=None, **kwargs):
16+
captured["quarter"] = quarter
17+
captured["year"] = year
18+
19+
def run(self):
20+
captured["ran"] = True
21+
22+
import grzctl.commands.db.cli as db_cli
23+
24+
monkeypatch.setattr(db_cli, "DatabaseBrowser", DummyBrowser)
25+
return captured
26+
27+
28+
def test_db_tui_passes_quarter_year(captured_browser_args, blank_database_config_path):
29+
root_logger = logging.getLogger()
30+
old_handlers = list(root_logger.handlers)
31+
try:
32+
runner = click.testing.CliRunner(catch_exceptions=False)
33+
cli = grzctl.cli.build_cli()
34+
result = runner.invoke(
35+
cli,
36+
[
37+
"db",
38+
"--config-file",
39+
str(blank_database_config_path),
40+
"tui",
41+
"--quarter",
42+
"2",
43+
"--year",
44+
"2025",
45+
],
46+
)
47+
assert result.exit_code == 0, result.stderr
48+
assert captured_browser_args["quarter"] == 2
49+
assert captured_browser_args["year"] == 2025
50+
assert captured_browser_args.get("ran") is True
51+
finally:
52+
root_logger.handlers[:] = old_handlers
53+
54+
55+
def test_db_tui_passes_none_when_quarter_year_omitted(captured_browser_args, blank_database_config_path):
56+
root_logger = logging.getLogger()
57+
old_handlers = list(root_logger.handlers)
58+
try:
59+
runner = click.testing.CliRunner(catch_exceptions=False)
60+
cli = grzctl.cli.build_cli()
61+
result = runner.invoke(
62+
cli,
63+
[
64+
"db",
65+
"--config-file",
66+
str(blank_database_config_path),
67+
"tui",
68+
],
69+
)
70+
assert result.exit_code == 0, result.stderr
71+
assert captured_browser_args["quarter"] is None
72+
assert captured_browser_args["year"] is None
73+
assert captured_browser_args.get("ran") is True
74+
finally:
75+
root_logger.handlers[:] = old_handlers
76+
77+
78+
def test_resolve_quarter_year_defaults_to_current_when_omitted(monkeypatch):
79+
def fake_date_to_quarter_year(_today):
80+
return (3, 2026)
81+
82+
monkeypatch.setattr(db_tui, "date_to_quarter_year", fake_date_to_quarter_year)
83+
84+
resolved_quarter, resolved_year = db_tui._resolve_quarter_year(
85+
datetime.date(2026, 8, 12),
86+
quarter=None,
87+
year=None,
88+
)
89+
90+
assert resolved_quarter == 3
91+
assert resolved_year == 2026
92+
93+
94+
def test_resolve_quarter_year_prefers_explicit_values(monkeypatch):
95+
def fake_date_to_quarter_year(_today):
96+
return (3, 2026)
97+
98+
monkeypatch.setattr(db_tui, "date_to_quarter_year", fake_date_to_quarter_year)
99+
100+
resolved_quarter, resolved_year = db_tui._resolve_quarter_year(
101+
datetime.date(2026, 8, 12),
102+
quarter=2,
103+
year=2025,
104+
)
105+
106+
assert resolved_quarter == 2
107+
assert resolved_year == 2025

0 commit comments

Comments
 (0)