Backups carry the Profile page (user_profiles) #211
@@ -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):
|
||||||
|
|||||||
@@ -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] == []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user