fix(523): the backup carries user_profiles - the Profile page travels with its user (#5879)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 27s

Settings -> Profile (name, role, expertise, response style, tone, interests,
working hours) is typed by its user and neither derived nor a credential, yet
_NOT_INCLUDED named it, so a restore dropped it without a word.

BACKUP_VERSION 26. Both exports carry it; the restore remaps only the user, and
a blank field stays blank rather than restoring as the page's default. The
column and import guards cover it, and the tar round-trip restores one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 19:55:52 -04:00
co-authored by Claude Opus 5.5
parent 07bc51b890
commit 38d02d9c6f
3 changed files with 90 additions and 5 deletions
+68 -3
View File
@@ -38,6 +38,7 @@ from scribe.models.setting import Setting
from scribe.models.system import RecordSystem, System
from scribe.models.task_log import TaskLog
from scribe.models.user import User
from scribe.models.user_profile import UserProfile
logger = logging.getLogger(__name__)
@@ -122,8 +123,12 @@ logger = logging.getLogger(__name__)
# every project's recorded answer to a shared idea, and the log is the
# precedent each later answer follows. Lose either and every assessment is
# owed again, made fresh, with nothing to keep it consistent with the last.
# v26 (2026-10) added user_profiles (#5879): the Profile page — name, role,
# expertise, response style, tone, interests, working hours. Every field is
# typed by its user and none is derived or a credential, so leaving it out made
# a restore drop it without a word.
# Bump when the serialized schema changes.
BACKUP_VERSION = 25
BACKUP_VERSION = 26
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -180,6 +185,8 @@ _BACKED_UP = [
# memberships.
"platforms", "project_platforms", "family_ideas", "family_idea_platforms",
"family_idea_references", "family_adoptions", "family_decisions",
# v26 (2026-10): what each user wrote on their Profile page (#5879).
"user_profiles",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -198,7 +205,7 @@ _NOT_INCLUDED = [
"api_keys", "note_embeddings", "rule_embeddings", "milestone_embeddings",
"system_embeddings",
"app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles",
"invitation_tokens", "password_reset_tokens",
"retrieval_logs",
# Verdicts on retrieval_logs rows (#4772), so they go where their subject
# goes: a restore that carried a judgment of a call it did not carry would
@@ -292,6 +299,8 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"retrieval_tuning_events": {"id"},
# Every other column is the correction itself, or who made it and why.
"moment_mappings": {"id"},
# One row per user, keyed by the user; every other column is what they typed.
"user_profiles": {"id"},
"design_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
"design_tokens": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
"repo_bindings": {"id", "created_at", "updated_at"},
@@ -394,6 +403,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"system_usage_events": {"id"},
"retrieval_tuning_events": {"id"},
"moment_mappings": {"id"},
"user_profiles": {"id"},
"design_systems": {
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
},
@@ -570,6 +580,24 @@ def _moment_mapping_rows(rows) -> list[dict]:
]
def _user_profile_rows(rows) -> list[dict]:
"""The Profile page (#5879). Not `to_dict()`: that one fills blanks with
the page's defaults and omits `user_id`, and a restore needs the stored
value and the owner."""
return [
{
"user_id": r.user_id, "display_name": r.display_name,
"job_title": r.job_title, "industry": r.industry,
"expertise_level": r.expertise_level,
"response_style": r.response_style, "tone": r.tone,
"interests": r.interests, "work_schedule": r.work_schedule,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
def _retrieval_tuning_event_rows(rows) -> list[dict]:
"""The record of why a retrieval dial is where it is (#4102).
@@ -1068,6 +1096,9 @@ async def export_full_backup() -> dict:
moment_mappings = (await session.execute(
select(MomentMapping).order_by(MomentMapping.id)
)).scalars().all()
user_profiles = (await session.execute(
select(UserProfile).order_by(UserProfile.id)
)).scalars().all()
repo_bindings = (await session.execute(select(RepoBinding).order_by(RepoBinding.id))).scalars().all()
code_shapes = (await session.execute(select(CodeShape).order_by(CodeShape.id))).scalars().all()
code_shape_events = (await session.execute(
@@ -1142,6 +1173,7 @@ async def export_full_backup() -> dict:
retrieval_tuning_events
),
"moment_mappings": _moment_mapping_rows(moment_mappings),
"user_profiles": _user_profile_rows(user_profiles),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
@@ -1327,6 +1359,9 @@ async def export_user_backup(user_id: int) -> dict:
.where(MomentMapping.user_id == user_id)
.order_by(MomentMapping.id)
)).scalars().all()
user_profiles = (await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)).scalars().all()
rule_relations = (await session.execute(
select(RuleRelation).where(
RuleRelation.from_rule_id.in_(_rule_ids),
@@ -1429,6 +1464,7 @@ async def export_user_backup(user_id: int) -> dict:
retrieval_tuning_events
),
"moment_mappings": _moment_mapping_rows(moment_mappings),
"user_profiles": _user_profile_rows(user_profiles),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
@@ -1661,6 +1697,27 @@ def _build_moment_mapping(row: dict, maps: _Maps) -> MomentMapping | None:
)
def _build_user_profile(row: dict, maps: _Maps) -> UserProfile | None:
"""Only the user remaps. A blank field stays blank, never the page's
default: `to_dict()` fills those in on read, from the same None."""
uid = maps.users.get(row.get("user_id") or 0)
if uid is None:
return None
return UserProfile(
user_id=uid,
display_name=row.get("display_name"),
job_title=row.get("job_title"),
industry=row.get("industry"),
expertise_level=row.get("expertise_level"),
response_style=row.get("response_style"),
tone=row.get("tone"),
interests=row.get("interests"),
work_schedule=row.get("work_schedule"),
created_at=_dt(row.get("created_at")),
updated_at=_dt(row.get("updated_at")),
)
def _build_retrieval_tuning_event(row: dict, maps: _Maps) -> RetrievalTuningEvent | None:
"""No id remapping beyond the user: `surface` is a registry NAME, not a
foreign key, which is what lets this history survive a restore into an
@@ -2377,7 +2434,7 @@ async def _restore_v2(data: dict) -> dict:
"rule_systems": 0, "rule_moments": 0, "rule_moment_judgments": 0,
"rule_relations": 0, "rule_versions": 0,
"retrieval_tuning_events": 0, "lesson_rule_links": 0,
"lesson_no_rule": 0, "moment_mappings": 0,
"lesson_no_rule": 0, "moment_mappings": 0, "user_profiles": 0,
"platforms": 0, "project_platforms": 0, "family_ideas": 0,
"family_idea_platforms": 0, "family_idea_references": 0,
"family_adoptions": 0, "family_decisions": 0,
@@ -2499,6 +2556,14 @@ async def _restore_v2(data: dict) -> dict:
session.add(mapping)
stats["moment_mappings"] += 1
# 8d. User profiles (v26) — one per user, so only the user remaps.
for up_data in data.get("user_profiles", []):
profile = _build_user_profile(up_data, maps)
if profile is None:
continue
session.add(profile)
stats["user_profiles"] += 1
# 9. Rulebooks (v3)
for rb_data in data.get("rulebooks", []):
rb = _build_rulebook(rb_data, maps)