Spaces:
Sleeping
Sleeping
Download backend/databases.py from Success-dev/receipter-pro: direct link, hf CLI and curl.
- Browser
- Download file 37.9 kB
-
https://huggingface.co/spaces/Success-dev/receipter-pro/resolve/main/backend/databases.py
- Command line
-
hf download hf://spaces/Success-dev/receipter-pro/backend/databases.py
-
curl -L -o databases.py https://huggingface.co/spaces/Success-dev/receipter-pro/resolve/main/backend/databases.py
37.9 kB
| """ | |
| Audience "databases" β a parsed upload (CSV/XLSX/JSON), cleaned, classified, and normalized | |
| (see backend/normalize.py for exactly what "normalized" means here and why it stops short of | |
| blind full 5NF). Replaces the old flat backend/audiences.py contact list: a row now belongs to | |
| a named, typed database rather than floating in one flat per-org list. | |
| Column types: email, contact (phone), name, tag, bool, link, text (default). The auto- | |
| classifier runs once at upload time; a column's type can be changed later via | |
| update_column_type, which requires at least one existing value to conform to the new type's | |
| syntax and flags (not blocks) every row that doesn't. | |
| Encryption follows the same posture as the old contacts module (SRS FR-15): row data and | |
| lookup-table data are both encrypted at rest via backend.crypto_utils, same mechanism as | |
| mailbox credentials. | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| import io | |
| import json | |
| import re | |
| import uuid | |
| from datetime import datetime, timezone | |
| from backend import firestore_client as db | |
| from backend import normalize | |
| from backend.templating import split_items | |
| from backend import orgs | |
| from backend.crypto_utils import decrypt, encrypt | |
| COLUMN_TYPES: tuple[str, ...] = ("email", "contact", "name", "tag", "bool", "link", "date", "text") | |
| EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") | |
| # Sheets exports often prefix phone numbers with a stray apostrophe ('+234...) to stop them | |
| # being read as numbers; tolerate it rather than call the whole column text. | |
| PHONE_RE = re.compile(r"^'?\+?[\d][\d\s\-().]{5,}$") | |
| # Dates and timestamps: 2004-05-17, 2024/03/09 14:22:01 GMT+1, 17/05/2004, 17 May 2004, | |
| # 2025-01-02T10:00:00.000Z. Checked before PHONE_RE, which would otherwise swallow digits-and-dashes. | |
| _TIME = r"\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?(\s*(am|pm))?(\s*(z|gmt|utc|[a-z]{3})?\s*([+-]\d{1,2}(:?\d{2})?)?)?" | |
| DATE_RE = re.compile( | |
| rf"^(\d{{4}}[-/.]\d{{1,2}}[-/.]\d{{1,2}}([ T]{_TIME})?" | |
| rf"|\d{{1,2}}[-/.]\d{{1,2}}[-/.]\d{{2,4}}([ T]{_TIME})?" | |
| r"|\d{1,2}\s+[A-Za-z]{3,9}\.?,?\s+\d{4}" | |
| r"|[A-Za-z]{3,9}\.?\s+\d{1,2},?\s+\d{4})$", | |
| re.IGNORECASE, | |
| ) | |
| # "18 - 24", "25-34": a range of small numbers, not a phone number. | |
| RANGE_RE = re.compile(r"^\d{1,3}\s*(-|to|β)\s*\d{1,3}\+?$|^\d{1,3}\+$", re.IGNORECASE) | |
| URL_RE = re.compile(r"^(https?://|www\.)\S+$", re.IGNORECASE) | |
| BOOL_VALUES = {"true", "false", "yes", "no", "y", "n", "1", "0"} | |
| # None of these three ever raise for an empty cell β an unfilled field isn't a conformance | |
| # failure, only a filled-but-malformed one is. | |
| TYPE_VALIDATORS = { | |
| "email": lambda v: bool(EMAIL_RE.match(v.strip())), | |
| "contact": lambda v: bool(PHONE_RE.match(v.strip())) and not DATE_RE.match(v.strip()), | |
| "date": lambda v: bool(DATE_RE.match(v.strip())), | |
| "link": lambda v: bool(URL_RE.match(v.strip())), | |
| "bool": lambda v: v.strip().lower() in BOOL_VALUES, | |
| } | |
| class ValidationError(Exception): | |
| pass | |
| def _now() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def _database_path(org_id: str, database_id: str) -> str: | |
| return f"orgs/{org_id}/databases/{database_id}" | |
| def _row_path(org_id: str, database_id: str, row_id: str) -> str: | |
| return f"orgs/{org_id}/databases/{database_id}/rows/{row_id}" | |
| def _rows_prefix(org_id: str, database_id: str) -> str: | |
| return f"orgs/{org_id}/databases/{database_id}/rows/" | |
| def _lookup_path(org_id: str, database_id: str, lookup_name: str) -> str: | |
| return f"orgs/{org_id}/databases/{database_id}/lookups/{lookup_name}" | |
| # --- Parsing ------------------------------------------------------------------ | |
| def _parse_csv(file_bytes: bytes) -> tuple[list[str], list[dict]]: | |
| text = file_bytes.decode("utf-8-sig") | |
| reader = csv.DictReader(io.StringIO(text)) | |
| headers = list(reader.fieldnames or []) | |
| return headers, [dict(row) for row in reader] | |
| def _parse_json(file_bytes: bytes) -> tuple[list[str], list[dict]]: | |
| data = json.loads(file_bytes.decode("utf-8")) | |
| if isinstance(data, dict): | |
| data = data.get("rows") or data.get("data") or list(data.values()) | |
| if not isinstance(data, list) or not data or not all(isinstance(r, dict) for r in data): | |
| raise ValidationError("JSON upload must be a non-empty array of objects") | |
| headers = list(dict.fromkeys(k for row in data for k in row.keys())) | |
| return headers, [{h: row.get(h) for h in headers} for row in data] | |
| def _parse_xlsx(file_bytes: bytes) -> tuple[list[str], list[dict]]: | |
| import openpyxl # only needed for this one branch β keep the import lazy | |
| workbook = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True) | |
| sheet = workbook.active | |
| rows_iter = sheet.iter_rows(values_only=True) | |
| try: | |
| header_row = next(rows_iter) | |
| except StopIteration: | |
| raise ValidationError("Spreadsheet has no rows") | |
| headers = [str(h) if h is not None else "" for h in header_row] | |
| rows = [ | |
| {headers[i]: (r[i] if i < len(r) else None) for i in range(len(headers))} | |
| for r in rows_iter | |
| ] | |
| return headers, rows | |
| _PARSERS = {"csv": _parse_csv, "json": _parse_json, "xlsx": _parse_xlsx, "xls": _parse_xlsx} | |
| def _clean(headers: list[str], rows: list[dict]) -> tuple[list[str], list[dict]]: | |
| """Trims whitespace, drops blank/duplicate headers, drops fully-empty rows and columns.""" | |
| deduped: list[str] = [] | |
| seen: set[str] = set() | |
| for h in headers: | |
| h = (h or "").strip() | |
| if not h: | |
| continue | |
| base, i = h, 1 | |
| while h in seen: | |
| i += 1 | |
| h = f"{base}_{i}" | |
| seen.add(h) | |
| deduped.append(h) | |
| cleaned_rows = [] | |
| for row in rows: | |
| new_row = {h: (str(row.get(h)).strip() if row.get(h) not in (None, "") else "") for h in deduped} | |
| if any(new_row.values()): | |
| cleaned_rows.append(new_row) | |
| nonempty_cols = [h for h in deduped if any(r[h] for r in cleaned_rows)] | |
| cleaned_rows = [{h: r[h] for h in nonempty_cols} for r in cleaned_rows] | |
| return nonempty_cols, cleaned_rows | |
| # --- Classification ------------------------------------------------------------- | |
| def _match_ratio(values: list[str], predicate) -> float: | |
| nonempty = [v for v in values if v] | |
| if not nonempty: | |
| return 0.0 | |
| return sum(1 for v in nonempty if predicate(v)) / len(nonempty) | |
| _PERSON_HEADER_RE = re.compile(r"^(full |first |last |sur|given |your |participant |contact )?(name|surname|fullname)\b", re.IGNORECASE) | |
| def _looks_like_person_name_header(header: str) -> bool: | |
| """A header that merely *contains* "name" is often not a person ("Name of School / | |
| Institution", "Username", "Company name"): only headers that lead with name-ish words count.""" | |
| return bool(_PERSON_HEADER_RE.match(header.strip())) and not re.search( | |
| r"\b(of|school|institution|organi[sz]ation|company|business|user|file|team|group)\b", header, re.IGNORECASE | |
| ) | |
| def classify_column(header: str, values: list[str]) -> str: | |
| if _match_ratio(values, lambda v: bool(EMAIL_RE.match(v))) >= 0.6: | |
| return "email" | |
| if _match_ratio(values, lambda v: bool(URL_RE.match(v))) >= 0.6: | |
| return "link" | |
| if _match_ratio(values, lambda v: v.lower() in BOOL_VALUES) >= 0.9: | |
| return "bool" | |
| if _match_ratio(values, lambda v: bool(DATE_RE.match(v))) >= 0.6: | |
| return "date" | |
| if _match_ratio(values, lambda v: bool(PHONE_RE.match(v)) and not RANGE_RE.match(v)) >= 0.6: | |
| return "contact" | |
| if _match_ratio(values, lambda v: ";" in v) >= 0.3: | |
| return "tag" | |
| if _looks_like_person_name_header(header): | |
| return "name" | |
| return "text" | |
| LIST_DELIMITERS = (";", ",", "|", "\n", "mixed") | |
| def detect_list_delimiter(values: list[str]) -> str | None: | |
| """Looks for a separator that makes this column a list of short items ("Acme, Beta Ltd"). | |
| Returns the delimiter or None. Deliberately conservative: it only ever feeds a *suggestion* | |
| the user confirms, because commas also appear in names ("Smith, John") and sentences.""" | |
| nonempty = [v for v in values if v] | |
| if len(nonempty) < 2: | |
| return None | |
| def qualifies(delim: str) -> bool: | |
| parts = [split_items(v, delim) for v in nonempty if (delim in v if delim != "mixed" else re.search(r"[;,|\n]", v))] | |
| if len(parts) < 2 or len(parts) / len(nonempty) < 0.3: | |
| return False | |
| items = [i for p in parts for i in p] | |
| if not items or any(len(i) > 60 for i in items): | |
| return False | |
| # Sentences have long "items"; lists of names/events/tags have short ones. | |
| return sum(len(i.split()) for i in items) / len(items) <= 4 | |
| ratios = {d: sum(1 for v in nonempty if d in v) / len(nonempty) for d in (";", ",", "|", "\n")} | |
| if ratios[";"] >= 0.2 and ratios[","] >= 0.2 and qualifies("mixed"): | |
| return "mixed" | |
| for delim in sorted(ratios, key=lambda d: -ratios[d]): | |
| if ratios[delim] >= 0.3 and qualifies(delim): | |
| return delim | |
| return None | |
| def _has_conforming_value(values: list[str], new_type: str) -> bool: | |
| validator = TYPE_VALIDATORS.get(new_type) | |
| if validator is None: | |
| return True # name/tag/text have no syntax to conform to | |
| nonempty = [v for v in values if v] | |
| return not nonempty or any(validator(v) for v in nonempty) | |
| def _flagged_values(values: list[str], col_type: str) -> list[int]: | |
| validator = TYPE_VALIDATORS.get(col_type) | |
| if validator is None: | |
| return [] | |
| return [i for i, v in enumerate(values) if v and not validator(v)] | |
| def _assign_row_ids(rows: list[dict]) -> list[tuple[str, dict]]: | |
| return [(uuid.uuid4().hex, row) for row in rows] | |
| def _columns_with_flags(column_types: dict[str, str], headers: list[str], id_rows: list[tuple[str, dict]]) -> list[dict]: | |
| """Flags non-conforming values up front, at classification time β not only when a column's | |
| type is changed later. An auto-classified "email" column with one bad row should show that | |
| immediately, the same as a manual type change would.""" | |
| columns = [] | |
| for h in headers: | |
| values = [row.get(h, "") for _, row in id_rows] | |
| flagged_indices = _flagged_values(values, column_types[h]) | |
| col = { | |
| "name": h, | |
| "type": column_types[h], | |
| "flagged_row_ids": [id_rows[i][0] for i in flagged_indices], | |
| } | |
| if col["type"] == "tag": | |
| col["delimiter"] = detect_list_delimiter(values) or ";" | |
| elif col["type"] in ("text", "name"): | |
| suggested = detect_list_delimiter(values) | |
| if suggested: | |
| col["list_suggestion"] = suggested | |
| columns.append(col) | |
| return columns | |
| # --- Upload / create ------------------------------------------------------------ | |
| def create_database_from_upload( | |
| org_id: str, actor_uid: str, name: str, file_bytes: bytes, filename: str, | |
| save_copy=None, | |
| ) -> str: | |
| """save_copy(database_id, ext, file_bytes) -> None is injected rather than hardcoded so | |
| this stays unit-testable without touching disk; the real implementation lives in app.py's | |
| route (writes to uploads/{org_id}/{database_id}.{ext}).""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" | |
| parser = _PARSERS.get(ext) | |
| if parser is None: | |
| raise ValidationError(f"Unsupported file type {filename!r} β use .csv, .xlsx, or .json") | |
| headers, rows = parser(file_bytes) | |
| headers, rows = _clean(headers, rows) | |
| if not headers or not rows: | |
| raise ValidationError("No usable data found in that file after cleaning") | |
| column_types = {h: classify_column(h, [r[h] for r in rows]) for h in headers} | |
| identity_columns = {h for h in headers if column_types[h] in ("email", "contact")} | |
| result = normalize.normalize(rows, headers, exclude_determinants=identity_columns, auto_split=False) | |
| database_id = uuid.uuid4().hex | |
| if save_copy: | |
| save_copy(database_id, ext, file_bytes) | |
| id_rows = _assign_row_ids(result.root.rows) | |
| columns = _columns_with_flags(column_types, result.root.columns, id_rows) | |
| db.set_doc(_database_path(org_id, database_id), { | |
| "name": name, | |
| "source_filename": filename, | |
| "source_kind": "xlsx" if ext in ("xlsx", "xls") else ext, | |
| "created_at": _now(), | |
| "row_count": len(result.root.rows), | |
| "columns": columns, | |
| "lookup_tables": [{"name": t.name, "columns": t.columns} for t in result.lookups], | |
| "suggestions": result.suggestions, | |
| }) | |
| for lookup in result.lookups: | |
| db.set_doc(_lookup_path(org_id, database_id, lookup.name), { | |
| "columns": lookup.columns, | |
| "encrypted_rows": encrypt(json.dumps(lookup.rows)), | |
| }) | |
| for row_id, row in id_rows: | |
| db.set_doc(_row_path(org_id, database_id, row_id), {"encrypted_data": encrypt(json.dumps(row))}) | |
| return database_id | |
| def get_database(org_id: str, database_id: str) -> dict: | |
| doc = db.get_doc(_database_path(org_id, database_id)) | |
| if doc is None: | |
| raise orgs.NotFoundError(f"No database {database_id!r} in org {org_id!r}") | |
| return doc | |
| def list_databases(org_id: str) -> dict[str, dict]: | |
| prefix = f"orgs/{org_id}/databases/" | |
| docs = db.query_prefix(prefix) | |
| return {path[len(prefix):]: data for path, data in docs.items()} | |
| def list_rows(org_id: str, database_id: str, search: str | None = None, sort: str | None = None) -> list[dict]: | |
| """Every row, decrypted, with its row_id β client-side-sized (prototype scale), so search | |
| and sort both happen here in Python rather than needing a real query engine.""" | |
| prefix = _rows_prefix(org_id, database_id) | |
| docs = db.query_prefix(prefix) | |
| rows = [] | |
| for path, data in docs.items(): | |
| row = json.loads(decrypt(data["encrypted_data"])) | |
| row["row_id"] = path[len(prefix):] | |
| rows.append(row) | |
| if search: | |
| needle = search.strip().lower() | |
| rows = [r for r in rows if any(needle in str(v).lower() for v in r.values())] | |
| if sort: | |
| rows.sort(key=lambda r: (r.get(sort) is None, str(r.get(sort, "")).lower())) | |
| return rows | |
| # Operators a segment condition can use. Which ones make sense depends on the column's type β | |
| # SEGMENT_OPERATORS_BY_TYPE below is what the frontend uses to only offer valid ones, but | |
| # _row_matches tolerates any operator against any column rather than erroring, since a changed | |
| # column type shouldn't break a condition written before the change. | |
| SEGMENT_OPERATORS_BY_TYPE: dict[str, tuple[str, ...]] = { | |
| "bool": ("is_true", "is_false", "is_empty", "is_not_empty"), | |
| "tag": ("has_tag", "lacks_tag", "is_empty", "is_not_empty"), | |
| } | |
| _DEFAULT_OPERATORS = ("equals", "not_equals", "contains", "not_contains", "is_empty", "is_not_empty") | |
| def _row_matches(row: dict, col_type: str, field: str, op: str, value: str, delimiter: str | None = None) -> bool: | |
| text = str(row.get(field, "") or "") | |
| if op == "is_empty": | |
| return text.strip() == "" | |
| if op == "is_not_empty": | |
| return text.strip() != "" | |
| if col_type == "bool": | |
| truthy = text.strip().lower() in {"true", "yes", "y", "1"} | |
| if op == "is_true": | |
| return truthy | |
| if op == "is_false": | |
| return not truthy | |
| if col_type == "tag": | |
| tags = {t.lower() for t in split_items(text, delimiter)} | |
| needle = value.strip().lower() | |
| if op == "has_tag": | |
| return needle in tags | |
| if op == "lacks_tag": | |
| return needle not in tags | |
| needle = value.strip().lower() | |
| hay = text.strip().lower() | |
| if op == "equals": | |
| return hay == needle | |
| if op == "not_equals": | |
| return hay != needle | |
| if op == "contains": | |
| return needle in hay | |
| if op == "not_contains": | |
| return needle not in hay | |
| return True | |
| def apply_segment(rows: list[dict], columns: list[dict], conditions: list[dict]) -> list[dict]: | |
| """Narrows `rows` to only those matching every condition (AND). Each condition is | |
| {field, op, value}; unknown fields match nothing (a stale condition from a since-removed | |
| column shouldn't silently widen a send back out to everyone).""" | |
| col_types = {c["name"]: c["type"] for c in columns} | |
| delimiters = {c["name"]: c.get("delimiter") for c in columns} | |
| matched = rows | |
| for cond in conditions: | |
| field = cond.get("field", "") | |
| if field not in col_types: | |
| return [] | |
| op = cond.get("op", "") | |
| value = str(cond.get("value", "")) | |
| col_type = col_types[field] | |
| matched = [r for r in matched if _row_matches(r, col_type, field, op, value, delimiters[field])] | |
| return matched | |
| def preview_segment(org_id: str, database_id: str, conditions: list[dict]) -> dict: | |
| """Live "how many recipients does this match" count for the campaign segment builder, plus | |
| a small sample so the UI can show who, not just how many.""" | |
| database = get_database(org_id, database_id) | |
| rows = list_rows(org_id, database_id) | |
| matched = apply_segment(rows, database["columns"], conditions) | |
| return {"matched": len(matched), "sample": matched[:5]} | |
| def add_row(org_id: str, actor_uid: str, database_id: str, values: dict) -> str: | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| return insert_row(org_id, database_id, values) | |
| def insert_row(org_id: str, database_id: str, values: dict) -> str: | |
| """add_row without the role check β for callers that have already authorized the insert | |
| some other way (backend/collect.py: a confirmed double opt-in, not a signed-in user).""" | |
| get_database(org_id, database_id) # raises NotFoundError if missing | |
| row_id = uuid.uuid4().hex | |
| db.set_doc(_row_path(org_id, database_id, row_id), {"encrypted_data": encrypt(json.dumps(values))}) | |
| database = get_database(org_id, database_id) | |
| db.update_doc(_database_path(org_id, database_id), {"row_count": database["row_count"] + 1}) | |
| known = {c["name"] for c in database["columns"]} | |
| new_cols = [k for k in values if k not in known] | |
| if new_cols: | |
| updated_columns = database["columns"] + [{"name": k, "type": "text", "flagged_row_ids": []} for k in new_cols] | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated_columns}) | |
| return row_id | |
| def update_row(org_id: str, actor_uid: str, database_id: str, row_id: str, tags: dict) -> None: | |
| """Merges `tags` into the row's existing values. Any key not already a known column is | |
| appended to the database's column list (type "text") β a manually-added tag becomes a | |
| real, placeholder-usable column for the whole database, matching how the CSV-derived | |
| columns work, not a side channel.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| path = _row_path(org_id, database_id, row_id) | |
| doc = db.get_doc(path) | |
| if doc is None: | |
| raise orgs.NotFoundError(f"No row {row_id!r} in database {database_id!r}") | |
| row = json.loads(decrypt(doc["encrypted_data"])) | |
| row.update(tags) | |
| db.set_doc(path, {"encrypted_data": encrypt(json.dumps(row))}) | |
| database = get_database(org_id, database_id) | |
| known = {c["name"] for c in database["columns"]} | |
| new_cols = [k for k in tags if k not in known] | |
| if new_cols: | |
| updated_columns = database["columns"] + [{"name": k, "type": "text", "flagged_row_ids": []} for k in new_cols] | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated_columns}) | |
| def bulk_update_rows(org_id: str, actor_uid: str, database_id: str, row_ids: list[str], tags: dict) -> list[str]: | |
| """Same merge semantics as update_row, applied to many rows in one call β the mass "fill a | |
| column" / "add a tag to the selection" grid actions need this, since looping update_row | |
| client-side would mean one HTTP round trip per selected row (unusable at a few thousand | |
| rows). Fetches the database doc once up front and updates it once at the end, rather than | |
| once per row like update_row does. Silently skips a row_id that no longer exists (e.g. a | |
| stale selection from before a delete) instead of failing the whole batch over it.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| database = get_database(org_id, database_id) | |
| updated: list[str] = [] | |
| for row_id in row_ids: | |
| path = _row_path(org_id, database_id, row_id) | |
| doc = db.get_doc(path) | |
| if doc is None: | |
| continue | |
| row = json.loads(decrypt(doc["encrypted_data"])) | |
| row.update(tags) | |
| db.set_doc(path, {"encrypted_data": encrypt(json.dumps(row))}) | |
| updated.append(row_id) | |
| known = {c["name"] for c in database["columns"]} | |
| new_cols = [k for k in tags if k not in known] | |
| if new_cols: | |
| updated_columns = database["columns"] + [{"name": k, "type": "text", "flagged_row_ids": []} for k in new_cols] | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated_columns}) | |
| return updated | |
| def bulk_append_tag( | |
| org_id: str, actor_uid: str, database_id: str, row_ids: list[str], tag: str, tag_column: str = "Tags", | |
| ) -> list[str]: | |
| """Appends `tag` to the existing semicolon-delimited tag_column value for each row, rather | |
| than overwriting it like bulk_update_rows does β each row keeps whatever tags it already | |
| had, so this can't be expressed as one uniform PATCH value the way a column "fill" can. | |
| Still a single HTTP call for the whole selection, looping server-side rather than once per | |
| row over the network.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| database = get_database(org_id, database_id) | |
| updated: list[str] = [] | |
| for row_id in row_ids: | |
| path = _row_path(org_id, database_id, row_id) | |
| doc = db.get_doc(path) | |
| if doc is None: | |
| continue | |
| row = json.loads(decrypt(doc["encrypted_data"])) | |
| existing = [t for t in str(row.get(tag_column, "")).split(";") if t] | |
| if tag not in existing: | |
| existing.append(tag) | |
| row[tag_column] = ";".join(existing) | |
| db.set_doc(path, {"encrypted_data": encrypt(json.dumps(row))}) | |
| updated.append(row_id) | |
| known = {c["name"] for c in database["columns"]} | |
| if tag_column not in known: | |
| updated_columns = database["columns"] + [{"name": tag_column, "type": "tag", "flagged_row_ids": []}] | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated_columns}) | |
| return updated | |
| def delete_rows(org_id: str, actor_uid: str, database_id: str, row_ids: list[str]) -> int: | |
| """Bulk delete for the grid's mass-selection "Delete" action β same one-call-not-N | |
| reasoning as bulk_update_rows. Returns the number actually deleted (stale ids are skipped, | |
| not errors) so the caller can show an accurate count.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| database = get_database(org_id, database_id) | |
| deleted = 0 | |
| for row_id in row_ids: | |
| path = _row_path(org_id, database_id, row_id) | |
| if db.get_doc(path) is None: | |
| continue | |
| db.delete_doc(path) | |
| deleted += 1 | |
| if deleted: | |
| db.update_doc(_database_path(org_id, database_id), {"row_count": max(0, database["row_count"] - deleted)}) | |
| return deleted | |
| def bulk_set_values(org_id: str, actor_uid: str, database_id: str, updates: dict[str, dict]) -> list[str]: | |
| """Like bulk_update_rows, but each row gets its OWN value map rather than one uniform | |
| value for everyone. This is what undoing a "fill column for N rows" needs β each row's | |
| prior value was (generally) different before the fill overwrote it, so restoring them | |
| can't be expressed as a single uniform PATCH the way the fill itself was.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| database = get_database(org_id, database_id) | |
| updated: list[str] = [] | |
| all_new_keys: set[str] = set() | |
| for row_id, values in updates.items(): | |
| path = _row_path(org_id, database_id, row_id) | |
| doc = db.get_doc(path) | |
| if doc is None: | |
| continue | |
| row = json.loads(decrypt(doc["encrypted_data"])) | |
| row.update(values) | |
| db.set_doc(path, {"encrypted_data": encrypt(json.dumps(row))}) | |
| updated.append(row_id) | |
| all_new_keys.update(values.keys()) | |
| known = {c["name"] for c in database["columns"]} | |
| new_cols = [k for k in all_new_keys if k not in known] | |
| if new_cols: | |
| updated_columns = database["columns"] + [{"name": k, "type": "text", "flagged_row_ids": []} for k in new_cols] | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated_columns}) | |
| return updated | |
| def remove_column(org_id: str, actor_uid: str, database_id: str, column: str) -> dict: | |
| """Drops a column from the schema and strips it from every row. Returns a snapshot | |
| ({column def, values_by_row_id}) so the caller (the grid's undo stack) can restore it | |
| exactly via restore_column without a second round trip to reconstruct what was deleted.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| database = get_database(org_id, database_id) | |
| col_def = next((c for c in database["columns"] if c["name"] == column), None) | |
| if col_def is None: | |
| raise orgs.NotFoundError(f"No column {column!r} in database {database_id!r}") | |
| values_by_row_id: dict[str, str] = {} | |
| for row in list_rows(org_id, database_id): | |
| row_id = row["row_id"] | |
| if column not in row: | |
| continue | |
| values_by_row_id[row_id] = row[column] | |
| remaining = {k: v for k, v in row.items() if k not in (column, "row_id")} | |
| db.set_doc(_row_path(org_id, database_id, row_id), {"encrypted_data": encrypt(json.dumps(remaining))}) | |
| updated_columns = [c for c in database["columns"] if c["name"] != column] | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated_columns}) | |
| return {"column": col_def, "values_by_row_id": values_by_row_id} | |
| def restore_column(org_id: str, actor_uid: str, database_id: str, column_def: dict, values_by_row_id: dict[str, str]) -> None: | |
| """The inverse of remove_column β undo target for the grid's "Remove column" action.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| database = get_database(org_id, database_id) | |
| for row_id, value in values_by_row_id.items(): | |
| path = _row_path(org_id, database_id, row_id) | |
| doc = db.get_doc(path) | |
| if doc is None: | |
| continue | |
| row = json.loads(decrypt(doc["encrypted_data"])) | |
| row[column_def["name"]] = value | |
| db.set_doc(path, {"encrypted_data": encrypt(json.dumps(row))}) | |
| if not any(c["name"] == column_def["name"] for c in database["columns"]): | |
| db.update_doc(_database_path(org_id, database_id), {"columns": database["columns"] + [column_def]}) | |
| def rename_column(org_id: str, actor_uid: str, database_id: str, column: str, new_name: str) -> str: | |
| """Renames a column in the schema and re-keys it in every row, keeping column order. No | |
| values change, so renaming back is an exact undo. Saved presets and campaigns that use | |
| {OldName} keep the old text; the UI warns about that.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| new_name = " ".join(new_name.split()) | |
| if not new_name: | |
| raise ValidationError("A column name can't be empty") | |
| if new_name == "row_id": | |
| raise ValidationError("That name is reserved") | |
| database = get_database(org_id, database_id) | |
| if not any(c["name"] == column for c in database["columns"]): | |
| raise orgs.NotFoundError(f"No column {column!r} in database {database_id!r}") | |
| if new_name == column: | |
| return new_name | |
| if any(c["name"].lower() == new_name.lower() for c in database["columns"] if c["name"] != column): | |
| raise ValidationError(f"There is already a column called {new_name!r}") | |
| for row in list_rows(org_id, database_id): | |
| if column not in row: | |
| continue | |
| rekeyed = {(new_name if k == column else k): v for k, v in row.items() if k != "row_id"} | |
| db.set_doc(_row_path(org_id, database_id, row["row_id"]), {"encrypted_data": encrypt(json.dumps(rekeyed))}) | |
| updated_columns = [{**c, "name": new_name} if c["name"] == column else c for c in database["columns"]] | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated_columns}) | |
| return new_name | |
| def column_rename_suggestions(org_id: str, database_id: str) -> list[dict]: | |
| from backend import rename_suggest | |
| return rename_suggest.suggest_renames(get_database(org_id, database_id)["columns"]) | |
| def column_type_suggestions(org_id: str, database_id: str) -> list[dict]: | |
| """Columns whose stored type no longer matches what their values say (e.g. a date column | |
| that an older classifier called "contact"). Only ever a suggestion; never applied unasked. | |
| Only recommends moving between the plain, low-stakes types, so a column the person | |
| deliberately set to email, list or yes/no is left alone.""" | |
| database = get_database(org_id, database_id) | |
| rows = list_rows(org_id, database_id) | |
| suggestions = [] | |
| for col in database["columns"]: | |
| if col["type"] not in ("text", "contact", "name"): | |
| continue | |
| guess = classify_column(col["name"], [r.get(col["name"], "") for r in rows]) | |
| if guess != col["type"] and guess in ("text", "date", "contact", "name", "email", "link", "bool"): | |
| suggestions.append({"column": col["name"], "current": col["type"], "suggested": guess}) | |
| return suggestions | |
| def rename_database(org_id: str, actor_uid: str, database_id: str, new_name: str) -> None: | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| get_database(org_id, database_id) | |
| if not new_name.strip(): | |
| raise ValidationError("Name can't be empty") | |
| db.update_doc(_database_path(org_id, database_id), {"name": new_name.strip()}) | |
| def set_database_disabled(org_id: str, actor_uid: str, database_id: str, disabled: bool) -> None: | |
| """"Disabled" means excluded from campaign sends only β still fully visible and editable | |
| here. Doesn't cascade into blocking merges or anything else; that would be a separate, | |
| more surprising rule than what the name implies.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| get_database(org_id, database_id) | |
| db.update_doc(_database_path(org_id, database_id), {"disabled": disabled}) | |
| def delete_database(org_id: str, actor_uid: str, database_id: str) -> None: | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| get_database(org_id, database_id) | |
| for path in list(db.query_prefix(_rows_prefix(org_id, database_id)).keys()): | |
| db.delete_doc(path) | |
| lookups_prefix = f"orgs/{org_id}/databases/{database_id}/lookups/" | |
| for path in list(db.query_prefix(lookups_prefix).keys()): | |
| db.delete_doc(path) | |
| db.delete_doc(_database_path(org_id, database_id)) | |
| def duplicate_database(org_id: str, actor_uid: str, database_id: str, new_name: str) -> str: | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| database = get_database(org_id, database_id) | |
| rows = list_rows(org_id, database_id) | |
| new_id = uuid.uuid4().hex | |
| id_map = {r["row_id"]: uuid.uuid4().hex for r in rows} | |
| db.set_doc(_database_path(org_id, new_id), {**database, "name": new_name, "created_at": _now()}) | |
| for r in rows: | |
| payload = {k: v for k, v in r.items() if k != "row_id"} | |
| db.set_doc(_row_path(org_id, new_id, id_map[r["row_id"]]), {"encrypted_data": encrypt(json.dumps(payload))}) | |
| lookups_prefix = f"orgs/{org_id}/databases/{database_id}/lookups/" | |
| for path, data in db.query_prefix(lookups_prefix).items(): | |
| db.set_doc(_lookup_path(org_id, new_id, path[len(lookups_prefix):]), data) | |
| # re-point flagged_row_ids at the new row ids β leaving them pointing at the OLD | |
| # database's ids would silently flag the wrong rows (or none, once those ids never match). | |
| updated_columns = [ | |
| {**c, "flagged_row_ids": [id_map[rid] for rid in c["flagged_row_ids"] if rid in id_map]} | |
| for c in database["columns"] | |
| ] | |
| db.update_doc(_database_path(org_id, new_id), {"columns": updated_columns}) | |
| return new_id | |
| def get_lookup_table(org_id: str, database_id: str, lookup_name: str) -> dict: | |
| """Read-only access to a normalized lookup table's actual rows β the database doc only | |
| ever stores the lookup's NAME and column list (see create_database_from_upload), never its | |
| rows, so the grid's lookup-table viewer needs a dedicated fetch rather than reading it off | |
| the database doc it already has.""" | |
| doc = db.get_doc(_lookup_path(org_id, database_id, lookup_name)) | |
| if doc is None: | |
| raise orgs.NotFoundError(f"No lookup table {lookup_name!r} in database {database_id!r}") | |
| return {"columns": doc["columns"], "rows": json.loads(decrypt(doc["encrypted_rows"]))} | |
| def set_column_list(org_id: str, actor_uid: str, database_id: str, column: str, delimiter: str | None) -> None: | |
| """Makes `column` a list column split on `delimiter` (or, with None, dismisses the | |
| suggestion to do so and leaves the column as it is).""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| if delimiter is not None and delimiter not in LIST_DELIMITERS: | |
| raise ValidationError(f"Unknown list separator {delimiter!r}") | |
| database = get_database(org_id, database_id) | |
| if not any(c["name"] == column for c in database["columns"]): | |
| raise orgs.NotFoundError(f"No column {column!r} in database {database_id!r}") | |
| updated = [] | |
| for c in database["columns"]: | |
| if c["name"] == column: | |
| c = {k: v for k, v in c.items() if k != "list_suggestion"} | |
| if delimiter is not None: | |
| c = {**c, "type": "tag", "delimiter": delimiter, "flagged_row_ids": []} | |
| updated.append(c) | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated}) | |
| def update_column_type(org_id: str, database_id: str, column: str, new_type: str) -> list[str]: | |
| if new_type not in COLUMN_TYPES: | |
| raise ValidationError(f"Unknown column type {new_type!r}") | |
| database = get_database(org_id, database_id) | |
| if not any(c["name"] == column for c in database["columns"]): | |
| raise orgs.NotFoundError(f"No column {column!r} in database {database_id!r}") | |
| rows = list_rows(org_id, database_id) | |
| values = [r.get(column, "") for r in rows] | |
| if not _has_conforming_value(values, new_type): | |
| raise ValidationError( | |
| f"No existing value in {column!r} looks like a valid {new_type} β refusing the change" | |
| ) | |
| flagged_indices = _flagged_values(values, new_type) | |
| flagged_row_ids = [rows[i]["row_id"] for i in flagged_indices] | |
| updated_columns = [ | |
| {**{k: v for k, v in c.items() if k != "list_suggestion"}, "type": new_type, "flagged_row_ids": flagged_row_ids, | |
| **({"delimiter": c.get("delimiter") or ";"} if new_type == "tag" else {})} | |
| if c["name"] == column else c | |
| for c in database["columns"] | |
| ] | |
| db.update_doc(_database_path(org_id, database_id), {"columns": updated_columns}) | |
| return flagged_row_ids | |
| def merge_databases(org_id: str, actor_uid: str, database_id_a: str, database_id_b: str, new_name: str) -> str: | |
| """Concatenates both databases' rows under a brand-new database, reconciling columns by | |
| name (union) and re-running classification + normalization over the combined set β a | |
| merge isn't just a row concat, it's a fresh upload of the combined data.""" | |
| orgs.require_permission(org_id, actor_uid, "edit_lists") | |
| rows_a = list_rows(org_id, database_id_a) | |
| rows_b = list_rows(org_id, database_id_b) | |
| headers = list(dict.fromkeys([k for r in rows_a + rows_b for k in r if k != "row_id"])) | |
| # Exact-duplicate rows (e.g. the same contact present in both databases) are dropped | |
| # before normalizing β besides being the obviously-wanted merge behavior on its own, an | |
| # un-deduplicated duplicate row makes EVERY column pair look like a valid functional | |
| # dependency (they trivially agree with their own duplicate), which would otherwise send | |
| # the FD-discovery heuristic down a false trail. | |
| seen_rows: set[tuple] = set() | |
| combined: list[dict] = [] | |
| for r in rows_a + rows_b: | |
| row = {h: r.get(h, "") for h in headers} | |
| key = tuple(row[h] for h in headers) | |
| if key in seen_rows: | |
| continue | |
| seen_rows.add(key) | |
| combined.append(row) | |
| column_types = {h: classify_column(h, [r[h] for r in combined]) for h in headers} | |
| identity_columns = {h for h in headers if column_types[h] in ("email", "contact")} | |
| result = normalize.normalize(combined, headers, exclude_determinants=identity_columns, auto_split=False) | |
| database_id = uuid.uuid4().hex | |
| id_rows = _assign_row_ids(result.root.rows) | |
| columns = _columns_with_flags(column_types, result.root.columns, id_rows) | |
| db.set_doc(_database_path(org_id, database_id), { | |
| "name": new_name, | |
| "source_filename": f"merge({database_id_a[:8]}, {database_id_b[:8]})", | |
| "source_kind": "merge", | |
| "created_at": _now(), | |
| "row_count": len(result.root.rows), | |
| "columns": columns, | |
| "lookup_tables": [{"name": t.name, "columns": t.columns} for t in result.lookups], | |
| "suggestions": result.suggestions, | |
| }) | |
| for lookup in result.lookups: | |
| db.set_doc(_lookup_path(org_id, database_id, lookup.name), { | |
| "columns": lookup.columns, | |
| "encrypted_rows": encrypt(json.dumps(lookup.rows)), | |
| }) | |
| for row_id, row in id_rows: | |
| db.set_doc(_row_path(org_id, database_id, row_id), {"encrypted_data": encrypt(json.dumps(row))}) | |
| return database_id | |