fix(6229): remove the Profile settings page — nothing read it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 43s

The Profile tab said an assistant used it to personalise chat and a daily
journal. Neither exists, and nothing read the profile: the page saved to
user_profiles and the backup carried it. Removed the tab, the client calls,
/api/profile, the service and the model, and migration 0123 drops the table
(downgrade recreates it empty).

Backup format v27 stops writing user_profiles; an older archive carrying the
section still restores, and the file-restore integration test now proves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:14:40 -04:00
co-authored by Claude Opus 5.5
parent 067aa58fbc
commit 54206afa76
13 changed files with 83 additions and 453 deletions
@@ -0,0 +1,64 @@
"""drop user_profiles
Revision ID: 0123
Revises: 0122
Create Date: 2026-10-22
The Profile settings page asked for a display name, job title, expertise,
response style, tone, interests and a work schedule, and said an assistant
used them to personalise chat and a daily journal. Nothing in the app read
any of it: the page saved to this table and the backup carried it, and that
was all. The page, its API and the backup section are gone, and the table
goes with them.
Downgrade recreates the empty table, so the schema steps back cleanly; the
rows themselves do not come back.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
revision = "0123"
down_revision = "0122"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("ix_user_profiles_user_id", table_name="user_profiles")
op.drop_table("user_profiles")
def downgrade() -> None:
op.create_table(
"user_profiles",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("job_title", sa.Text(), nullable=True),
sa.Column("industry", sa.Text(), nullable=True),
sa.Column("expertise_level", sa.Text(), nullable=True),
sa.Column("response_style", sa.Text(), nullable=True),
sa.Column("tone", sa.Text(), nullable=True),
sa.Column("interests", ARRAY(sa.Text()), nullable=True),
sa.Column("work_schedule", JSONB(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True)
+1 -2
View File
@@ -219,11 +219,10 @@ endpoint at `/mcp`, not these REST routes.
| POST | `/api/notifications/:id/read` | Mark one read |
| POST | `/api/notifications/read-all` | Mark all read |
## Profile and Settings
## Settings
| Method | Path | Description |
|--------|------|-------------|
| GET / PUT | `/api/profile` | Read / update the per-user profile |
| GET / PUT | `/api/settings` | Read / update key-value settings |
| GET | `/api/settings/search` | SearXNG configuration status |
| GET / POST | `/api/settings/forge-connections` | Your git forge connections / add one |
-1
View File
@@ -98,7 +98,6 @@ Email and password, plus optional OIDC single sign-on (Authentik, Keycloak, ...)
|-----|----------|
| General | Timezone, trash retention, retrieval thresholds, moments, reply shapes |
| Account | Email, password, active sessions |
| Profile | About you, response preferences, interests, work schedule |
| Notifications | Email notifications |
| Integrations | SearXNG, git forge connections |
| Data | Export, backup and restore, scheduled backups (admin) |
-18
View File
@@ -500,24 +500,6 @@ export const createApiKey = (name: string, scope: 'read' | 'write') =>
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
// ── User Profile ─────────────────────────────────────────────────────────────
export interface UserProfile {
display_name: string
job_title: string
industry: string
expertise_level: 'novice' | 'intermediate' | 'expert'
response_style: 'concise' | 'balanced' | 'detailed'
tone: 'casual' | 'professional' | 'technical'
interests: string[]
work_schedule: { days?: string[]; start?: string; end?: string }
}
export const getProfile = () => apiGet<UserProfile>('/api/profile')
export const updateProfile = (data: Partial<UserProfile>) =>
apiPut<UserProfile>('/api/profile', data)
// ── Note Versions (pinning) ──────────────────────────────────────────────────
import type { NoteVersion } from '../types/task'
+3 -197
View File
@@ -5,10 +5,9 @@ import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { usePlatformsStore } from "@/stores/platforms";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
import MomentsSettings from "@/components/MomentsSettings.vue";
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
@@ -530,7 +529,7 @@ async function copyCommit() {
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -846,50 +845,6 @@ const searchLoading = ref(false);
const searchError = ref("");
// ── Profile ──────────────────────────────────────────────────────────────────
const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const profile = ref<UserProfile>({
display_name: '', job_title: '', industry: '',
expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual',
interests: [], work_schedule: {},
})
const profileSaving = ref(false)
const profileSaved = ref(false)
async function loadProfile() {
try { profile.value = await getProfile() } catch { /* non-critical */ }
}
async function saveProfile() {
profileSaving.value = true
profileSaved.value = false
try {
profile.value = await updateProfile({
display_name: profile.value.display_name,
job_title: profile.value.job_title,
industry: profile.value.industry,
expertise_level: profile.value.expertise_level,
response_style: profile.value.response_style,
tone: profile.value.tone,
interests: profile.value.interests,
work_schedule: profile.value.work_schedule,
})
profileSaved.value = true
setTimeout(() => { profileSaved.value = false }, 2000)
} catch { toastStore.show('Failed to save profile', 'error') }
finally { profileSaving.value = false }
}
function toggleProfileWorkDay(day: string) {
const days = [...(profile.value.work_schedule.days ?? [])]
const idx = days.indexOf(day)
if (idx >= 0) days.splice(idx, 1)
else days.push(day)
profile.value.work_schedule = { ...profile.value.work_schedule, days }
}
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
onMounted(async () => {
await store.fetchSettings();
newEmail.value = authStore.user?.email ?? "";
@@ -974,9 +929,6 @@ onMounted(async () => {
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
}
// Load user profile
await loadProfile();
// Check SearXNG status
try {
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
@@ -1647,7 +1599,7 @@ async function deleteUser(userId: number) {
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'apikeys']"
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -2366,111 +2318,6 @@ async function deleteUser(userId: number) {
</div>
<!-- ── Notifications ── -->
<!-- ── Profile ── -->
<div v-show="activeTab === 'profile'" class="settings-grid">
<section class="settings-section full-width">
<h2>About You</h2>
<p class="section-desc">This information is used by the assistant to personalise responses in chat and the daily journal.</p>
<div class="assistant-grid">
<div class="field">
<label>Display Name</label>
<input v-model="profile.display_name" type="text" class="fs-input input" placeholder="e.g. Alex" />
<p class="field-hint">How the assistant addresses you.</p>
</div>
<div class="field">
<label>Job Title</label>
<input v-model="profile.job_title" type="text" class="fs-input input" placeholder="e.g. Product Manager" />
</div>
<div class="field">
<label>Industry</label>
<input v-model="profile.industry" type="text" class="fs-input input" placeholder="e.g. Technology" />
</div>
<div class="field">
<label>Expertise Level</label>
<select v-model="profile.expertise_level" class="fs-input input">
<option value="novice">Novice — explain things simply</option>
<option value="intermediate">Intermediate — balanced explanations</option>
<option value="expert">Expert — assume deep knowledge</option>
</select>
<p class="field-hint">Calibrates how the assistant explains concepts.</p>
</div>
</div>
<div class="actions">
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Response Preferences</h2>
<div class="assistant-grid">
<div class="field">
<label>Response Style</label>
<select v-model="profile.response_style" class="fs-input input">
<option value="concise">Concise — short and direct</option>
<option value="balanced">Balanced — default</option>
<option value="detailed">Detailed — thorough explanations</option>
</select>
</div>
<div class="field">
<label>Tone</label>
<select v-model="profile.tone" class="fs-input input">
<option value="casual">Casual — friendly and relaxed</option>
<option value="professional">Professional — formal and precise</option>
<option value="technical">Technical — jargon-friendly</option>
</select>
</div>
</div>
<div class="actions">
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Interests</h2>
<p class="section-desc">Topics you care about — used to personalise the journal's daily prep and chat responses.</p>
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
<div class="actions">
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Work Schedule</h2>
<p class="section-desc">Helps the journal understand when you're working and what's relevant each morning.</p>
<div class="field">
<label>Work Days</label>
<div class="day-picker">
<button
v-for="day in WORK_DAYS"
:key="day"
class="day-btn"
:class="{ active: (profile.work_schedule.days ?? []).includes(day) }"
@click="toggleProfileWorkDay(day)"
type="button"
>{{ day }}</button>
</div>
</div>
<div class="assistant-grid" style="margin-top:0.75rem">
<div class="field">
<label>Start Time</label>
<input v-model="profile.work_schedule.start" type="time" class="fs-input input" />
</div>
<div class="field">
<label>End Time</label>
<input v-model="profile.work_schedule.end" type="time" class="fs-input input" />
</div>
</div>
<div class="actions">
<button class="btn-primary" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="profileSaved" class="saved-msg">Saved!</span>
</div>
</section>
</div>
<div v-show="activeTab === 'notifications'" class="settings-grid">
<section class="settings-section">
@@ -3724,18 +3571,6 @@ async function deleteUser(userId: number) {
line-height: 1.5;
}
/* Assistant — 2-col internal grid */
.assistant-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
.assistant-grid {
grid-template-columns: 1fr;
}
}
.field {
margin-bottom: 1rem;
}
@@ -3985,9 +3820,6 @@ async function deleteUser(userId: number) {
.settings-section.full-width {
grid-column: auto;
}
.assistant-grid {
grid-template-columns: 1fr;
}
.smtp-grid {
grid-template-columns: 1fr;
}
@@ -4455,32 +4287,6 @@ async function deleteUser(userId: number) {
line-height: 1.5;
}
/* ── Profile tab ─────────────────────────────────────────────────────────── */
.day-picker {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
margin-top: 0.35rem;
}
.day-btn {
padding: 0.3rem 0.65rem;
border: 1px solid var(--fs-border-color);
border-radius: 6px;
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.day-btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
.day-btn.active {
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
border-color: var(--fs-accent);
color: var(--fs-accent-fg);
font-weight: 500;
}
/* ── Shared areas ──────────────────────────────────────────────────
The slug is shown deliberately: it is what decides whether two names are
the same area, and an admin renaming an entry needs to see it move. */
-2
View File
@@ -23,7 +23,6 @@ from scribe.routes.in_app_notifications import notifications_bp
from scribe.routes.users import users_bp
from scribe.routes.api_keys import api_keys_bp
from scribe.routes.search import search_bp
from scribe.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp
from scribe.routes.plugin import plugin_bp
@@ -93,7 +92,6 @@ def create_app() -> Quart:
app.register_blueprint(users_bp)
app.register_blueprint(api_keys_bp)
app.register_blueprint(search_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp)
app.register_blueprint(lessons_bp)
app.register_blueprint(rulebooks_bp)
-1
View File
@@ -74,7 +74,6 @@ from scribe.models.group import Group, GroupMembership # noqa: E402, F401
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
from scribe.models.notification import Notification # noqa: E402, F401
from scribe.models.api_key import ApiKey # noqa: E402, F401
from scribe.models.user_profile import UserProfile # noqa: E402, F401
# Imported before rulebook: rule_systems foreign-keys canonical_systems.
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
from scribe.models.rulebook import ( # noqa: E402, F401
-39
View File
@@ -1,39 +0,0 @@
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin
class UserProfile(Base, TimestampMixin):
__tablename__ = "user_profiles"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
)
display_name: Mapped[str | None] = mapped_column(Text, nullable=True)
job_title: Mapped[str | None] = mapped_column(Text, nullable=True)
industry: Mapped[str | None] = mapped_column(Text, nullable=True)
# novice / intermediate / expert — calibrates explanation depth
expertise_level: Mapped[str | None] = mapped_column(Text, nullable=True)
# concise / balanced / detailed
response_style: Mapped[str | None] = mapped_column(Text, nullable=True)
# casual / professional / technical
tone: Mapped[str | None] = mapped_column(Text, nullable=True)
interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
# {days: ["Mon","Tue",...], start: "09:00", end: "17:00"}
work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
def to_dict(self) -> dict:
return {
"display_name": self.display_name or "",
"job_title": self.job_title or "",
"industry": self.industry or "",
"expertise_level": self.expertise_level or "intermediate",
"response_style": self.response_style or "balanced",
"tone": self.tone or "casual",
"interests": self.interests or [],
"work_schedule": self.work_schedule or {},
}
-43
View File
@@ -1,43 +0,0 @@
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services.user_profile import (
VALID_EXPERTISE,
VALID_STYLES,
VALID_TONES,
get_profile,
update_profile,
)
profile_bp = Blueprint("profile", __name__, url_prefix="/api/profile")
@profile_bp.route("", methods=["GET"])
@login_required
async def get_profile_route():
uid = get_current_user_id()
profile = await get_profile(uid)
return jsonify(profile.to_dict())
@profile_bp.route("", methods=["PUT"])
@login_required
async def update_profile_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if "expertise_level" in data and data["expertise_level"] not in VALID_EXPERTISE:
return jsonify({"error": f"expertise_level must be one of {sorted(VALID_EXPERTISE)}"}), 400
if "response_style" in data and data["response_style"] not in VALID_STYLES:
return jsonify({"error": f"response_style must be one of {sorted(VALID_STYLES)}"}), 400
if "tone" in data and data["tone"] not in VALID_TONES:
return jsonify({"error": f"tone must be one of {sorted(VALID_TONES)}"}), 400
if "interests" in data and not isinstance(data["interests"], list):
return jsonify({"error": "interests must be an array"}), 400
if "work_schedule" in data and not isinstance(data["work_schedule"], dict):
return jsonify({"error": "work_schedule must be an object"}), 400
profile = await update_profile(uid, data)
return jsonify(profile.to_dict())
+5 -65
View File
@@ -38,7 +38,6 @@ 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__)
@@ -109,9 +108,10 @@ logger = logging.getLogger(__name__)
# ledger and the decision log. Lose the ledger or the log and every
# assessment is owed again, with nothing to keep it consistent with the
# last.
# v26 user_profiles: what each user wrote on their Profile page. Every field
# is typed by its user, and none is derived or a credential.
BACKUP_VERSION = 26
# v26 user_profiles: what each user wrote on their Profile page.
# v27 drops user_profiles with the Profile page. Older archives carrying the
# section still restore — the key is simply not read.
BACKUP_VERSION = 27
# 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
@@ -162,8 +162,6 @@ _BACKED_UP = [
# memberships.
"platforms", "project_platforms", "family_ideas", "family_idea_platforms",
"family_idea_references", "family_adoptions", "family_decisions",
# What each user wrote on their Profile page.
"user_profiles",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -269,8 +267,6 @@ _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"},
@@ -373,7 +369,6 @@ _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",
},
@@ -550,24 +545,6 @@ def _moment_mapping_rows(rows) -> list[dict]:
]
def _user_profile_rows(rows) -> list[dict]:
"""The Profile page. 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.
@@ -1064,9 +1041,6 @@ 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(
@@ -1141,7 +1115,6 @@ 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,9 +1300,6 @@ 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),
@@ -1432,7 +1402,6 @@ 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),
@@ -1663,27 +1632,6 @@ 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
@@ -2398,7 +2346,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, "user_profiles": 0,
"lesson_no_rule": 0, "moment_mappings": 0,
"platforms": 0, "project_platforms": 0, "family_ideas": 0,
"family_idea_platforms": 0, "family_idea_references": 0,
"family_adoptions": 0, "family_decisions": 0,
@@ -2520,14 +2468,6 @@ 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)
-55
View File
@@ -1,55 +0,0 @@
"""User profile service — structured per-user preferences.
The profile is user-managed metadata (name, job, expertise, style, tone,
interests, work schedule), read and written through /api/profile and carried
in backups.
"""
import logging
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.user_profile import UserProfile
logger = logging.getLogger(__name__)
VALID_EXPERTISE = {"novice", "intermediate", "expert"}
VALID_STYLES = {"concise", "balanced", "detailed"}
VALID_TONES = {"casual", "professional", "technical"}
async def get_profile(user_id: int) -> UserProfile:
"""Get or create the profile row for a user."""
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile is None:
profile = UserProfile(user_id=user_id)
session.add(profile)
await session.commit()
await session.refresh(profile)
return profile
async def update_profile(user_id: int, data: dict) -> UserProfile:
"""Upsert structured profile fields from a validated dict."""
allowed = {
"display_name", "job_title", "industry", "expertise_level",
"response_style", "tone", "interests", "work_schedule",
}
async with async_session() as session:
result = await session.execute(
select(UserProfile).where(UserProfile.user_id == user_id)
)
profile = result.scalar_one_or_none()
if profile is None:
profile = UserProfile(user_id=user_id)
session.add(profile)
for key, value in data.items():
if key in allowed:
setattr(profile, key, value)
await session.commit()
await session.refresh(profile)
return profile
+7 -19
View File
@@ -1,6 +1,6 @@
"""Real-Postgres proof that the scheduled backup's FILE restores (milestone
523): a payload written as the tar, read back through `scribe.restore`'s
loader, and fed to the real `restore_full_backup`.
"""Real-Postgres proof that the scheduled backup's FILE restores: a payload
written as the tar, read back through `scribe.restore`'s loader, and fed to
the real `restore_full_backup`.
The unit lane already shows the tar hands back the dict it was given. What
only this lane shows is that the dict it hands back is one the restore
@@ -16,7 +16,6 @@ from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.user import User
from scribe.models.user_profile import UserProfile
from scribe.restore import load_backup, run
from scribe.services import backup
from scribe.services.backup_file import write_archive
@@ -52,7 +51,8 @@ async def _no_leftovers():
async def written(tmp_path):
"""A project with a milestone and a task in it, exported with the real
row builders, renamed so the restore mints a separate user, and written
as the backup tar."""
as the backup tar. It also carries a `user_profiles` section, as an
archive from before v27 does."""
async with async_session() as s:
user = await ensure_user(s, OWNER)
project = Project(user_id=user.id, title="Restore drill")
@@ -65,10 +65,6 @@ async def written(tmp_path):
title="Prove the restore", body="From the tar, not the JSON.",
status="todo", note_type="note", task_kind="work")
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()
payload = {
"version": backup.BACKUP_VERSION,
@@ -77,7 +73,8 @@ async def written(tmp_path):
"projects": backup._project_rows([project]),
"milestones": backup._milestone_rows([milestone]),
"notes": backup._note_rows([note]),
"user_profiles": backup._user_profile_rows([profile]),
"user_profiles": [{"user_id": user.id, "display_name": "Drill Owner",
"tone": "technical", "interests": ["backups"]}],
}
payload["users"][0]["username"] = RESTORED
from datetime import datetime, timezone
@@ -94,19 +91,10 @@ 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()
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()
profile = (await s.execute(
select(UserProfile).where(UserProfile.user_id == user.id)
)).scalars().one()
assert project.title == "Restore drill"
assert (note.project_id, note.milestone_id) == (project.id, milestone.id)
assert note.body == "From the tar, not the JSON."
assert note.status == "todo"
# The Profile page travels with its user; 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):
+3 -11
View File
@@ -21,11 +21,8 @@ from scribe.services import backup
def test_backup_version_is_current():
"""The bump is the point of the test — a payload section added without
moving the version produces backups that are structurally different and
indistinguishable by inspection.
(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.)"""
assert backup.BACKUP_VERSION == 26
indistinguishable by inspection."""
assert backup.BACKUP_VERSION == 27
def _exportable_note(**over):
@@ -164,7 +161,6 @@ def _column_guard_targets():
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
return {
"users": (User, backup._user_rows),
@@ -212,7 +208,6 @@ def _column_guard_targets():
"family_idea_references": (FamilyIdeaReference, backup._family_idea_reference_rows),
"family_adoptions": (FamilyAdoption, backup._family_adoption_rows),
"family_decisions": (FamilyDecision, backup._family_decision_rows),
"user_profiles": (UserProfile, backup._user_profile_rows),
}
@@ -324,7 +319,6 @@ def _import_guard_targets():
"system_usage_events": backup._build_system_usage_event,
"retrieval_tuning_events": backup._build_retrieval_tuning_event,
"moment_mappings": backup._build_moment_mapping,
"user_profiles": backup._build_user_profile,
"design_systems": backup._build_design_system,
"design_tokens": backup._build_design_token,
"repo_bindings": backup._build_repo_binding,
@@ -593,9 +587,7 @@ async def test_export_full_backup_contains_every_declared_section():
# v25: family canon.
"platforms", "project_platforms", "family_ideas",
"family_idea_platforms", "family_idea_references",
"family_adoptions", "family_decisions",
# v26: the Profile page.
"user_profiles"):
"family_adoptions", "family_decisions"):
assert key in out, f"missing export section: {key}"
assert out[key] == []