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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Human handoff (CAPTCHA, ID upload, unexpected page, match review). Never solves anything.
|
|
2
|
+
|
|
3
|
+
* `QueuedHandoff` (scheduled / headless / non-tty): persist the Todo with a resume_token,
|
|
4
|
+
notify (PII-free), return False immediately. Never blocks.
|
|
5
|
+
* `InteractiveHandoff`: persist the Todo, open a HEADED browser on the same URL (cookies
|
|
6
|
+
carried over), notify, then wait until the user presses Enter, marks the todo done in the
|
|
7
|
+
dashboard, or the timeout passes. Falls back to queued behaviour when there is no tty or
|
|
8
|
+
no display.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
17
|
+
|
|
18
|
+
from ..models import Actor, Event, Todo
|
|
19
|
+
from ..models import ListingState as S
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from playwright.async_api import Page
|
|
23
|
+
|
|
24
|
+
from ..interfaces import Notifier
|
|
25
|
+
from ..storage import Store
|
|
26
|
+
from .browser import BrowserManager
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LogNotifier:
|
|
32
|
+
"""Fallback when freemyid.notify is not available. Body is PII-free by contract."""
|
|
33
|
+
|
|
34
|
+
def notify(self, title: str, body: str, url: str | None = None) -> None:
|
|
35
|
+
log.info("notify: %s: %s", title, body)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_notifier() -> Notifier:
|
|
39
|
+
try:
|
|
40
|
+
from ..notify import get_notifier as _gn # type: ignore[import-not-found]
|
|
41
|
+
return _gn()
|
|
42
|
+
except Exception: # noqa: BLE001 - module may not exist yet / no desktop session
|
|
43
|
+
return LogNotifier()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resume_token(todo: Todo) -> str:
|
|
47
|
+
return f"{todo.profile_id}:{todo.broker_id or '-'}:{todo.kind.value}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _existing_open(store: Store, todo: Todo) -> Todo | None:
|
|
51
|
+
for t in store.list_todos(todo.profile_id):
|
|
52
|
+
if t.broker_id == todo.broker_id and t.kind == todo.kind and t.request_id == \
|
|
53
|
+
todo.request_id:
|
|
54
|
+
return t
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def persist_todo(store: Store, todo: Todo) -> Todo:
|
|
59
|
+
"""Save a todo unless an identical open one exists (dedupe across scheduled runs)."""
|
|
60
|
+
if not todo.resume_token:
|
|
61
|
+
todo = todo.model_copy(update={"resume_token": resume_token(todo)})
|
|
62
|
+
cur = _existing_open(store, todo)
|
|
63
|
+
if cur:
|
|
64
|
+
return cur
|
|
65
|
+
store.save_todo(todo)
|
|
66
|
+
return todo
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
GENERIC_HELP = "Free My ID needs your help with 1 site. Open Free My ID to see it."
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class QueuedHandoff:
|
|
73
|
+
def __init__(self, store: Store, notifier: Notifier, label: str = "") -> None:
|
|
74
|
+
self.store, self.notifier, self.label = store, notifier, label
|
|
75
|
+
self.count = 0
|
|
76
|
+
|
|
77
|
+
async def request(self, todo: Todo, page: Page | None, timeout_s: int = 900) -> bool:
|
|
78
|
+
persist_todo(self.store, todo)
|
|
79
|
+
self.count += 1
|
|
80
|
+
# SEC-28: lock-screen text stays generic (no label, kind or site)
|
|
81
|
+
self.notifier.notify("Free My ID needs you", GENERIC_HELP)
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class InteractiveHandoff(QueuedHandoff):
|
|
86
|
+
def __init__(self, store: Store, notifier: Notifier, browser: BrowserManager | None,
|
|
87
|
+
label: str = "") -> None:
|
|
88
|
+
super().__init__(store, notifier, label)
|
|
89
|
+
self.browser = browser
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def can_block() -> bool:
|
|
93
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
94
|
+
return False
|
|
95
|
+
if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or
|
|
96
|
+
os.environ.get("WAYLAND_DISPLAY")):
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
async def request(self, todo: Todo, page: Page | None, timeout_s: int = 900) -> bool:
|
|
101
|
+
if not self.can_block():
|
|
102
|
+
return await super().request(todo, page, timeout_s)
|
|
103
|
+
todo = persist_todo(self.store, todo)
|
|
104
|
+
self.count += 1
|
|
105
|
+
headed_ctx = None
|
|
106
|
+
if page is not None and self.browser is not None:
|
|
107
|
+
try:
|
|
108
|
+
state = await page.context.storage_state()
|
|
109
|
+
hosts = sorted(getattr(page.context, "freemyid_hosts", set()))
|
|
110
|
+
headed_ctx = await self.browser.new_context(hosts, storage_state=state,
|
|
111
|
+
headed=True)
|
|
112
|
+
hp = await headed_ctx.new_page()
|
|
113
|
+
await self.browser.goto(hp, page.url, check_challenge=False)
|
|
114
|
+
except Exception as e: # noqa: BLE001
|
|
115
|
+
log.warning("could not open visible browser: %s", type(e).__name__)
|
|
116
|
+
self.notifier.notify("Free My ID needs you", GENERIC_HELP)
|
|
117
|
+
print(f"\n[Free My ID] {todo.title}\n {todo.body}\n"
|
|
118
|
+
" Finish it in the browser window, then press Enter here "
|
|
119
|
+
f"(or wait {timeout_s // 60} min to skip).", flush=True)
|
|
120
|
+
done = await self._wait(todo, timeout_s)
|
|
121
|
+
if headed_ctx is not None:
|
|
122
|
+
try:
|
|
123
|
+
if page is not None and done:
|
|
124
|
+
# carry the solved session back to the automated page's context
|
|
125
|
+
await page.context.add_cookies(cast(Any, await headed_ctx.cookies()))
|
|
126
|
+
await headed_ctx.close()
|
|
127
|
+
except Exception as e: # noqa: BLE001
|
|
128
|
+
log.debug("closing visible browser failed: %s", type(e).__name__)
|
|
129
|
+
if done:
|
|
130
|
+
self.store.complete_todo(todo.id)
|
|
131
|
+
return done
|
|
132
|
+
|
|
133
|
+
async def _wait(self, todo: Todo, timeout_s: int) -> bool:
|
|
134
|
+
loop = asyncio.get_running_loop()
|
|
135
|
+
enter = loop.run_in_executor(None, sys.stdin.readline)
|
|
136
|
+
deadline = loop.time() + timeout_s
|
|
137
|
+
while loop.time() < deadline:
|
|
138
|
+
if enter.done():
|
|
139
|
+
return True
|
|
140
|
+
if all(t.id != todo.id for t in self.store.list_todos(todo.profile_id)):
|
|
141
|
+
return True # resolved from the dashboard
|
|
142
|
+
await asyncio.sleep(1.0)
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def make_handoff(store: Store, notifier: Notifier, interactive: bool,
|
|
147
|
+
browser: BrowserManager | None, label: str = "") -> QueuedHandoff:
|
|
148
|
+
if interactive:
|
|
149
|
+
return InteractiveHandoff(store, notifier, browser, label)
|
|
150
|
+
return QueuedHandoff(store, notifier, label)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------- resolution
|
|
154
|
+
# Helpers for the dashboard / CLI (wizard-packager) once a human has acted.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def confirm_review(store: Store, listing_id: str, profile_id: str, is_me: bool) -> None:
|
|
158
|
+
"""User answered a REVIEW_MATCH todo. A 'yes' promotes the listing to CONFIRMED (the
|
|
159
|
+
matcher keeps user confirmations on re-scan); 'no' leaves it ignored."""
|
|
160
|
+
from ..models import MatchLevel
|
|
161
|
+
for li in store.list_listings(profile_id):
|
|
162
|
+
if li.id == listing_id:
|
|
163
|
+
if is_me:
|
|
164
|
+
li = li.model_copy(update={
|
|
165
|
+
"match_level": MatchLevel.CONFIRMED,
|
|
166
|
+
"match_reasons": sorted({*li.match_reasons, "user_confirmed"})})
|
|
167
|
+
else:
|
|
168
|
+
li = li.model_copy(update={"match_level": MatchLevel.NO_MATCH,
|
|
169
|
+
"match_reasons": ["user_rejected"]})
|
|
170
|
+
store.save_listing(li)
|
|
171
|
+
st = store.get_status(profile_id, li.broker_id)
|
|
172
|
+
store.save_status(st.model_copy(update={"next_check_at": None})) # re-run soon
|
|
173
|
+
store.log(Event(profile_id=profile_id, broker_id=li.broker_id, actor=Actor.USER,
|
|
174
|
+
action="review_match", detail={"is_me": is_me}))
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def mark_manual_submitted(store: Store, profile_id: str, broker_id: str,
|
|
179
|
+
request_id: str | None = None) -> None:
|
|
180
|
+
"""User mailed the letter / made the call / finished the form by hand."""
|
|
181
|
+
from ..models import RequestStatus, utcnow
|
|
182
|
+
from ..state import transition
|
|
183
|
+
now = utcnow()
|
|
184
|
+
st = store.get_status(profile_id, broker_id)
|
|
185
|
+
new = transition(st, S.SUBMITTED, now)
|
|
186
|
+
store.save_status(new)
|
|
187
|
+
if request_id and (r := store.get_request(request_id)):
|
|
188
|
+
store.save_request(r.model_copy(update={"status": RequestStatus.SENT, "sent_at": now}))
|
|
189
|
+
store.log(Event(profile_id=profile_id, broker_id=broker_id, actor=Actor.USER,
|
|
190
|
+
action="manual_submitted", from_state=st.state, to_state=new.state))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_SUBMIT_KINDS = frozenset({"assisted_opt_out", "send_email_manually", "postal_letter",
|
|
194
|
+
"phone_call", "account_required"})
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def mark_resolved(store: Store, todo: Todo) -> None:
|
|
198
|
+
"""Dashboard "I did it" hook (wizard/server.py looks this up by name).
|
|
199
|
+
|
|
200
|
+
For hand-done opt-outs (Assisted card, manual email, letter, call) the broker becomes
|
|
201
|
+
SUBMITTED so the re-check and escalation clocks start. Follow-up/demand letters sent by
|
|
202
|
+
hand do not restart the clock. Other kinds just get closed by the caller.
|
|
203
|
+
"""
|
|
204
|
+
if todo.broker_id and (todo.resume_token or "").startswith("id_requested:"):
|
|
205
|
+
_resume_after_id_request(store, todo)
|
|
206
|
+
return
|
|
207
|
+
if not todo.broker_id or todo.kind.value not in _SUBMIT_KINDS:
|
|
208
|
+
return
|
|
209
|
+
tok = todo.resume_token or ""
|
|
210
|
+
if tok.count(":") >= 2: # manual_email:<broker>:<follow_up|legal_demand>
|
|
211
|
+
store.log(Event(profile_id=todo.profile_id, broker_id=todo.broker_id,
|
|
212
|
+
actor=Actor.USER, action="manual_letter_sent",
|
|
213
|
+
detail={"todo_kind": todo.kind.value}))
|
|
214
|
+
return
|
|
215
|
+
from ..state import can_transition
|
|
216
|
+
st = store.get_status(todo.profile_id, todo.broker_id)
|
|
217
|
+
if st.state == S.SUBMITTED or not can_transition(st.state, S.SUBMITTED):
|
|
218
|
+
return
|
|
219
|
+
if st.state in (S.UNKNOWN, S.NOT_FOUND):
|
|
220
|
+
return # nothing was found to remove; just close the to-do
|
|
221
|
+
mark_manual_submitted(store, todo.profile_id, todo.broker_id, todo.request_id)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resume_after_id_request(store: Store, todo: Todo) -> None:
|
|
225
|
+
"""The person dealt with a site's request for ID themselves: the site goes back to
|
|
226
|
+
SUBMITTED, so the re-check and escalation clocks restart from today."""
|
|
227
|
+
from ..models import utcnow
|
|
228
|
+
from ..state import transition
|
|
229
|
+
assert todo.broker_id is not None
|
|
230
|
+
st = store.get_status(todo.profile_id, todo.broker_id)
|
|
231
|
+
if st.state != S.NEEDS_HUMAN or st.last_submitted_at is None:
|
|
232
|
+
return
|
|
233
|
+
new = transition(st, S.SUBMITTED, utcnow())
|
|
234
|
+
store.save_status(new.model_copy(update={"needs_human_reason": None}))
|
|
235
|
+
store.log(Event(profile_id=todo.profile_id, broker_id=todo.broker_id, actor=Actor.USER,
|
|
236
|
+
action="id_request_resolved", from_state=st.state, to_state=S.SUBMITTED,
|
|
237
|
+
detail={"todo_kind": todo.kind.value}))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Letter / email / call-script rendering from jinja2 templates in freemyid/templates/.
|
|
2
|
+
|
|
3
|
+
`render(template, **vars) -> str` is the CLI-facing entry point (interfaces.py).
|
|
4
|
+
`letter_vars(...)` builds the variable set with DATA MINIMIZATION: only fields the broker's
|
|
5
|
+
listings already show (Listing.shown_fields) plus the removal mailbox are included.
|
|
6
|
+
|
|
7
|
+
Every email template starts with a `Subject: ...` line; `split_subject` separates it.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import date, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from jinja2 import FileSystemLoader, StrictUndefined
|
|
16
|
+
from jinja2.sandbox import SandboxedEnvironment
|
|
17
|
+
|
|
18
|
+
from ..models import Broker, Listing, Profile, ProfileField, StateLaw
|
|
19
|
+
from .matcher import STATE_NAMES
|
|
20
|
+
|
|
21
|
+
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
|
22
|
+
TEMPLATES = ("generic_removal", "ccpa", "state_privacy", "follow_up", "legal_demand",
|
|
23
|
+
"postal_letter", "phone_script")
|
|
24
|
+
_ALIASES = {"generic": "generic_removal", "cpra": "ccpa", "california": "ccpa",
|
|
25
|
+
"state": "state_privacy"}
|
|
26
|
+
|
|
27
|
+
_env = SandboxedEnvironment(loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
|
28
|
+
undefined=StrictUndefined, autoescape=False,
|
|
29
|
+
trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve(template: str) -> str:
|
|
33
|
+
name = template.removesuffix(".j2").removesuffix(".txt")
|
|
34
|
+
name = _ALIASES.get(name, name)
|
|
35
|
+
if name not in TEMPLATES:
|
|
36
|
+
raise ValueError(f"unknown template {template!r}")
|
|
37
|
+
return f"{name}.txt.j2"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render(template: str, **vars: Any) -> str:
|
|
41
|
+
"""Render a named template. Missing variables raise (StrictUndefined)."""
|
|
42
|
+
defaults: dict[str, Any] = {
|
|
43
|
+
"listing_urls": [], "city": None, "state": None, "age": None, "phone": None,
|
|
44
|
+
"statute_name": None, "citation": None, "response_days": None,
|
|
45
|
+
"original_request_date": None, "response_deadline": None, "reference": None,
|
|
46
|
+
"broker_postal": None, "complaint_urls": [], "today": date.today().isoformat(),
|
|
47
|
+
"is_agent": False, "agent_name": None, "street_address": None, "zip": None,
|
|
48
|
+
}
|
|
49
|
+
defaults.update(vars)
|
|
50
|
+
return _env.get_template(_resolve(template)).render(**defaults)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def split_subject(text: str) -> tuple[str, str]:
|
|
54
|
+
first, _, rest = text.partition("\n")
|
|
55
|
+
if first.lower().startswith("subject:"):
|
|
56
|
+
return first.split(":", 1)[1].strip(), rest.lstrip("\n")
|
|
57
|
+
return "Personal data deletion request", text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def choose_template(state_law: StateLaw | None) -> str:
|
|
61
|
+
"""Initial-request template family for a law that is ALREADY known to apply and be in
|
|
62
|
+
effect (see engine.outbox.effective_law): ccpa for CA, state_privacy for another state
|
|
63
|
+
with a statute, generic_removal otherwise."""
|
|
64
|
+
if state_law is None:
|
|
65
|
+
return "generic_removal"
|
|
66
|
+
if state_law.state.upper() == "CA":
|
|
67
|
+
return "ccpa"
|
|
68
|
+
return "state_privacy" if state_law.statute_name else "generic_removal"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def letter_vars(profile: Profile, broker: Broker, listings: list[Listing],
|
|
72
|
+
state_law: StateLaw | None = None, *,
|
|
73
|
+
original_request_date: datetime | None = None,
|
|
74
|
+
reference: str | None = None, today: date | None = None) -> dict[str, Any]:
|
|
75
|
+
"""Variables for templates, limited to what the listings already show.
|
|
76
|
+
|
|
77
|
+
If there are no listings (blind request to a non-searchable broker) only the name and
|
|
78
|
+
the removal mailbox are included.
|
|
79
|
+
"""
|
|
80
|
+
today = today or date.today()
|
|
81
|
+
shown: set[ProfileField] = {f for li in listings for f in li.shown_fields}
|
|
82
|
+
cur = next((a for a in profile.addresses if a.current), profile.addresses[0])
|
|
83
|
+
v: dict[str, Any] = {
|
|
84
|
+
# SEC-29: middle name / suffix only if the listing itself shows the middle name
|
|
85
|
+
"name": profile.name.full if ProfileField.MIDDLE_NAME in shown
|
|
86
|
+
else f"{profile.name.first} {profile.name.last}",
|
|
87
|
+
"broker_name": broker.name,
|
|
88
|
+
"listing_urls": [li.url for li in listings],
|
|
89
|
+
"mailbox": profile.mailbox.address if profile.mailbox else None,
|
|
90
|
+
"today": today.isoformat(),
|
|
91
|
+
"is_agent": profile.consent.basis.value != "self",
|
|
92
|
+
"agent_name": profile.consent.attested_by
|
|
93
|
+
if profile.consent.basis.value != "self" else None,
|
|
94
|
+
}
|
|
95
|
+
if ProfileField.CITY in shown:
|
|
96
|
+
v["city"] = cur.city
|
|
97
|
+
if ProfileField.STATE in shown:
|
|
98
|
+
v["state"] = cur.state
|
|
99
|
+
if ProfileField.AGE in shown:
|
|
100
|
+
v["age"] = profile.estimated_age(today)
|
|
101
|
+
if ProfileField.STREET_ADDRESS in shown and cur.street:
|
|
102
|
+
v["street_address"] = cur.street
|
|
103
|
+
if ProfileField.ZIP in shown and cur.zip:
|
|
104
|
+
v["zip"] = cur.zip
|
|
105
|
+
if ProfileField.PHONE in shown and profile.permissions.allow_phone_disclosure and \
|
|
106
|
+
profile.phones:
|
|
107
|
+
v["phone"] = profile.phones[0]
|
|
108
|
+
if state_law:
|
|
109
|
+
v["statute_name"] = state_law.statute_name
|
|
110
|
+
v["citation"] = state_law.citation
|
|
111
|
+
v["response_days"] = state_law.response_days
|
|
112
|
+
v["state_name"] = STATE_NAMES.get(state_law.state, state_law.state)
|
|
113
|
+
v["complaint_urls"] = [str(u) for u in [state_law.ag_complaint_url,
|
|
114
|
+
*state_law.other_complaint_urls] if u]
|
|
115
|
+
if original_request_date:
|
|
116
|
+
v["original_request_date"] = original_request_date.date().isoformat()
|
|
117
|
+
days = (state_law.response_days if state_law and state_law.response_days
|
|
118
|
+
else broker.typical_processing_days)
|
|
119
|
+
v["response_deadline"] = (original_request_date.date() +
|
|
120
|
+
timedelta(days=days)).isoformat()
|
|
121
|
+
if reference:
|
|
122
|
+
v["reference"] = reference
|
|
123
|
+
if broker.postal:
|
|
124
|
+
v["broker_postal"] = "\n".join([broker.postal.recipient, *broker.postal.lines])
|
|
125
|
+
return v
|