freemyid 0.2.0.dev1__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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
freemyid/wizard/flows.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""CLI setup/edit flows and family bundle export/import."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import getpass
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..crypto import export_with_passphrase, import_with_passphrase
|
|
9
|
+
from ..models import Profile
|
|
10
|
+
from . import draft as dr
|
|
11
|
+
|
|
12
|
+
BUNDLE_VERSION = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ask(text: str, default: str = "") -> str:
|
|
16
|
+
suffix = f" [{default}]" if default else ""
|
|
17
|
+
return (input(f"{text}{suffix}: ").strip()) or default
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _yes(text: str, default: bool = False) -> bool:
|
|
21
|
+
ans = input(f"{text} [{'Y/n' if default else 'y/N'}]: ").strip().lower()
|
|
22
|
+
return default if not ans else ans.startswith("y")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _secret(text: str) -> str:
|
|
26
|
+
return getpass.getpass(f"{text}: ")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _say(text: str = "") -> None:
|
|
30
|
+
print(text)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Form(dict):
|
|
34
|
+
"""Adapter so CLI answers go through the same validators as the web form."""
|
|
35
|
+
|
|
36
|
+
def getlist(self, key):
|
|
37
|
+
v = self.get(key)
|
|
38
|
+
return v if isinstance(v, list) else ([v] if v else [])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _step(d: dr.Draft, step: str, answers: dict) -> bool:
|
|
42
|
+
errs = dr.apply_step(d, step, _Form(answers))
|
|
43
|
+
for e in errs:
|
|
44
|
+
_say(f" ! {e}")
|
|
45
|
+
return not errs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _loop(d: dr.Draft, step: str, ask) -> None:
|
|
49
|
+
while True:
|
|
50
|
+
if _step(d, step, ask()):
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _lines_default(items: list[str]) -> str:
|
|
55
|
+
return "; ".join(items)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _split(text: str) -> str:
|
|
59
|
+
return "\n".join(x.strip() for x in text.split(";") if x.strip())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _consent(d: dr.Draft) -> None:
|
|
63
|
+
_say("\n== Permission ==")
|
|
64
|
+
for t in dr.CONSENT_TEXT:
|
|
65
|
+
_say(f"- {t}")
|
|
66
|
+
_say("\nWho is giving permission?")
|
|
67
|
+
keys = list(dr.BASIS_LABELS)
|
|
68
|
+
for i, k in enumerate(keys, 1):
|
|
69
|
+
_say(f" {i}. {dr.BASIS_LABELS[k]}")
|
|
70
|
+
|
|
71
|
+
def ask():
|
|
72
|
+
n = _ask("Choose 1-3", "1")
|
|
73
|
+
basis = keys[int(n) - 1] if n.isdigit() and 1 <= int(n) <= 3 else ""
|
|
74
|
+
if basis == "authorized_agent":
|
|
75
|
+
_say(" The signed form can be attached later from the dashboard; Free My ID will "
|
|
76
|
+
"not run for this person until it is.")
|
|
77
|
+
return {"basis": basis, "attested_by": _ask("Type your full name to sign", d.attested_by),
|
|
78
|
+
"ack": "1" if _yes("I have read this and I agree") else ""}
|
|
79
|
+
_loop(d, "consent", ask)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _names(d: dr.Draft) -> None:
|
|
83
|
+
_say("\n== Names ==")
|
|
84
|
+
_loop(d, "names", lambda: {
|
|
85
|
+
"first": _ask("First name", d.first), "middle": _ask("Middle name (optional)", d.middle),
|
|
86
|
+
"last": _ask("Last name", d.last), "suffix": _ask("Suffix (optional)", d.suffix),
|
|
87
|
+
"label": _ask("What to call them on screen", d.label or d.first),
|
|
88
|
+
"nicknames": _split(_ask("Nicknames, separated by ;", _lines_default(d.nicknames))),
|
|
89
|
+
"previous_names": _split(_ask("Previous/maiden full names, separated by ;",
|
|
90
|
+
_lines_default(d.previous_names)))})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _addresses(d: dr.Draft) -> None:
|
|
94
|
+
_say("\n== Addresses (city and state are enough) ==")
|
|
95
|
+
|
|
96
|
+
def ask():
|
|
97
|
+
a: dict = {}
|
|
98
|
+
cur = "0"
|
|
99
|
+
for i in range(4):
|
|
100
|
+
old = d.addresses[i] if i < len(d.addresses) else {}
|
|
101
|
+
city = _ask(f"Address {i + 1} city (blank to stop)", old.get("city", ""))
|
|
102
|
+
if not city:
|
|
103
|
+
break
|
|
104
|
+
a[f"city{i}"] = city
|
|
105
|
+
a[f"state{i}"] = _ask(" State (2 letters)", old.get("state", ""))
|
|
106
|
+
a[f"street{i}"] = _ask(" Street (optional)", old.get("street", ""))
|
|
107
|
+
a[f"zip{i}"] = _ask(" ZIP (optional)", old.get("zip", ""))
|
|
108
|
+
if _yes(" Is this the current address", i == 0):
|
|
109
|
+
cur = str(i)
|
|
110
|
+
a["current"] = cur
|
|
111
|
+
return a
|
|
112
|
+
_loop(d, "addresses", ask)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _details(d: dr.Draft) -> None:
|
|
116
|
+
_say("\n== Other details (used to find the right listings; birth year, relatives and "
|
|
117
|
+
"personal emails are never sent) ==")
|
|
118
|
+
_loop(d, "details", lambda: {
|
|
119
|
+
"birth_year": _ask("Year of birth, 4 digits (or blank)", dr.birth_year_of(d)),
|
|
120
|
+
"phones": _split(_ask("Phones, separated by ;", _lines_default(d.phones))),
|
|
121
|
+
"emails": _split(_ask("Personal emails, separated by ;", _lines_default(d.emails))),
|
|
122
|
+
"relatives": _split(_ask("Relatives (full names), separated by ;",
|
|
123
|
+
_lines_default(d.relatives)))})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _permissions(d: dr.Draft) -> None:
|
|
127
|
+
_say("\n== What Free My ID may do (all off unless you say yes; it never pays) ==")
|
|
128
|
+
_step(d, "permissions", {
|
|
129
|
+
"allow_id_upload": "1" if _yes("Allow uploading an ID when a broker requires it") else "",
|
|
130
|
+
"allow_account_creation": "1" if _yes("Allow creating a broker account if required")
|
|
131
|
+
else "",
|
|
132
|
+
"allow_phone_disclosure": "1" if _yes("Allow giving a phone number if required") else ""})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _trust_local(d: dr.Draft, a: dict) -> None:
|
|
136
|
+
"""Ask the user to approve the local mail app's security code (no password is sent)."""
|
|
137
|
+
from ..engine.mail_tls import TlsPinError, trust_on_first_use
|
|
138
|
+
from ..models import MailboxConfig
|
|
139
|
+
from ..netpolicy import NetPolicy
|
|
140
|
+
pre = dr.PRESETS["proton_bridge"]
|
|
141
|
+
try:
|
|
142
|
+
res = trust_on_first_use(MailboxConfig(
|
|
143
|
+
provider="proton_bridge", address=a.get("address") or "x@example.org",
|
|
144
|
+
username="x", keyring_service="freemyid-mailbox", **pre),
|
|
145
|
+
netpolicy=NetPolicy(None, allow_loopback=True))
|
|
146
|
+
except (TlsPinError, ValueError):
|
|
147
|
+
_say(dr.unreachable_message(dr.PRESETS["proton_bridge"]["imap_host"]))
|
|
148
|
+
return
|
|
149
|
+
_say("\n" + res.message)
|
|
150
|
+
if _yes("Do you trust this code", False):
|
|
151
|
+
d.tls_pin = res.fingerprint
|
|
152
|
+
else:
|
|
153
|
+
_say("Not trusted. Free My ID will not sign in to this mail app.")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _mailbox(d: dr.Draft) -> None:
|
|
157
|
+
_say("\n== Dedicated removal mailbox ==")
|
|
158
|
+
if not _yes("Set it up now", True):
|
|
159
|
+
d.skip_mailbox = True
|
|
160
|
+
return
|
|
161
|
+
d.skip_mailbox = False
|
|
162
|
+
provs = list(dr.PRESETS) # gmail, yahoo, icloud, proton_bridge, other (Outlook.com: skip)
|
|
163
|
+
old = d.mailbox or {}
|
|
164
|
+
|
|
165
|
+
def ask():
|
|
166
|
+
prov = _ask(f"Provider ({'/'.join(provs)})", old.get("provider", "gmail"))
|
|
167
|
+
a = {"provider": prov, "address": _ask("Mailbox address", old.get("address", "")),
|
|
168
|
+
"password": _secret("Password / app password (hidden; blank keeps saved)")}
|
|
169
|
+
if prov == "proton_bridge":
|
|
170
|
+
_trust_local(d, a)
|
|
171
|
+
if prov == "other":
|
|
172
|
+
a.update(imap_host=_ask("Incoming mail server (IMAP)"), smtp_host=_ask("Outgoing mail server (SMTP)"),
|
|
173
|
+
imap_port=_ask("IMAP port", "993"), smtp_port=_ask("SMTP port", "465"),
|
|
174
|
+
imap_ssl="1" if _yes("IMAP uses SSL", True) else "",
|
|
175
|
+
imap_starttls="1" if _yes("IMAP uses STARTTLS (needed if SSL is off, "
|
|
176
|
+
"or your password is sent unprotected)") else "",
|
|
177
|
+
smtp_starttls="1" if _yes("SMTP uses STARTTLS") else "")
|
|
178
|
+
return a
|
|
179
|
+
_loop(d, "mailbox", ask)
|
|
180
|
+
if d.password and _yes("Test the connection now"):
|
|
181
|
+
_say(dr.test_mailbox_connection(d.mailbox, d.password)[1])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _email_sched(d: dr.Draft) -> None:
|
|
185
|
+
_say("\n== Email approval ==")
|
|
186
|
+
auto = _yes("Send routine removal emails automatically? (No = ask me each time)")
|
|
187
|
+
_step(d, "email", {"email_mode": "auto" if auto else "approve_each"})
|
|
188
|
+
_say("\n== Schedule ==")
|
|
189
|
+
on = _yes("Re-check regularly", d.weekly_recheck)
|
|
190
|
+
_step(d, "schedule", {"weekly_recheck": "1" if on else "", "weekday": d.weekday, "hour": d.hour})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _finish(store, d: dr.Draft) -> Profile:
|
|
194
|
+
_say("\nSome brokers need manual steps, listings can reappear, and 'removed' means "
|
|
195
|
+
"removed from that broker only.")
|
|
196
|
+
if not _yes("Save this person", True):
|
|
197
|
+
raise SystemExit("Cancelled; nothing saved.")
|
|
198
|
+
prof = dr.save_draft(store, d)
|
|
199
|
+
_say("Saved. Next: `freemyid run --dry-run`, then `freemyid schedule install`.")
|
|
200
|
+
return prof
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def setup_cli(store, prefill: Path | None = None) -> Profile:
|
|
204
|
+
"""Interactive terminal setup, same steps as the web wizard."""
|
|
205
|
+
d = dr.Draft()
|
|
206
|
+
if prefill:
|
|
207
|
+
d = load_bundle(Path(prefill).read_bytes(), _secret("Passphrase for the setup file"))
|
|
208
|
+
_say("Loaded. Please review everything and give your own permission.")
|
|
209
|
+
_consent(d)
|
|
210
|
+
_names(d)
|
|
211
|
+
_addresses(d)
|
|
212
|
+
_details(d)
|
|
213
|
+
_permissions(d)
|
|
214
|
+
_mailbox(d)
|
|
215
|
+
_email_sched(d)
|
|
216
|
+
return _finish(store, d)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def edit_cli(store, profile_id: str) -> Profile:
|
|
220
|
+
p = store.get_profile(profile_id)
|
|
221
|
+
if p is None:
|
|
222
|
+
raise ValueError("no such profile")
|
|
223
|
+
d = dr.draft_from_profile(p)
|
|
224
|
+
sections = [("Names", _names), ("Addresses", _addresses), ("Other details", _details),
|
|
225
|
+
("Permissions", _permissions), ("Mailbox", _mailbox),
|
|
226
|
+
("Email approval and schedule", _email_sched)]
|
|
227
|
+
for title, fn in sections:
|
|
228
|
+
if _yes(f"Change {title}"):
|
|
229
|
+
fn(d)
|
|
230
|
+
return _finish(store, d)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# --------------------------------------------------------------------------- bundles
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def export_family_bundle(store, profile_id: str, dest: Path, passphrase: str) -> Path:
|
|
237
|
+
"""Passphrase-encrypted pre-filled profile. No consent, mailbox or password inside."""
|
|
238
|
+
p = store.get_profile(profile_id)
|
|
239
|
+
if p is None:
|
|
240
|
+
raise ValueError("no such profile")
|
|
241
|
+
data = p.model_dump(mode="json", exclude={"id", "consent", "mailbox", "created_at",
|
|
242
|
+
"updated_at", "is_minor"})
|
|
243
|
+
blob = export_with_passphrase(
|
|
244
|
+
json.dumps({"freemyid_bundle": BUNDLE_VERSION, "profile": data}).encode(), passphrase)
|
|
245
|
+
dest = Path(dest)
|
|
246
|
+
if dest.suffix != ".freemyid-profile":
|
|
247
|
+
dest = dest.with_name(dest.name + ".freemyid-profile")
|
|
248
|
+
dest.write_bytes(blob)
|
|
249
|
+
dest.chmod(0o600)
|
|
250
|
+
return dest
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def find_family_bundle() -> Path | None:
|
|
254
|
+
"""A .freemyid-profile shipped next to the installed app (the "family installer")."""
|
|
255
|
+
import sys
|
|
256
|
+
base = Path(sys.executable).parent if getattr(sys, "frozen", False) else None
|
|
257
|
+
if base is None:
|
|
258
|
+
return None
|
|
259
|
+
return next(iter(sorted(base.glob("*.freemyid-profile"))), None)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def load_bundle(blob: bytes, passphrase: str) -> dr.Draft:
|
|
263
|
+
"""Decrypt a bundle into a fresh draft. Consent and mailbox are never imported."""
|
|
264
|
+
obj = json.loads(import_with_passphrase(blob, passphrase))
|
|
265
|
+
if obj.get("freemyid_bundle") != BUNDLE_VERSION:
|
|
266
|
+
raise ValueError("unsupported bundle version")
|
|
267
|
+
pr = obj.get("profile")
|
|
268
|
+
if not isinstance(pr, dict):
|
|
269
|
+
raise TypeError("bad bundle")
|
|
270
|
+
# Never trust identifiers from a file: a fresh id is minted on save (path-traversal guard).
|
|
271
|
+
for k in ("id", "consent", "mailbox", "created_at", "updated_at", "is_minor"):
|
|
272
|
+
pr.pop(k, None)
|
|
273
|
+
from ..models import Consent, ConsentBasis
|
|
274
|
+
tmp = Profile.model_validate({
|
|
275
|
+
**pr, "consent": Consent(basis=ConsentBasis.SELF, attested_by="x",
|
|
276
|
+
text_version="import").model_dump(mode="json")})
|
|
277
|
+
d = dr.draft_from_profile(tmp, keep_consent=False)
|
|
278
|
+
d.editing = False
|
|
279
|
+
d.imported = True
|
|
280
|
+
d.attested_by = ""
|
|
281
|
+
d.basis = ConsentBasis.SELF.value
|
|
282
|
+
d.mailbox, d.skip_mailbox, d.mailbox_answered = {}, False, False
|
|
283
|
+
return d
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Plain progress report (HTML, or PDF when weasyprint is installed).
|
|
2
|
+
|
|
3
|
+
Deliberately PII-light: the person's on-screen label, site names, states and dates only.
|
|
4
|
+
No addresses, phone numbers or evidence images, so the file is safe to share with family.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .. import paths
|
|
11
|
+
from ..models import ListingState, utcnow
|
|
12
|
+
from .ui import CHIP_LABEL, HONESTY, esc
|
|
13
|
+
|
|
14
|
+
_CSS = ("body{font:18px/1.5 system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem}"
|
|
15
|
+
"table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #ccc;"
|
|
16
|
+
"padding:.4rem;text-align:left}td.d{white-space:nowrap}.cov{display:block;font-size:.8em;color:#666}"
|
|
17
|
+
"@page{size:A4;margin:1.5cm}@media print{body{font-size:14px;max-width:none;margin:0}}"
|
|
18
|
+
".note{background:#f4f4f4;padding:.8rem;border-radius:6px}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_GOOD = {ListingState.CONFIRMED, ListingState.REMOVED}
|
|
22
|
+
_WAIT = {ListingState.SUBMITTED, ListingState.AWAITING_VERIFICATION}
|
|
23
|
+
_ASKED = _GOOD | _WAIT | {ListingState.REAPPEARED, ListingState.NEEDS_HUMAN}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_report(store, registry, profile_id: str, fmt: str = "html") -> Path:
|
|
27
|
+
p = store.get_profile(profile_id)
|
|
28
|
+
if p is None:
|
|
29
|
+
raise ValueError("no such profile")
|
|
30
|
+
if fmt not in ("html", "pdf"):
|
|
31
|
+
raise ValueError("fmt must be 'html' or 'pdf'")
|
|
32
|
+
names = {b.id: b.name for b in registry.brokers} if registry else {}
|
|
33
|
+
members: dict[str, list[str]] = {}
|
|
34
|
+
for b in (registry.brokers if registry else []):
|
|
35
|
+
if getattr(b, "covered_by", None):
|
|
36
|
+
members.setdefault(b.covered_by, []).append(b.name)
|
|
37
|
+
|
|
38
|
+
def site_cell(bid: str) -> str:
|
|
39
|
+
full = names.get(bid, bid)
|
|
40
|
+
short = full.split(" (")[0]
|
|
41
|
+
cov = members.get(bid)
|
|
42
|
+
if cov:
|
|
43
|
+
extra = f'<span class="cov">covers: {esc(", ".join(sorted(cov)))}</span>'
|
|
44
|
+
elif short != full:
|
|
45
|
+
extra = f'<span class="cov">{esc(full[len(short) + 2:].rstrip(")"))}</span>'
|
|
46
|
+
else:
|
|
47
|
+
extra = ""
|
|
48
|
+
return esc(short) + extra
|
|
49
|
+
|
|
50
|
+
sts = sorted(store.list_statuses(profile_id), key=lambda s: names.get(s.broker_id, s.broker_id))
|
|
51
|
+
counts: dict[ListingState, int] = {}
|
|
52
|
+
for s in sts:
|
|
53
|
+
counts[s.state] = counts.get(s.state, 0) + 1
|
|
54
|
+
asked = sum(n for k, n in counts.items() if k in _ASKED)
|
|
55
|
+
good = sum(n for k, n in counts.items() if k in _GOOD)
|
|
56
|
+
wait = sum(n for k, n in counts.items() if k in _WAIT)
|
|
57
|
+
rows = "".join(
|
|
58
|
+
f"<tr><td>{site_cell(s.broker_id)}</td><td>{esc(CHIP_LABEL[s.state])}"
|
|
59
|
+
f"</td><td class='d'>{s.last_scan_at.date() if s.last_scan_at else '-'}</td>"
|
|
60
|
+
f"<td class='d'>{s.last_submitted_at.date() if s.last_submitted_at else '-'}</td></tr>"
|
|
61
|
+
for s in sts)
|
|
62
|
+
todos = store.list_todos(profile_id)
|
|
63
|
+
doc = (f'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Free My ID report'
|
|
64
|
+
f"</title><style>{_CSS}</style></head><body><h1>{esc(p.label)}: what Free My ID has asked, and what came back</h1>"
|
|
65
|
+
f"<p>As of {utcnow().date()}, Free My ID has asked {asked} people-search sites to "
|
|
66
|
+
f"delete this person's details. {good} have confirmed or no longer list them. "
|
|
67
|
+
f"{wait} are still waiting. {len(todos)} need a person to do one step. "
|
|
68
|
+
f"Sites can put details back up, so Free My ID checks again.</p>"
|
|
69
|
+
f'<table><tr><th>Site</th><th>Status</th><th>Last checked</th>'
|
|
70
|
+
f"<th>Last request</th></tr>{rows}</table>"
|
|
71
|
+
f'<p class="note">{esc(HONESTY)}</p></body></html>')
|
|
72
|
+
stem = f"report-{profile_id[:8]}-{utcnow().strftime('%Y%m%d')}"
|
|
73
|
+
out = paths.artifacts_dir() / f"{stem}.html"
|
|
74
|
+
out.write_text(doc, encoding="utf-8")
|
|
75
|
+
out.chmod(0o600)
|
|
76
|
+
if fmt == "pdf":
|
|
77
|
+
try:
|
|
78
|
+
from weasyprint import HTML # optional dependency
|
|
79
|
+
except ImportError:
|
|
80
|
+
return out # caller sees the .html suffix and can tell PDF was unavailable
|
|
81
|
+
pdf = out.with_suffix(".pdf")
|
|
82
|
+
HTML(string=doc).write_pdf(str(pdf))
|
|
83
|
+
pdf.chmod(0o600)
|
|
84
|
+
out.unlink(missing_ok=True)
|
|
85
|
+
return pdf
|
|
86
|
+
return out
|