sud-cli 0.1.1__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.
- sud_cli/__init__.py +3 -0
- sud_cli/__main__.py +4 -0
- sud_cli/_wm.py +118 -0
- sud_cli/agenda.py +90 -0
- sud_cli/autofill.py +192 -0
- sud_cli/autofill_markers.json +56 -0
- sud_cli/backup.py +256 -0
- sud_cli/calendar_ics.py +191 -0
- sud_cli/case_diff.py +165 -0
- sud_cli/case_search_bridge.py +1322 -0
- sud_cli/chain.py +226 -0
- sud_cli/cli.py +3721 -0
- sud_cli/completeness.py +105 -0
- sud_cli/config.py +20 -0
- sud_cli/conflict.py +210 -0
- sud_cli/conflicts.py +207 -0
- sud_cli/court_alerts.py +156 -0
- sud_cli/court_source.py +108 -0
- sud_cli/data/completeness_rules.json +18 -0
- sud_cli/data/key_rates.json +59 -0
- sud_cli/data/npa_historical_editions.json +19 -0
- sud_cli/db.py +2072 -0
- sud_cli/deadline_matrix.py +99 -0
- sud_cli/deadline_state.py +25 -0
- sud_cli/deadlines.py +8 -0
- sud_cli/deadlines_state.py +1 -0
- sud_cli/deferral.py +5 -0
- sud_cli/efrsb_manual.py +85 -0
- sud_cli/efrsb_public.py +210 -0
- sud_cli/egrul_nalog.py +150 -0
- sud_cli/explain.py +190 -0
- sud_cli/fees_extra.py +104 -0
- sud_cli/forms.py +204 -0
- sud_cli/fssp_manual.py +91 -0
- sud_cli/fssp_semi.py +285 -0
- sud_cli/fssp_stats.py +105 -0
- sud_cli/government_transfers.json +8 -0
- sud_cli/holidays_rf.json +62 -0
- sud_cli/idempotency.py +78 -0
- sud_cli/importing.py +575 -0
- sud_cli/interest.py +144 -0
- sud_cli/jurisdiction.py +177 -0
- sud_cli/jurisdiction_rules.json +265 -0
- sud_cli/legal_deadlines.py +278 -0
- sud_cli/legal_engine.py +558 -0
- sud_cli/legal_rules.json +1058 -0
- sud_cli/limitation.py +70 -0
- sud_cli/mcp_server.py +1594 -0
- sud_cli/metrics.py +142 -0
- sud_cli/npa_as_of.py +21 -0
- sud_cli/output.py +89 -0
- sud_cli/payment.py +24 -0
- sud_cli/pdn.py +259 -0
- sud_cli/plenum_corpus.py +134 -0
- sud_cli/plenums_seed.json +45 -0
- sud_cli/practice_status.py +136 -0
- sud_cli/pravo_publication.py +361 -0
- sud_cli/pretrial.py +151 -0
- sud_cli/production_calendar.py +233 -0
- sud_cli/providers.py +95 -0
- sud_cli/readiness.py +234 -0
- sud_cli/regional_holidays.py +37 -0
- sud_cli/reminders.py +92 -0
- sud_cli/reports.py +349 -0
- sud_cli/sud_netguard.py +943 -0
- sud_cli/timeline_audit.py +80 -0
- sud_cli/verify.py +201 -0
- sud_cli-0.1.1.dist-info/METADATA +119 -0
- sud_cli-0.1.1.dist-info/RECORD +74 -0
- sud_cli-0.1.1.dist-info/WHEEL +4 -0
- sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
- sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
- sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
sud_cli/backup.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""история §5 (S-85): backup / verify / restore snapshot'а store.db.
|
|
2
|
+
|
|
3
|
+
sqlite stdlib, без внешних процессов:
|
|
4
|
+
- create — ``sqlite3.Connection.backup`` (горячий снимок, не cp под WAL),
|
|
5
|
+
файл 0600 + sha256 sidecar; путь снимка пишется в ``meta`` (S-86 читает
|
|
6
|
+
«last backup» оттуда, а не из воздуха);
|
|
7
|
+
- verify — PRAGMA integrity_check + quick_check + список таблиц схемы;
|
|
8
|
+
sidecar сверяется, если лежит рядом;
|
|
9
|
+
- restore — деструктив: без явного подтверждения не делает ничего, превью
|
|
10
|
+
показывает, что будет перезаписано (путь, размер, таблица->кол-во строк
|
|
11
|
+
текущей БД). Живой state в тестах не трогается: вызывающий передаёт tmp-dir.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import sqlite3
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
from .db import _db_path, get_connection, init_db, set_meta
|
|
23
|
+
|
|
24
|
+
BACKUP_PREFIX = "store-backup-"
|
|
25
|
+
SIDECAR_SUFFIX = ".sha256"
|
|
26
|
+
META_LAST_BACKUP_KEY = "last_backup_path"
|
|
27
|
+
META_LAST_BACKUP_SHA_KEY = "last_backup_sha256"
|
|
28
|
+
|
|
29
|
+
RESTORE_DISCLAIMER = (
|
|
30
|
+
"restore перезаписывает store.db state-dir целиком: всё, что было создано "
|
|
31
|
+
"после снимка, теряется. Подтверждение --yes обязательно."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _now_stamp() -> str:
|
|
36
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sha256_file(path: Path) -> str:
|
|
40
|
+
h = hashlib.sha256()
|
|
41
|
+
with path.open("rb") as f:
|
|
42
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
43
|
+
h.update(chunk)
|
|
44
|
+
return h.hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_backup_dir(state_dir: Any) -> Path:
|
|
48
|
+
return Path(state_dir) / "backups"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def snapshot_tables(path: Path) -> Dict[str, int]:
|
|
52
|
+
"""таблица -> число строк (только пользовательские таблицы, без sqlite_INTERNAL)."""
|
|
53
|
+
conn = sqlite3.connect(str(path))
|
|
54
|
+
try:
|
|
55
|
+
rows = conn.execute(
|
|
56
|
+
"SELECT name FROM sqlite_master WHERE type='table' "
|
|
57
|
+
"AND name NOT LIKE 'sqlite_%' ORDER BY name ASC"
|
|
58
|
+
).fetchall()
|
|
59
|
+
out = {}
|
|
60
|
+
for (name,) in rows:
|
|
61
|
+
out[name] = int(conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0])
|
|
62
|
+
return out
|
|
63
|
+
finally:
|
|
64
|
+
conn.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_backup(state_dir: Any, out: Optional[Path] = None) -> Dict[str, Any]:
|
|
68
|
+
"""Горячий снимок store.db -> timestamped файл 0600 + sha256 sidecar."""
|
|
69
|
+
state_dir = Path(state_dir)
|
|
70
|
+
init_db(state_dir)
|
|
71
|
+
src_path = _db_path(state_dir)
|
|
72
|
+
if not src_path.is_file():
|
|
73
|
+
raise FileNotFoundError(f"store.db не найден: {src_path}")
|
|
74
|
+
|
|
75
|
+
if out is None:
|
|
76
|
+
dest_dir = default_backup_dir(state_dir)
|
|
77
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
target = dest_dir / f"{BACKUP_PREFIX}{_now_stamp()}.db"
|
|
79
|
+
else:
|
|
80
|
+
target = Path(out)
|
|
81
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
src = sqlite3.connect(str(src_path))
|
|
84
|
+
try:
|
|
85
|
+
dst = sqlite3.connect(str(target))
|
|
86
|
+
try:
|
|
87
|
+
with dst:
|
|
88
|
+
src.backup(dst)
|
|
89
|
+
finally:
|
|
90
|
+
dst.close()
|
|
91
|
+
finally:
|
|
92
|
+
src.close()
|
|
93
|
+
|
|
94
|
+
# 0600 до того, как появится sidecar: снимок содержит ПДн дела.
|
|
95
|
+
os.chmod(target, 0o600)
|
|
96
|
+
digest = _sha256_file(target)
|
|
97
|
+
sidecar = target.with_name(target.name + SIDECAR_SUFFIX)
|
|
98
|
+
sidecar.write_text(f"{digest} {target.name}\n", encoding="utf-8")
|
|
99
|
+
os.chmod(sidecar, 0o600)
|
|
100
|
+
|
|
101
|
+
conn = get_connection(state_dir)
|
|
102
|
+
try:
|
|
103
|
+
set_meta(conn, META_LAST_BACKUP_KEY, str(target))
|
|
104
|
+
set_meta(conn, META_LAST_BACKUP_SHA_KEY, digest)
|
|
105
|
+
finally:
|
|
106
|
+
conn.close()
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"path": str(target),
|
|
110
|
+
"sidecar": str(sidecar),
|
|
111
|
+
"sha256": digest,
|
|
112
|
+
"bytes": target.stat().st_size,
|
|
113
|
+
"mode": oct(0o600),
|
|
114
|
+
"tables": snapshot_tables(target),
|
|
115
|
+
"source": str(src_path),
|
|
116
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_sidecar(backup: Path) -> Optional[str]:
|
|
121
|
+
sidecar = backup.with_name(backup.name + SIDECAR_SUFFIX)
|
|
122
|
+
if not sidecar.is_file():
|
|
123
|
+
return None
|
|
124
|
+
text = sidecar.read_text(encoding="utf-8").strip()
|
|
125
|
+
return text.split()[0] if text else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def verify_backup(path: Any) -> Dict[str, Any]:
|
|
129
|
+
"""integrity_check + quick_check + таблицы схемы; sidecar, если есть рядом."""
|
|
130
|
+
backup = Path(path)
|
|
131
|
+
if not backup.is_file():
|
|
132
|
+
raise FileNotFoundError(f"backup не найден: {backup}")
|
|
133
|
+
integrity = quick = None
|
|
134
|
+
conn = sqlite3.connect(str(backup))
|
|
135
|
+
try:
|
|
136
|
+
integrity = conn.execute("PRAGMA integrity_check").fetchone()[0]
|
|
137
|
+
quick = conn.execute("PRAGMA quick_check").fetchone()[0]
|
|
138
|
+
tables = [r[0] for r in conn.execute(
|
|
139
|
+
"SELECT name FROM sqlite_master WHERE type='table' "
|
|
140
|
+
"AND name NOT LIKE 'sqlite_%' ORDER BY name ASC").fetchall()]
|
|
141
|
+
schema_version = None
|
|
142
|
+
if "meta" in tables:
|
|
143
|
+
row = conn.execute(
|
|
144
|
+
"SELECT value FROM meta WHERE key='schema_version'").fetchone()
|
|
145
|
+
schema_version = row[0] if row else None
|
|
146
|
+
finally:
|
|
147
|
+
conn.close()
|
|
148
|
+
|
|
149
|
+
recorded = read_sidecar(backup)
|
|
150
|
+
actual = _sha256_file(backup)
|
|
151
|
+
return {
|
|
152
|
+
"path": str(backup),
|
|
153
|
+
"bytes": backup.stat().st_size,
|
|
154
|
+
"integrity_check": integrity,
|
|
155
|
+
"quick_check": quick,
|
|
156
|
+
"integrity_ok": integrity == "ok",
|
|
157
|
+
"quick_ok": quick == "ok",
|
|
158
|
+
"tables": tables,
|
|
159
|
+
"table_count": len(tables),
|
|
160
|
+
"schema_version": schema_version,
|
|
161
|
+
"sha256": actual,
|
|
162
|
+
"sidecar_sha256": recorded,
|
|
163
|
+
"sidecar_match": None if recorded is None else (recorded == actual),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def restore_preview(state_dir: Any, path: Any) -> Dict[str, Any]:
|
|
168
|
+
"""Что будет перезаписано: путь живой БД, её размер/таблицы + снимок."""
|
|
169
|
+
state_dir = Path(state_dir)
|
|
170
|
+
live = _db_path(state_dir)
|
|
171
|
+
info: Dict[str, Any] = {
|
|
172
|
+
"target": str(live),
|
|
173
|
+
"target_exists": live.is_file(),
|
|
174
|
+
"target_bytes": live.stat().st_size if live.is_file() else 0,
|
|
175
|
+
"target_tables": snapshot_tables(live) if live.is_file() else {},
|
|
176
|
+
"source": str(path),
|
|
177
|
+
"source_tables": {},
|
|
178
|
+
}
|
|
179
|
+
if Path(path).is_file():
|
|
180
|
+
info["source_tables"] = snapshot_tables(Path(path))
|
|
181
|
+
return info
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def restore_backup(state_dir: Any, path: Any, confirmed: bool = False,
|
|
185
|
+
actor: str = "cli") -> Dict[str, Any]:
|
|
186
|
+
"""Перезапись store.db из снимка. Без confirmed ничего не меняется."""
|
|
187
|
+
state_dir = Path(state_dir)
|
|
188
|
+
backup = Path(path)
|
|
189
|
+
preview = restore_preview(state_dir, backup)
|
|
190
|
+
if not confirmed:
|
|
191
|
+
return {
|
|
192
|
+
"status": "needs_confirmation",
|
|
193
|
+
"detail": "restore деструктивен: требуется --yes; изменится store.db "
|
|
194
|
+
"целиком — всё, что создано после снимка, будет потеряно",
|
|
195
|
+
"preview": preview,
|
|
196
|
+
"disclaimer": RESTORE_DISCLAIMER,
|
|
197
|
+
}
|
|
198
|
+
if not backup.is_file():
|
|
199
|
+
raise FileNotFoundError(f"backup не найден: {backup}")
|
|
200
|
+
check = verify_backup(backup)
|
|
201
|
+
if not (check["integrity_ok"] and check["quick_ok"]):
|
|
202
|
+
raise sqlite3.DatabaseError(
|
|
203
|
+
f"backup не прошёл integrity_check/quick_check: "
|
|
204
|
+
f"{check['integrity_check']}/{check['quick_check']}")
|
|
205
|
+
|
|
206
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
target = _db_path(state_dir)
|
|
208
|
+
src = sqlite3.connect(str(backup))
|
|
209
|
+
try:
|
|
210
|
+
dst = sqlite3.connect(str(target))
|
|
211
|
+
try:
|
|
212
|
+
with dst:
|
|
213
|
+
src.backup(dst)
|
|
214
|
+
finally:
|
|
215
|
+
dst.close()
|
|
216
|
+
finally:
|
|
217
|
+
src.close()
|
|
218
|
+
os.chmod(target, 0o600)
|
|
219
|
+
conn = get_connection(state_dir)
|
|
220
|
+
try:
|
|
221
|
+
set_meta(conn, "last_restore_at", datetime.now(timezone.utc).isoformat())
|
|
222
|
+
set_meta(conn, "last_restore_source", str(backup))
|
|
223
|
+
finally:
|
|
224
|
+
conn.close()
|
|
225
|
+
return {
|
|
226
|
+
"status": "restored",
|
|
227
|
+
"target": str(target),
|
|
228
|
+
"source": str(backup),
|
|
229
|
+
"actor": actor,
|
|
230
|
+
"tables_after": snapshot_tables(target),
|
|
231
|
+
"disclaimer": RESTORE_DISCLAIMER,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def last_backup(state_dir: Any) -> Dict[str, Any]:
|
|
236
|
+
"""Последний снимок из meta (S-86): путь+sha, которые мы сами записали."""
|
|
237
|
+
state_dir = Path(state_dir)
|
|
238
|
+
init_db(state_dir)
|
|
239
|
+
conn = get_connection(state_dir)
|
|
240
|
+
try:
|
|
241
|
+
from .db import get_meta
|
|
242
|
+
|
|
243
|
+
path = get_meta(conn, META_LAST_BACKUP_KEY)
|
|
244
|
+
sha = get_meta(conn, META_LAST_BACKUP_SHA_KEY)
|
|
245
|
+
finally:
|
|
246
|
+
conn.close()
|
|
247
|
+
exists = bool(path) and Path(str(path)).is_file()
|
|
248
|
+
recorded = read_sidecar(Path(str(path))) if exists else None
|
|
249
|
+
return {
|
|
250
|
+
"path": path,
|
|
251
|
+
"sha256": sha,
|
|
252
|
+
"exists": exists,
|
|
253
|
+
# verified — sidecar рядом и совпал с записанным в meta; None, если
|
|
254
|
+
# снимка нет: «не проверялось», а не «проверено и плохо».
|
|
255
|
+
"verified": None if not exists else bool(recorded and recorded == sha),
|
|
256
|
+
}
|
sud_cli/calendar_ics.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""W6a §3 (S-28): экспорт дедлайнов в RFC 5545 (ICS).
|
|
2
|
+
|
|
3
|
+
Источник — deadline_states computed/confirmed (the wave), по последнему состоянию
|
|
4
|
+
на пару (case_id, deadline_key); waived и due_at=NULL не экспортируются.
|
|
5
|
+
Конверт детерминирован: DTSTAMP выводится из due-даты, поэтому повторный
|
|
6
|
+
экспорт тех же состояний даёт побайтово тот же файл (UID стабильны —
|
|
7
|
+
требование идемпотентности из ТЗ).
|
|
8
|
+
|
|
9
|
+
Формат (RFC 5545):
|
|
10
|
+
- CRLF переносы; строки не длиннее 75 октетов — длинные фолдятся continuation
|
|
11
|
+
пробелом, разбивка идёт по границам символов utf-8;
|
|
12
|
+
- спецсимволы TEXT-значений экранируются: ``\\\\`` -> ``\\\\\\\\``, ``;`` -> ``\\\\;``,
|
|
13
|
+
``,`` -> ``\\\\,``, перевод строки -> ``\\\\n``;
|
|
14
|
+
- DTSTART/DTEND — локальное время суда (Europe/Moscow, 09:00, +1 час) с явным
|
|
15
|
+
TZID и блоком VTIMEZONE: календарь без таймзоны агент прочтёт как UTC;
|
|
16
|
+
- VALARM -P1D ставится ТОЛЬКО на confirmed: напоминать за сутки по unverified
|
|
17
|
+
вычисленному сроку — давление на оператора, а не факт (anti-laches).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from datetime import date, timedelta
|
|
22
|
+
from typing import Optional, Tuple
|
|
23
|
+
|
|
24
|
+
PRODID = "-//sud-cli//DEADLINES RU 2.11.0//EN"
|
|
25
|
+
TZID = "Europe/Moscow"
|
|
26
|
+
DUE_HOUR = 9 # процессуальные сроки истекают в конце дня; событие ставим на утро суда
|
|
27
|
+
LINE_LEN = 75 # октетов, RFC 5545 3.1
|
|
28
|
+
EXPORT_STATES = ("computed", "confirmed")
|
|
29
|
+
|
|
30
|
+
_VTIMEZONE = (
|
|
31
|
+
"BEGIN:VTIMEZONE",
|
|
32
|
+
f"TZID:{TZID}",
|
|
33
|
+
"BEGIN:STANDARD",
|
|
34
|
+
"DTSTART:20141026T000000",
|
|
35
|
+
"TZOFFSETFROM:+0400",
|
|
36
|
+
"TZOFFSETTO:+0300",
|
|
37
|
+
"TZNAME:MSK",
|
|
38
|
+
"END:STANDARD",
|
|
39
|
+
"END:VTIMEZONE",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _esc(text) -> str:
|
|
44
|
+
out = str(text if text is not None else "")
|
|
45
|
+
out = out.replace("\\", "\\\\")
|
|
46
|
+
out = out.replace(";", "\\;")
|
|
47
|
+
out = out.replace(",", "\\,")
|
|
48
|
+
out = out.replace("\r\n", "\\n").replace("\n", "\\n")
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fold(line: str) -> list[str]:
|
|
53
|
+
"""Разбить строку на куски <= 75 октетов; continuation-строки с ведущим
|
|
54
|
+
пробелом (RFC 5545 3.1), разбивка по границам символов utf-8."""
|
|
55
|
+
if len(line.encode("utf-8")) <= LINE_LEN:
|
|
56
|
+
return [line]
|
|
57
|
+
pieces: list[str] = []
|
|
58
|
+
current: list[str] = []
|
|
59
|
+
current_len = 0
|
|
60
|
+
limit = LINE_LEN
|
|
61
|
+
for ch in line:
|
|
62
|
+
ch_len = len(ch.encode("utf-8"))
|
|
63
|
+
if current_len + ch_len > limit:
|
|
64
|
+
pieces.append("".join(current))
|
|
65
|
+
current = [ch]
|
|
66
|
+
current_len = ch_len
|
|
67
|
+
limit = LINE_LEN - 1 # continuation-строка начинается с пробела
|
|
68
|
+
else:
|
|
69
|
+
current.append(ch)
|
|
70
|
+
current_len += ch_len
|
|
71
|
+
if current:
|
|
72
|
+
pieces.append("".join(current))
|
|
73
|
+
return [pieces[0]] + [" " + p for p in pieces[1:]]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _line(name: str, value: str) -> list[str]:
|
|
77
|
+
return _fold(f"{name}:{value}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def event_uid(case_id: str, deadline_key: str) -> str:
|
|
81
|
+
"""Стабильный UID: пара (дело, ключ) — не id строки истории."""
|
|
82
|
+
return f"{case_id}-{deadline_key}@sud-cli"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _latest_per_key(states):
|
|
86
|
+
latest = {}
|
|
87
|
+
for s in states:
|
|
88
|
+
key = (s.get("case_id"), s.get("deadline_key"))
|
|
89
|
+
try:
|
|
90
|
+
sid = int(s.get("id") or 0)
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
sid = 0
|
|
93
|
+
prev = latest.get(key)
|
|
94
|
+
if prev is None or sid >= int(prev.get("id") or 0):
|
|
95
|
+
latest[key] = s
|
|
96
|
+
return latest
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _describe(state: dict) -> str:
|
|
100
|
+
basis = state.get("basis")
|
|
101
|
+
if isinstance(basis, dict):
|
|
102
|
+
basis_txt = "; ".join(f"{k}={v}" for k, v in sorted(basis.items()))
|
|
103
|
+
elif basis:
|
|
104
|
+
basis_txt = str(basis)
|
|
105
|
+
else:
|
|
106
|
+
basis_txt = "-"
|
|
107
|
+
parts = [f"case={state.get('case_id')}", f"key={state.get('deadline_key')}",
|
|
108
|
+
f"state={state.get('state')}", f"basis: {basis_txt}"]
|
|
109
|
+
if state.get("evidence_note"):
|
|
110
|
+
parts.append(f"evidence: {state['evidence_note']}")
|
|
111
|
+
if state.get("confirmed_by"):
|
|
112
|
+
parts.append(f"confirmed_by: {state['confirmed_by']}")
|
|
113
|
+
return " | ".join(parts)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_ics(states, today: Optional[date] = None, days: int = 60) -> Tuple[str, list[dict]]:
|
|
117
|
+
"""VCALENDAR-текст (CRLF) + список событий; пустой вход = валидный пустой календарь."""
|
|
118
|
+
today = today or date.today()
|
|
119
|
+
horizon = today + timedelta(days=days)
|
|
120
|
+
events_meta: list[dict] = []
|
|
121
|
+
body: list[str] = []
|
|
122
|
+
chosen = []
|
|
123
|
+
for (case_id, key), s in sorted(_latest_per_key(states).items(),
|
|
124
|
+
key=lambda kv: (str(kv[0][0]), str(kv[0][1]))):
|
|
125
|
+
if s.get("state") not in EXPORT_STATES:
|
|
126
|
+
continue
|
|
127
|
+
raw_due = s.get("due_at")
|
|
128
|
+
if not raw_due:
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
due = date.fromisoformat(str(raw_due)[:10])
|
|
132
|
+
except ValueError:
|
|
133
|
+
continue
|
|
134
|
+
if not (today <= due <= horizon):
|
|
135
|
+
continue
|
|
136
|
+
chosen.append((case_id, key, s, due))
|
|
137
|
+
|
|
138
|
+
for case_id, key, s, due in chosen:
|
|
139
|
+
uid = event_uid(str(case_id), str(key))
|
|
140
|
+
stamp = f"{due.strftime('%Y%m%d')}T000000Z"
|
|
141
|
+
start = f"{due.strftime('%Y%m%d')}T{DUE_HOUR:02d}0000"
|
|
142
|
+
# DTEND = DTSTART + 1 час; дата та же, поскольку DUE_HOUR+1 < 24.
|
|
143
|
+
end = f"{due.strftime('%Y%m%d')}T{DUE_HOUR + 1:02d}0000"
|
|
144
|
+
lines: list[str] = ["BEGIN:VEVENT"]
|
|
145
|
+
lines += _line("UID", _esc(uid))
|
|
146
|
+
lines += _line("DTSTAMP", stamp)
|
|
147
|
+
lines += _line("DTSTART;TZID=" + TZID, start)
|
|
148
|
+
lines += _line("DTEND;TZID=" + TZID, end)
|
|
149
|
+
lines += _line("SUMMARY", _esc(f"deadline: {key} {case_id}"))
|
|
150
|
+
lines += _line("DESCRIPTION", _esc(_describe(s)))
|
|
151
|
+
lines += _line("STATUS", "CONFIRMED")
|
|
152
|
+
if s.get("state") == "confirmed":
|
|
153
|
+
lines += ["BEGIN:VALARM"]
|
|
154
|
+
lines += _line("ACTION", "DISPLAY")
|
|
155
|
+
lines += _line("TRIGGER", "-P1D")
|
|
156
|
+
lines += _line("DESCRIPTION", _esc(f"deadline reminder: {key} {case_id}"))
|
|
157
|
+
lines += ["END:VALARM"]
|
|
158
|
+
lines.append("END:VEVENT")
|
|
159
|
+
body += lines
|
|
160
|
+
events_meta.append({
|
|
161
|
+
"uid": uid,
|
|
162
|
+
"case_id": case_id,
|
|
163
|
+
"deadline_key": key,
|
|
164
|
+
"due_at": due.isoformat(),
|
|
165
|
+
"state": s.get("state"),
|
|
166
|
+
"alarm": s.get("state") == "confirmed",
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
out: list[str] = ["BEGIN:VCALENDAR"]
|
|
170
|
+
out += _line("VERSION", "2.0")
|
|
171
|
+
out += _line("PRODID", PRODID)
|
|
172
|
+
out += _line("CALSCALE", "GREGORIAN")
|
|
173
|
+
out += _line("METHOD", "PUBLISH")
|
|
174
|
+
out += list(_VTIMEZONE)
|
|
175
|
+
out += body
|
|
176
|
+
out.append("END:VCALENDAR")
|
|
177
|
+
text = "\r\n".join(out) + "\r\n"
|
|
178
|
+
return text, events_meta
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def ics_for_state_dir(state_dir, days: int = 60,
|
|
182
|
+
today: Optional[date] = None) -> Tuple[str, list[dict]]:
|
|
183
|
+
from sud_cli.db import get_connection, init_db, list_all_deadline_states
|
|
184
|
+
|
|
185
|
+
init_db(state_dir)
|
|
186
|
+
conn = get_connection(state_dir)
|
|
187
|
+
try:
|
|
188
|
+
states = list_all_deadline_states(conn)
|
|
189
|
+
finally:
|
|
190
|
+
conn.close()
|
|
191
|
+
return build_ics(states, today=today, days=days)
|
sud_cli/case_diff.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Build a version diff over two saved snapshots — mirrors case-search semantics.
|
|
2
|
+
|
|
3
|
+
case-search has ``diff_snapshots`` in ``case_search/diff.py:59`` but never
|
|
4
|
+
exposes it: no CLI subcommand calls it and ``public_api.py`` does not re-export
|
|
5
|
+
it (verified by grep over the whole package and by ``case-search --help``).
|
|
6
|
+
sud-cli therefore implements the same field set on its own, reading the two
|
|
7
|
+
snapshot JSON files that the import commands already write into the state
|
|
8
|
+
directory. case-search itself is untouched.
|
|
9
|
+
|
|
10
|
+
The compared fields are copied from ``case_search.diff._SCALARS`` so the two
|
|
11
|
+
implementations agree on what counts as a change.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
# Copied from case_search/diff.py::_SCALARS.
|
|
21
|
+
SCALAR_FIELDS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
|
22
|
+
("status", ("status",)),
|
|
23
|
+
("court", ("court",)),
|
|
24
|
+
("judge", ("judge",)),
|
|
25
|
+
("updated_at", ("updated_at",)),
|
|
26
|
+
("next_hearing.date", ("next_hearing", "date")),
|
|
27
|
+
("next_hearing.time", ("next_hearing", "time")),
|
|
28
|
+
("next_hearing.hall", ("next_hearing", "hall")),
|
|
29
|
+
("next_hearing.type", ("next_hearing", "type")),
|
|
30
|
+
("last_event.date", ("last_event", "date")),
|
|
31
|
+
("last_event.event_type", ("last_event", "event_type")),
|
|
32
|
+
("last_event.document_url", ("last_event", "document_url")),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DiffError(Exception):
|
|
37
|
+
"""A snapshot file is missing, unreadable or not a case snapshot."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _dig(payload: Dict[str, Any], path: Tuple[str, ...]) -> Any:
|
|
41
|
+
current: Any = payload
|
|
42
|
+
for key in path:
|
|
43
|
+
if not isinstance(current, dict):
|
|
44
|
+
return None
|
|
45
|
+
current = current.get(key)
|
|
46
|
+
return current
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load(path: Path) -> Dict[str, Any]:
|
|
50
|
+
try:
|
|
51
|
+
raw = path.read_text(encoding="utf-8")
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
raise DiffError(f"cannot read snapshot: {exc}") from exc
|
|
54
|
+
try:
|
|
55
|
+
payload = json.loads(raw)
|
|
56
|
+
except json.JSONDecodeError as exc:
|
|
57
|
+
raise DiffError(f"snapshot is not valid JSON: {exc}") from exc
|
|
58
|
+
if not isinstance(payload, dict):
|
|
59
|
+
raise DiffError("snapshot must be a JSON object")
|
|
60
|
+
return payload
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _snapshot_payload(path: Path) -> Dict[str, Any]:
|
|
64
|
+
"""Return the snapshot object, unwrapping an ``auto-import`` envelope.
|
|
65
|
+
|
|
66
|
+
``auto-import`` wraps its result as ``{"snapshot": {...}, "extensions": ...}``
|
|
67
|
+
while ``sudrf-import`` writes the snapshot at the top level. Accepting both
|
|
68
|
+
keeps the command usable regardless of which importer produced the file.
|
|
69
|
+
"""
|
|
70
|
+
payload = _load(path)
|
|
71
|
+
inner = payload.get("snapshot")
|
|
72
|
+
if isinstance(inner, dict):
|
|
73
|
+
return inner
|
|
74
|
+
return payload
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parties(payload: Dict[str, Any]) -> Dict[str, List[str]]:
|
|
78
|
+
parties = payload.get("parties")
|
|
79
|
+
if not isinstance(parties, dict):
|
|
80
|
+
return {"plaintiff": [], "respondent": [], "third_parties": []}
|
|
81
|
+
return {
|
|
82
|
+
key: list(parties.get(key) or [])
|
|
83
|
+
for key in ("plaintiff", "respondent", "third_parties")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _timeline_index(payload: Dict[str, Any]) -> Dict[Tuple[str, str], bool]:
|
|
88
|
+
index: Dict[Tuple[str, str], bool] = {}
|
|
89
|
+
for event in payload.get("events_timeline") or []:
|
|
90
|
+
if not isinstance(event, dict):
|
|
91
|
+
continue
|
|
92
|
+
date = event.get("date")
|
|
93
|
+
name = event.get("name")
|
|
94
|
+
if date is None or name is None:
|
|
95
|
+
continue
|
|
96
|
+
index[(str(date), str(name))] = bool(event.get("published"))
|
|
97
|
+
return index
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def diff_case_snapshots(
|
|
101
|
+
old_path: Path, new_path: Path
|
|
102
|
+
) -> Dict[str, Any]:
|
|
103
|
+
"""Compare two saved snapshots of the same case.
|
|
104
|
+
|
|
105
|
+
Returns a dict with ``changed``, ``changes`` (path/before/after) and
|
|
106
|
+
``added_removed_events``. An unchanged pair yields ``changed: False`` with
|
|
107
|
+
empty lists rather than an error, because "nothing moved" is a legitimate
|
|
108
|
+
answer to the question.
|
|
109
|
+
"""
|
|
110
|
+
old = _snapshot_payload(old_path)
|
|
111
|
+
new = _snapshot_payload(new_path)
|
|
112
|
+
|
|
113
|
+
old_number = str(old.get("case_number") or "")
|
|
114
|
+
new_number = str(new.get("case_number") or "")
|
|
115
|
+
if not old_number or not new_number:
|
|
116
|
+
raise DiffError("snapshot has no case_number")
|
|
117
|
+
|
|
118
|
+
# Check identity *before* comparing: a field-by-field report across two
|
|
119
|
+
# different cases would be noise dressed up as a diff.
|
|
120
|
+
if old_number != new_number:
|
|
121
|
+
raise DiffError(
|
|
122
|
+
f"snapshots belong to different cases: {old_number} vs {new_number}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
changes: List[Dict[str, Any]] = []
|
|
126
|
+
for path, keys in SCALAR_FIELDS:
|
|
127
|
+
before = _dig(old, keys)
|
|
128
|
+
after = _dig(new, keys)
|
|
129
|
+
if before != after:
|
|
130
|
+
changes.append({"path": path, "before": before, "after": after})
|
|
131
|
+
|
|
132
|
+
before_parties = _parties(old)
|
|
133
|
+
after_parties = _parties(new)
|
|
134
|
+
for role in ("plaintiff", "respondent", "third_parties"):
|
|
135
|
+
if before_parties[role] != after_parties[role]:
|
|
136
|
+
changes.append({
|
|
137
|
+
"path": f"parties.{role}",
|
|
138
|
+
"before": before_parties[role],
|
|
139
|
+
"after": after_parties[role],
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
old_tl = _timeline_index(old)
|
|
143
|
+
new_tl = _timeline_index(new)
|
|
144
|
+
timeline_changes: List[Dict[str, Any]] = []
|
|
145
|
+
for key in sorted(set(old_tl) | set(new_tl)):
|
|
146
|
+
was, now = old_tl.get(key), new_tl.get(key)
|
|
147
|
+
if was != now:
|
|
148
|
+
timeline_changes.append({
|
|
149
|
+
"date": key[0],
|
|
150
|
+
"name": key[1],
|
|
151
|
+
"before": was,
|
|
152
|
+
"after": now,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"case_number": new_number,
|
|
157
|
+
"changed": bool(changes or timeline_changes),
|
|
158
|
+
"changes": sorted(changes, key=lambda c: c["path"]),
|
|
159
|
+
"timeline_changes": timeline_changes,
|
|
160
|
+
"compared_fields": len(SCALAR_FIELDS) + 3,
|
|
161
|
+
"source": {
|
|
162
|
+
"old": str(old_path),
|
|
163
|
+
"new": str(new_path),
|
|
164
|
+
},
|
|
165
|
+
}
|