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.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -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)
@@ -0,0 +1,3 @@
1
+ from .db import Store
2
+
3
+ __all__ = ["Store"]