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/setup_config.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""`freemyid setup --from config.yaml`: non-interactive setup from a versioned YAML file.
|
|
2
|
+
|
|
3
|
+
OWNER: automation-engineer (CLI v0.2). Schema: schema/setup-config.schema.json (generated by
|
|
4
|
+
tools/gen_schema.py from :class:`SetupConfig`). Example: docs/examples/setup-config.example.yaml.
|
|
5
|
+
|
|
6
|
+
The file is turned into the same wizard Draft and goes through the same validators as the
|
|
7
|
+
web and terminal wizards (``wizard.draft.apply_step``), so the rules can never drift apart.
|
|
8
|
+
Passwords are never written in the file: the YAML names an environment variable or a file.
|
|
9
|
+
Consent stays explicit: ``consent.agreed: true`` plus a typed ``signed_name``, and the CLI
|
|
10
|
+
shows what will happen and needs ``--yes`` or an interactive confirm.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from datetime import date
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
23
|
+
from .wizard.draft import Draft
|
|
24
|
+
|
|
25
|
+
SETUP_CONFIG_VERSION = 1
|
|
26
|
+
MAX_ADDRESSES = 4 # the wizard's limit
|
|
27
|
+
MAX_FILE_BYTES = 256 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _M(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SetupConsent(_M):
|
|
35
|
+
agreed: bool = Field(description="Must be true: you have read and agree to the consent "
|
|
36
|
+
"text that `freemyid setup --from` prints")
|
|
37
|
+
signed_name: str = Field(min_length=1, description="Full name of the person giving "
|
|
38
|
+
"permission, typed as a signature")
|
|
39
|
+
basis: Literal["self", "guardian_of_minor", "authorized_agent"] = Field(
|
|
40
|
+
default="self", description="self = I am this person; guardian_of_minor = parent or "
|
|
41
|
+
"legal guardian of someone under 18; authorized_agent = signed written permission")
|
|
42
|
+
authorization_file: str | None = Field(
|
|
43
|
+
default=None, description="Signed authorization form (PDF/image), required for "
|
|
44
|
+
"authorized_agent. Relative paths are relative to this YAML file")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SetupName(_M):
|
|
48
|
+
first: str = Field(min_length=1)
|
|
49
|
+
middle: str | None = None
|
|
50
|
+
last: str = Field(min_length=1)
|
|
51
|
+
suffix: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SetupAddress(_M):
|
|
55
|
+
street: str | None = None
|
|
56
|
+
city: str = Field(min_length=1)
|
|
57
|
+
state: str = Field(pattern=r"^[A-Za-z]{2}$", description="2-letter US state, e.g. IL")
|
|
58
|
+
zip: str | None = None
|
|
59
|
+
current: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SetupPerson(_M):
|
|
63
|
+
label: str | None = Field(default=None, description="What to call them on screen, e.g. "
|
|
64
|
+
"'Mom'. Defaults to the first name")
|
|
65
|
+
name: SetupName
|
|
66
|
+
nicknames: list[str] = []
|
|
67
|
+
previous_names: list[str] = Field(default=[], description="Full previous/maiden names")
|
|
68
|
+
birth_year: int | None = Field(default=None, ge=1900, description="Only the year is kept")
|
|
69
|
+
age: int | None = Field(default=None, ge=0, le=130, description="If birth_year is unknown")
|
|
70
|
+
addresses: list[SetupAddress] = Field(min_length=1, max_length=MAX_ADDRESSES)
|
|
71
|
+
phones: list[str] = []
|
|
72
|
+
emails: list[str] = Field(default=[], description="Personal emails, used only to "
|
|
73
|
+
"recognize listings, never sent anywhere")
|
|
74
|
+
relatives: list[str] = []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SetupPermissions(_M):
|
|
78
|
+
allow_id_upload: bool = False
|
|
79
|
+
allow_account_creation: bool = False
|
|
80
|
+
allow_phone_disclosure: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SetupMailbox(_M):
|
|
84
|
+
provider: Literal["gmail", "yahoo", "icloud", "proton_bridge", "other"]
|
|
85
|
+
address: str = Field(description="The dedicated removal mailbox address")
|
|
86
|
+
username: str | None = Field(default=None, description="Login name if not the address")
|
|
87
|
+
password_env: str | None = Field(default=None, description="Name of an environment "
|
|
88
|
+
"variable holding the app password")
|
|
89
|
+
password_file: str | None = Field(default=None, description="File holding the app "
|
|
90
|
+
"password (first line). Keep it chmod 600")
|
|
91
|
+
imap_host: str | None = None
|
|
92
|
+
imap_port: int | None = Field(default=None, ge=1, le=65535)
|
|
93
|
+
imap_ssl: bool | None = None
|
|
94
|
+
imap_starttls: bool | None = None
|
|
95
|
+
smtp_host: str | None = None
|
|
96
|
+
smtp_port: int | None = Field(default=None, ge=1, le=65535)
|
|
97
|
+
smtp_starttls: bool | None = None
|
|
98
|
+
tls_cert_sha256: str | None = Field(
|
|
99
|
+
default=None, pattern=r"^[0-9A-Fa-f:]{64,95}$",
|
|
100
|
+
description="Proton Mail Bridge only: the certificate fingerprint you approved")
|
|
101
|
+
|
|
102
|
+
@model_validator(mode="before")
|
|
103
|
+
@classmethod
|
|
104
|
+
def _no_inline_password(cls, v: Any) -> Any:
|
|
105
|
+
if isinstance(v, dict) and "password" in v:
|
|
106
|
+
raise ValueError("never put the password in the file; use password_env or "
|
|
107
|
+
"password_file")
|
|
108
|
+
return v
|
|
109
|
+
|
|
110
|
+
@model_validator(mode="after")
|
|
111
|
+
def _one_password_source(self) -> SetupMailbox:
|
|
112
|
+
if bool(self.password_env) == bool(self.password_file):
|
|
113
|
+
raise ValueError("give exactly one of password_env or password_file")
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SetupSchedule(_M):
|
|
118
|
+
weekly_recheck: bool = True
|
|
119
|
+
weekday: int = Field(default=6, ge=0, le=6, description="0=Mon .. 6=Sun")
|
|
120
|
+
hour: int = Field(default=10, ge=0, le=23)
|
|
121
|
+
install_job: bool = Field(default=False, description="Also install the background job "
|
|
122
|
+
"(same as `freemyid schedule install`)")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SetupConfig(_M):
|
|
126
|
+
"""Free My ID setup file, version 1. One file = one person."""
|
|
127
|
+
version: Literal[1] = Field(description="Setup file format version")
|
|
128
|
+
consent: SetupConsent
|
|
129
|
+
person: SetupPerson
|
|
130
|
+
permissions: SetupPermissions = SetupPermissions()
|
|
131
|
+
mailbox: SetupMailbox | None = None
|
|
132
|
+
email_mode: Literal["approve_each", "auto"] = "approve_each"
|
|
133
|
+
schedule: SetupSchedule = SetupSchedule()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SetupFileError(ValueError):
|
|
137
|
+
"""The setup file is unreadable or invalid. `problems` are plain-language lines."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, problems: list[str]) -> None:
|
|
140
|
+
super().__init__("; ".join(problems))
|
|
141
|
+
self.problems = problems
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load(path: Path) -> SetupConfig:
|
|
145
|
+
path = Path(path)
|
|
146
|
+
try:
|
|
147
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
148
|
+
raise SetupFileError([f"{path.name} is too large to be a setup file"])
|
|
149
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
150
|
+
except OSError as e:
|
|
151
|
+
raise SetupFileError([f"cannot read {path}: {type(e).__name__}"]) from e
|
|
152
|
+
except yaml.YAMLError as e:
|
|
153
|
+
raise SetupFileError([f"{path.name} is not valid YAML"]) from e
|
|
154
|
+
if not isinstance(raw, dict):
|
|
155
|
+
raise SetupFileError([f"{path.name} should be a YAML mapping (key: value)"])
|
|
156
|
+
try:
|
|
157
|
+
return SetupConfig.model_validate(raw)
|
|
158
|
+
except ValidationError as e:
|
|
159
|
+
# field paths and messages only: never echo the values (they are PII)
|
|
160
|
+
raise SetupFileError([f"{'.'.join(str(x) for x in err['loc']) or 'file'}: {err['msg']}"
|
|
161
|
+
for err in e.errors(include_input=False)]) from e
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve(base: Path, p: str) -> Path:
|
|
165
|
+
q = Path(os.path.expanduser(p))
|
|
166
|
+
return q if q.is_absolute() else base / q
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def read_password(mb: SetupMailbox, base: Path) -> str:
|
|
170
|
+
if mb.password_env:
|
|
171
|
+
pw = os.environ.get(mb.password_env, "")
|
|
172
|
+
if not pw:
|
|
173
|
+
raise SetupFileError([(f"mailbox.password_env: environment variable "
|
|
174
|
+
f"{mb.password_env} is not set")])
|
|
175
|
+
return pw
|
|
176
|
+
assert mb.password_file
|
|
177
|
+
f = _resolve(base, mb.password_file)
|
|
178
|
+
try:
|
|
179
|
+
lines = f.read_text(encoding="utf-8").splitlines()
|
|
180
|
+
except OSError as e:
|
|
181
|
+
raise SetupFileError([f"mailbox.password_file: cannot read it ({type(e).__name__})"]
|
|
182
|
+
) from e
|
|
183
|
+
pw = lines[0].strip() if lines else ""
|
|
184
|
+
if not pw:
|
|
185
|
+
raise SetupFileError(["mailbox.password_file: the file is empty"])
|
|
186
|
+
return pw
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _flag(b: bool | None) -> str:
|
|
190
|
+
return "1" if b else ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def to_draft(cfg: SetupConfig, base: Path) -> Draft:
|
|
194
|
+
"""Build a wizard Draft through the wizard's own validators. Raises SetupFileError."""
|
|
195
|
+
from .wizard import draft as dr
|
|
196
|
+
from .wizard.flows import _Form
|
|
197
|
+
|
|
198
|
+
d = dr.Draft()
|
|
199
|
+
problems: list[str] = []
|
|
200
|
+
|
|
201
|
+
def step(name: str, form: dict[str, Any], files: dict | None = None) -> None:
|
|
202
|
+
problems.extend(f"{name}: {e}" for e in dr.apply_step(d, name, _Form(form), files))
|
|
203
|
+
|
|
204
|
+
c = cfg.consent
|
|
205
|
+
if not c.agreed:
|
|
206
|
+
problems.append("consent.agreed must be true (read the consent text first)")
|
|
207
|
+
files = None
|
|
208
|
+
if c.authorization_file:
|
|
209
|
+
f = _resolve(base, c.authorization_file)
|
|
210
|
+
try:
|
|
211
|
+
files = {"authorization": (f.name, f.read_bytes())}
|
|
212
|
+
except OSError as e:
|
|
213
|
+
problems.append(f"consent.authorization_file: cannot read it ({type(e).__name__})")
|
|
214
|
+
elif c.basis == "authorized_agent":
|
|
215
|
+
problems.append("consent.authorization_file is required for authorized_agent")
|
|
216
|
+
step("consent", {"basis": c.basis, "attested_by": c.signed_name,
|
|
217
|
+
"ack": "1" if c.agreed else ""}, files)
|
|
218
|
+
|
|
219
|
+
pp = cfg.person
|
|
220
|
+
step("name", {"first": pp.name.first, "middle": pp.name.middle or "",
|
|
221
|
+
"last": pp.name.last, "suffix": pp.name.suffix or "",
|
|
222
|
+
"label": pp.label or "", "nicknames": "\n".join(pp.nicknames),
|
|
223
|
+
"previous_names": "\n".join(pp.previous_names)})
|
|
224
|
+
addr: dict[str, Any] = {}
|
|
225
|
+
for i, a in enumerate(pp.addresses):
|
|
226
|
+
addr.update({f"street{i}": a.street or "", f"city{i}": a.city,
|
|
227
|
+
f"state{i}": a.state, f"zip{i}": a.zip or ""})
|
|
228
|
+
cur = next((i for i, a in enumerate(pp.addresses) if a.current), 0)
|
|
229
|
+
addr["current"] = str(cur)
|
|
230
|
+
step("address", addr)
|
|
231
|
+
about: dict[str, Any] = {"phones": "\n".join(pp.phones), "emails": "\n".join(pp.emails),
|
|
232
|
+
"relatives": "\n".join(pp.relatives)}
|
|
233
|
+
if pp.birth_year is not None:
|
|
234
|
+
if pp.birth_year > date.today().year:
|
|
235
|
+
problems.append("person.birth_year is in the future")
|
|
236
|
+
about["birth_year"] = str(pp.birth_year)
|
|
237
|
+
elif pp.age is not None:
|
|
238
|
+
about["age"] = str(pp.age)
|
|
239
|
+
step("about", about)
|
|
240
|
+
perm = cfg.permissions
|
|
241
|
+
step("permissions", {k: _flag(getattr(perm, k)) for k in
|
|
242
|
+
("allow_id_upload", "allow_account_creation",
|
|
243
|
+
"allow_phone_disclosure")})
|
|
244
|
+
|
|
245
|
+
mb = cfg.mailbox
|
|
246
|
+
if mb is None:
|
|
247
|
+
step("mailbox", {"provider": "skip"})
|
|
248
|
+
else:
|
|
249
|
+
try:
|
|
250
|
+
pw = read_password(mb, base)
|
|
251
|
+
except SetupFileError as e:
|
|
252
|
+
problems.extend(e.problems)
|
|
253
|
+
pw = ""
|
|
254
|
+
if mb.tls_cert_sha256:
|
|
255
|
+
d.tls_pin = mb.tls_cert_sha256.replace(":", "").lower()
|
|
256
|
+
form: dict[str, Any] = {"provider": mb.provider, "address": mb.address,
|
|
257
|
+
"username": mb.username or "", "password": pw}
|
|
258
|
+
hosts = ("imap_host", "smtp_host", "imap_port", "smtp_port", "imap_ssl",
|
|
259
|
+
"imap_starttls", "smtp_starttls")
|
|
260
|
+
if mb.provider == "other" or any(getattr(mb, k) is not None for k in hosts):
|
|
261
|
+
preset = dr.PRESETS[mb.provider]
|
|
262
|
+
form["custom_hosts"] = "1"
|
|
263
|
+
for k in hosts:
|
|
264
|
+
v = getattr(mb, k)
|
|
265
|
+
v = preset.get(k) if v is None else v
|
|
266
|
+
form[k] = _flag(v) if isinstance(v, bool) or k.endswith(("ssl", "tls")) \
|
|
267
|
+
else ("" if v is None else str(v))
|
|
268
|
+
if pw:
|
|
269
|
+
step("mailbox", form)
|
|
270
|
+
for i, msg in enumerate(problems):
|
|
271
|
+
if msg.startswith("mailbox:") and "security code" in msg:
|
|
272
|
+
problems[i] = ("mailbox: Proton Mail Bridge needs tls_cert_sha256 (approve "
|
|
273
|
+
"the certificate once in the wizard, then copy its fingerprint)")
|
|
274
|
+
step("email", {"email_mode": cfg.email_mode})
|
|
275
|
+
step("schedule", {"weekly_recheck": _flag(cfg.schedule.weekly_recheck)})
|
|
276
|
+
d.weekday, d.hour = cfg.schedule.weekday, cfg.schedule.hour
|
|
277
|
+
if problems:
|
|
278
|
+
raise SetupFileError(problems)
|
|
279
|
+
return d
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def plan_lines(cfg: SetupConfig) -> list[str]:
|
|
283
|
+
"""What will happen, in plain words. Shows only what the person wrote to be shown."""
|
|
284
|
+
from .wizard import draft as dr
|
|
285
|
+
pp = cfg.person
|
|
286
|
+
label = pp.label or pp.name.first
|
|
287
|
+
lines = [f"Free My ID will save one person on this computer: {label}.",
|
|
288
|
+
f" {len(pp.addresses)} address(es), {len(pp.phones)} phone(s), "
|
|
289
|
+
f"{len(pp.emails)} email(s), {len(pp.relatives)} relative(s); "
|
|
290
|
+
+ ("birth year given." if pp.birth_year else
|
|
291
|
+
"age given." if pp.age is not None else "no birth year."),
|
|
292
|
+
" Removal mailbox: " + (cfg.mailbox.provider if cfg.mailbox else
|
|
293
|
+
"none (email removals stay off)") + ".",
|
|
294
|
+
" Emails to brokers: " + ("sent automatically" if cfg.email_mode == "auto"
|
|
295
|
+
else "each one waits for your approval") + ".",
|
|
296
|
+
" Background re-checks: " + (
|
|
297
|
+
("weekly; the job will be installed now" if cfg.schedule.install_job
|
|
298
|
+
else "weekly once you run `freemyid schedule install`")
|
|
299
|
+
if cfg.schedule.weekly_recheck else "off") + ".",
|
|
300
|
+
(" Practice mode stays as it is: nothing is sent until real submissions are "
|
|
301
|
+
"enabled (`freemyid config enable-submissions`)."),
|
|
302
|
+
"",
|
|
303
|
+
f"Permission ({dr.BASIS_LABELS[cfg.consent.basis]}):"]
|
|
304
|
+
lines += [f" - {t}" for t in dr.CONSENT_TEXT]
|
|
305
|
+
lines.append(f"Signed: {cfg.consent.signed_name}")
|
|
306
|
+
return lines
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def find_existing(store: Any, label: str) -> Any:
|
|
310
|
+
k = label.strip().lower()
|
|
311
|
+
return next((p for p in store.list_profiles() if p.label.strip().lower() == k), None)
|
freemyid/state.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Per (profile, broker) state machine and escalation policy.
|
|
2
|
+
|
|
3
|
+
OWNER: orchestrator. Pure functions only: no I/O, fully deterministic, easy to test.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
from .models import Broker, BrokerStatus
|
|
12
|
+
from .models import ListingState as S
|
|
13
|
+
|
|
14
|
+
GRACE_DAYS = 15
|
|
15
|
+
|
|
16
|
+
# Allowed transitions. NEEDS_HUMAN and ERROR are reachable from anywhere and can return to
|
|
17
|
+
# any non-terminal state once resolved, so they are handled in `can_transition`.
|
|
18
|
+
TRANSITIONS: dict[S, set[S]] = {
|
|
19
|
+
S.UNKNOWN: {S.NOT_FOUND, S.FOUND},
|
|
20
|
+
S.NOT_FOUND: {S.NOT_FOUND, S.FOUND, S.REAPPEARED},
|
|
21
|
+
S.FOUND: {S.FOUND, S.SUBMITTED, S.NOT_FOUND},
|
|
22
|
+
S.SUBMITTED: {S.SUBMITTED, S.AWAITING_VERIFICATION, S.CONFIRMED, S.REMOVED},
|
|
23
|
+
S.AWAITING_VERIFICATION: {S.AWAITING_VERIFICATION, S.CONFIRMED, S.SUBMITTED, S.REMOVED},
|
|
24
|
+
S.CONFIRMED: {S.CONFIRMED, S.REMOVED, S.SUBMITTED},
|
|
25
|
+
S.REMOVED: {S.REMOVED, S.REAPPEARED},
|
|
26
|
+
S.REAPPEARED: {S.REAPPEARED, S.SUBMITTED, S.REMOVED},
|
|
27
|
+
}
|
|
28
|
+
_ANYWHERE = {S.NEEDS_HUMAN, S.ERROR}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TransitionError(ValueError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def can_transition(src: S, dst: S) -> bool:
|
|
36
|
+
if dst in _ANYWHERE:
|
|
37
|
+
return True
|
|
38
|
+
if src in _ANYWHERE:
|
|
39
|
+
return dst != S.UNKNOWN
|
|
40
|
+
return dst in TRANSITIONS.get(src, set())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def transition(status: BrokerStatus, dst: S, now: datetime) -> BrokerStatus:
|
|
44
|
+
"""Move to `dst`. Entering SUBMITTED from another state counts as a send.
|
|
45
|
+
|
|
46
|
+
A rescan that leaves the state unchanged (SUBMITTED -> SUBMITTED) does NOT re-stamp
|
|
47
|
+
`last_submitted_at`/`attempts`, otherwise escalation would never become due (SEC-01).
|
|
48
|
+
A real resubmission of an already-SUBMITTED listing must call `mark_submitted`.
|
|
49
|
+
"""
|
|
50
|
+
if not can_transition(status.state, dst):
|
|
51
|
+
raise TransitionError(f"{status.state} -> {dst} not allowed")
|
|
52
|
+
upd = status.model_copy(update={"state": dst, "updated_at": now})
|
|
53
|
+
if dst == S.SUBMITTED and status.state != S.SUBMITTED:
|
|
54
|
+
upd.last_submitted_at = now
|
|
55
|
+
upd.attempts = status.attempts + 1
|
|
56
|
+
if dst != S.NEEDS_HUMAN:
|
|
57
|
+
upd.needs_human_reason = None
|
|
58
|
+
if dst in (S.REMOVED, S.NOT_FOUND):
|
|
59
|
+
upd.escalation_level = 0
|
|
60
|
+
upd.last_escalated_at = None
|
|
61
|
+
return upd
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def mark_submitted(status: BrokerStatus, now: datetime) -> BrokerStatus:
|
|
65
|
+
"""Record a real send (first submission or resubmission). Call only after a send succeeded."""
|
|
66
|
+
if not can_transition(status.state, S.SUBMITTED):
|
|
67
|
+
raise TransitionError(f"{status.state} -> {S.SUBMITTED} not allowed")
|
|
68
|
+
return status.model_copy(update={
|
|
69
|
+
"state": S.SUBMITTED, "updated_at": now, "last_submitted_at": now,
|
|
70
|
+
"attempts": status.attempts + 1, "needs_human_reason": None,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def after_scan(status: BrokerStatus, found: bool, now: datetime) -> S:
|
|
75
|
+
"""Which state a scan result moves us to (caller then calls `transition`)."""
|
|
76
|
+
st = status.state
|
|
77
|
+
if st in (S.UNKNOWN, S.NOT_FOUND, S.ERROR, S.NEEDS_HUMAN) and status.last_submitted_at is None:
|
|
78
|
+
return S.FOUND if found else S.NOT_FOUND
|
|
79
|
+
if st == S.REMOVED:
|
|
80
|
+
return S.REAPPEARED if found else S.REMOVED
|
|
81
|
+
if st in (S.SUBMITTED, S.AWAITING_VERIFICATION, S.CONFIRMED):
|
|
82
|
+
return st if found else S.REMOVED
|
|
83
|
+
if st == S.REAPPEARED:
|
|
84
|
+
return S.REAPPEARED if found else S.REMOVED
|
|
85
|
+
if st == S.FOUND:
|
|
86
|
+
return S.FOUND if found else S.NOT_FOUND
|
|
87
|
+
# NOT_FOUND / NEEDS_HUMAN / ERROR after a prior submission: a listing that is visible
|
|
88
|
+
# again goes back on the submit path (nothing was sent from these states).
|
|
89
|
+
if found:
|
|
90
|
+
return S.REAPPEARED
|
|
91
|
+
return S.NOT_FOUND if st == S.NOT_FOUND else S.REMOVED
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def should_submit(state: S) -> bool:
|
|
95
|
+
return state in (S.FOUND, S.REAPPEARED)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --------------------------------------------------------------------------- escalation
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Escalation(StrEnum):
|
|
102
|
+
NONE = "none"
|
|
103
|
+
RESUBMIT = "resubmit" # level 1
|
|
104
|
+
FOLLOW_UP = "follow_up" # level 2 (state-law follow-up email)
|
|
105
|
+
LEGAL_DEMAND = "legal_demand" # level 3 (formal demand email, always needs approval)
|
|
106
|
+
COMPLAINT = "complaint" # level 4 (todo with AG / CPPA links)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_LADDER = [Escalation.RESUBMIT, Escalation.FOLLOW_UP, Escalation.LEGAL_DEMAND, Escalation.COMPLAINT]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class EscalationDecision:
|
|
114
|
+
action: Escalation
|
|
115
|
+
new_level: int
|
|
116
|
+
due_at: datetime | None # when this step became due (None if nothing to do)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def escalation_window(broker: Broker) -> timedelta:
|
|
120
|
+
return timedelta(days=broker.typical_processing_days + GRACE_DAYS)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def next_escalation(status: BrokerStatus, broker: Broker, now: datetime) -> EscalationDecision:
|
|
124
|
+
"""If a submitted request has been ignored, decide the next step.
|
|
125
|
+
|
|
126
|
+
Rung n+1 becomes due one window (processing days + 15) after the later of the last
|
|
127
|
+
submission and the last escalation. Caller applies the decision and sets
|
|
128
|
+
`escalation_level=new_level, last_escalated_at=now`. Removal resets the ladder.
|
|
129
|
+
"""
|
|
130
|
+
if status.state not in (S.SUBMITTED, S.AWAITING_VERIFICATION, S.CONFIRMED):
|
|
131
|
+
return EscalationDecision(Escalation.NONE, status.escalation_level, None)
|
|
132
|
+
if status.last_submitted_at is None or status.escalation_level >= len(_LADDER):
|
|
133
|
+
return EscalationDecision(Escalation.NONE, status.escalation_level, None)
|
|
134
|
+
anchor = max(t for t in (status.last_submitted_at, status.last_escalated_at) if t)
|
|
135
|
+
due = anchor + escalation_window(broker)
|
|
136
|
+
if now < due:
|
|
137
|
+
return EscalationDecision(Escalation.NONE, status.escalation_level, None)
|
|
138
|
+
return EscalationDecision(_LADDER[status.escalation_level], status.escalation_level + 1, due)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def next_check_at(broker: Broker, state: S, now: datetime, weekly: bool = True) -> datetime:
|
|
142
|
+
"""When this broker should next be scanned for this profile."""
|
|
143
|
+
if state in (S.SUBMITTED, S.AWAITING_VERIFICATION, S.CONFIRMED):
|
|
144
|
+
days = max(3, min(broker.typical_processing_days, 14))
|
|
145
|
+
elif state in (S.REMOVED, S.NOT_FOUND):
|
|
146
|
+
days = broker.recheck_interval_days
|
|
147
|
+
else:
|
|
148
|
+
days = 7
|
|
149
|
+
if weekly:
|
|
150
|
+
days = min(days, 7) if state in (S.FOUND, S.REAPPEARED, S.NEEDS_HUMAN) else days
|
|
151
|
+
return now + timedelta(days=days)
|