Backups carry the Profile page (user_profiles) #211

Merged
bvandeusen merged 1 commits from dev into main 2026-10-15 20:00:32 -04:00
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.system import RecordSystem, System
from scribe.models.task_log import TaskLog from scribe.models.task_log import TaskLog
from scribe.models.user import User from scribe.models.user import User
from scribe.models.user_profile import UserProfile
logger = logging.getLogger(__name__) 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 # 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 # precedent each later answer follows. Lose either and every assessment is
# owed again, made fresh, with nothing to keep it consistent with the last. # 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. # 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 # 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 # below, these two lists must together account for the entire schema — which is
@@ -180,6 +185,8 @@ _BACKED_UP = [
# memberships. # memberships.
"platforms", "project_platforms", "family_ideas", "family_idea_platforms", "platforms", "project_platforms", "family_ideas", "family_idea_platforms",
"family_idea_references", "family_adoptions", "family_decisions", "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 # 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", "api_keys", "note_embeddings", "rule_embeddings", "milestone_embeddings",
"system_embeddings", "system_embeddings",
"app_logs", "notifications", "app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles", "invitation_tokens", "password_reset_tokens",
"retrieval_logs", "retrieval_logs",
# Verdicts on retrieval_logs rows (#4772), so they go where their subject # 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 # 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"}, "retrieval_tuning_events": {"id"},
# Every other column is the correction itself, or who made it and why. # Every other column is the correction itself, or who made it and why.
"moment_mappings": {"id"}, "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_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
"design_tokens": {"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"}, "repo_bindings": {"id", "created_at", "updated_at"},
@@ -394,6 +403,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
"system_usage_events": {"id"}, "system_usage_events": {"id"},
"retrieval_tuning_events": {"id"}, "retrieval_tuning_events": {"id"},
"moment_mappings": {"id"}, "moment_mappings": {"id"},
"user_profiles": {"id"},
"design_systems": { "design_systems": {
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at", "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]: def _retrieval_tuning_event_rows(rows) -> list[dict]:
"""The record of why a retrieval dial is where it is (#4102). """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( moment_mappings = (await session.execute(
select(MomentMapping).order_by(MomentMapping.id) select(MomentMapping).order_by(MomentMapping.id)
)).scalars().all() )).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() 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_shapes = (await session.execute(select(CodeShape).order_by(CodeShape.id))).scalars().all()
code_shape_events = (await session.execute( code_shape_events = (await session.execute(
@@ -1142,6 +1173,7 @@ async def export_full_backup() -> dict:
retrieval_tuning_events retrieval_tuning_events
), ),
"moment_mappings": _moment_mapping_rows(moment_mappings), "moment_mappings": _moment_mapping_rows(moment_mappings),
"user_profiles": _user_profile_rows(user_profiles),
"repo_bindings": _repo_binding_rows(repo_bindings), "repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions), "note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes), "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) .where(MomentMapping.user_id == user_id)
.order_by(MomentMapping.id) .order_by(MomentMapping.id)
)).scalars().all() )).scalars().all()
user_profiles = (await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)).scalars().all()
rule_relations = (await session.execute( rule_relations = (await session.execute(
select(RuleRelation).where( select(RuleRelation).where(
RuleRelation.from_rule_id.in_(_rule_ids), RuleRelation.from_rule_id.in_(_rule_ids),
@@ -1429,6 +1464,7 @@ async def export_user_backup(user_id: int) -> dict:
retrieval_tuning_events retrieval_tuning_events
), ),
"moment_mappings": _moment_mapping_rows(moment_mappings), "moment_mappings": _moment_mapping_rows(moment_mappings),
"user_profiles": _user_profile_rows(user_profiles),
"repo_bindings": _repo_binding_rows(repo_bindings), "repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions), "note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes), "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: def _build_retrieval_tuning_event(row: dict, maps: _Maps) -> RetrievalTuningEvent | None:
"""No id remapping beyond the user: `surface` is a registry NAME, not a """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 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_systems": 0, "rule_moments": 0, "rule_moment_judgments": 0,
"rule_relations": 0, "rule_versions": 0, "rule_relations": 0, "rule_versions": 0,
"retrieval_tuning_events": 0, "lesson_rule_links": 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, "platforms": 0, "project_platforms": 0, "family_ideas": 0,
"family_idea_platforms": 0, "family_idea_references": 0, "family_idea_platforms": 0, "family_idea_references": 0,
"family_adoptions": 0, "family_decisions": 0, "family_adoptions": 0, "family_decisions": 0,
@@ -2499,6 +2556,14 @@ async def _restore_v2(data: dict) -> dict:
session.add(mapping) session.add(mapping)
stats["moment_mappings"] += 1 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) # 9. Rulebooks (v3)
for rb_data in data.get("rulebooks", []): for rb_data in data.get("rulebooks", []):
rb = _build_rulebook(rb_data, maps) rb = _build_rulebook(rb_data, maps)
@@ -16,6 +16,7 @@ from scribe.models.milestone import Milestone
from scribe.models.note import Note from scribe.models.note import Note
from scribe.models.project import Project from scribe.models.project import Project
from scribe.models.user import User from scribe.models.user import User
from scribe.models.user_profile import UserProfile
from scribe.restore import load_backup, run from scribe.restore import load_backup, run
from scribe.services import backup from scribe.services import backup
from scribe.services.backup_file import write_archive from scribe.services.backup_file import write_archive
@@ -64,6 +65,10 @@ async def written(tmp_path):
title="Prove the restore", body="From the tar, not the JSON.", title="Prove the restore", body="From the tar, not the JSON.",
status="todo", note_type="note", task_kind="work") status="todo", note_type="note", task_kind="work")
s.add(note) s.add(note)
profile = UserProfile(user_id=user.id, display_name="Drill Owner",
tone="technical", interests=["backups"],
work_schedule={"start": "09:00"})
s.add(profile)
await s.commit() await s.commit()
payload = { payload = {
"version": backup.BACKUP_VERSION, "version": backup.BACKUP_VERSION,
@@ -72,6 +77,7 @@ async def written(tmp_path):
"projects": backup._project_rows([project]), "projects": backup._project_rows([project]),
"milestones": backup._milestone_rows([milestone]), "milestones": backup._milestone_rows([milestone]),
"notes": backup._note_rows([note]), "notes": backup._note_rows([note]),
"user_profiles": backup._user_profile_rows([profile]),
} }
payload["users"][0]["username"] = RESTORED payload["users"][0]["username"] = RESTORED
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -88,10 +94,19 @@ async def test_the_tar_restores_with_every_id_remapped(written):
project = (await s.execute(select(Project).where(Project.user_id == user.id))).scalars().one() project = (await s.execute(select(Project).where(Project.user_id == user.id))).scalars().one()
milestone = (await s.execute(select(Milestone).where(Milestone.user_id == user.id))).scalars().one() milestone = (await s.execute(select(Milestone).where(Milestone.user_id == user.id))).scalars().one()
note = (await s.execute(select(Note).where(Note.user_id == user.id))).scalars().one() note = (await s.execute(select(Note).where(Note.user_id == user.id))).scalars().one()
profile = (await s.execute(
select(UserProfile).where(UserProfile.user_id == user.id)
)).scalars().one()
assert project.title == "Restore drill" assert project.title == "Restore drill"
assert (note.project_id, note.milestone_id) == (project.id, milestone.id) assert (note.project_id, note.milestone_id) == (project.id, milestone.id)
assert note.body == "From the tar, not the JSON." assert note.body == "From the tar, not the JSON."
assert note.status == "todo" assert note.status == "todo"
# The Profile page travels with its user (#5879); a field left blank stays
# blank rather than restoring as the page's default.
assert (profile.display_name, profile.tone) == ("Drill Owner", "technical")
assert profile.interests == ["backups"]
assert profile.work_schedule == {"start": "09:00"}
assert profile.job_title is None
async def test_the_command_refuses_a_database_with_accounts(written): async def test_the_command_refuses_a_database_with_accounts(written):
+7 -2
View File
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a (Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)""" name-carrying-a-value invites; it now says what it checks.)"""
assert backup.BACKUP_VERSION == 25 assert backup.BACKUP_VERSION == 26
def _exportable_note(**over): def _exportable_note(**over):
@@ -166,6 +166,7 @@ def _column_guard_targets():
from scribe.models.system import RecordSystem, System from scribe.models.system import RecordSystem, System
from scribe.models.task_log import TaskLog from scribe.models.task_log import TaskLog
from scribe.models.user import User from scribe.models.user import User
from scribe.models.user_profile import UserProfile
return { return {
"users": (User, backup._user_rows), "users": (User, backup._user_rows),
@@ -213,6 +214,7 @@ def _column_guard_targets():
"family_idea_references": (FamilyIdeaReference, backup._family_idea_reference_rows), "family_idea_references": (FamilyIdeaReference, backup._family_idea_reference_rows),
"family_adoptions": (FamilyAdoption, backup._family_adoption_rows), "family_adoptions": (FamilyAdoption, backup._family_adoption_rows),
"family_decisions": (FamilyDecision, backup._family_decision_rows), "family_decisions": (FamilyDecision, backup._family_decision_rows),
"user_profiles": (UserProfile, backup._user_profile_rows),
} }
@@ -326,6 +328,7 @@ def _import_guard_targets():
"system_usage_events": backup._build_system_usage_event, "system_usage_events": backup._build_system_usage_event,
"retrieval_tuning_events": backup._build_retrieval_tuning_event, "retrieval_tuning_events": backup._build_retrieval_tuning_event,
"moment_mappings": backup._build_moment_mapping, "moment_mappings": backup._build_moment_mapping,
"user_profiles": backup._build_user_profile,
"design_systems": backup._build_design_system, "design_systems": backup._build_design_system,
"design_tokens": backup._build_design_token, "design_tokens": backup._build_design_token,
"repo_bindings": backup._build_repo_binding, "repo_bindings": backup._build_repo_binding,
@@ -599,7 +602,9 @@ async def test_export_full_backup_contains_every_declared_section():
# v25: family canon (milestone 463). # v25: family canon (milestone 463).
"platforms", "project_platforms", "family_ideas", "platforms", "project_platforms", "family_ideas",
"family_idea_platforms", "family_idea_references", "family_idea_platforms", "family_idea_references",
"family_adoptions", "family_decisions"): "family_adoptions", "family_decisions",
# v26: the Profile page (#5879).
"user_profiles"):
assert key in out, f"missing export section: {key}" assert key in out, f"missing export section: {key}"
assert out[key] == [] assert out[key] == []