diff --git a/alembic/versions/0123_drop_user_profiles.py b/alembic/versions/0123_drop_user_profiles.py new file mode 100644 index 00000000..fae1c8d3 --- /dev/null +++ b/alembic/versions/0123_drop_user_profiles.py @@ -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) diff --git a/docs/api-reference.md b/docs/api-reference.md index fa66ae11..14e2b47d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 | diff --git a/docs/features.md b/docs/features.md index 1a690b26..3fcaa272 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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) | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 01969717..145364be 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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('/api/profile') -export const updateProfile = (data: Partial) => - apiPut('/api/profile', data) - - // ── Note Versions (pinning) ────────────────────────────────────────────────── import type { NoteVersion } from '../types/task' diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 59c5f639..490affb8 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -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(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({ - 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 { 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) { - - -
-

Response Preferences

-
-
- - -
-
- - -
-
-
- - Saved! -
-
- -
-

Interests

-

Topics you care about — used to personalise the journal's daily prep and chat responses.

- -
- - Saved! -
-
- -
-

Work Schedule

-

Helps the journal understand when you're working and what's relevant each morning.

-
- -
- -
-
-
-
- - -
-
- - -
-
-
- - Saved! -
-
- - -
@@ -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. */ diff --git a/src/scribe/app.py b/src/scribe/app.py index 3788cb7a..31b1dc39 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -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) diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index 1d287050..15377457 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -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 diff --git a/src/scribe/models/user_profile.py b/src/scribe/models/user_profile.py deleted file mode 100644 index e626d8ae..00000000 --- a/src/scribe/models/user_profile.py +++ /dev/null @@ -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 {}, - } diff --git a/src/scribe/routes/profile.py b/src/scribe/routes/profile.py deleted file mode 100644 index a8dd439b..00000000 --- a/src/scribe/routes/profile.py +++ /dev/null @@ -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()) diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index bfe5bad5..1004be46 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -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) diff --git a/src/scribe/services/user_profile.py b/src/scribe/services/user_profile.py deleted file mode 100644 index 802772f7..00000000 --- a/src/scribe/services/user_profile.py +++ /dev/null @@ -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 diff --git a/tests/test_integration_backup_file_restore.py b/tests/test_integration_backup_file_restore.py index 0c3b9e81..96cd4048 100644 --- a/tests/test_integration_backup_file_restore.py +++ b/tests/test_integration_backup_file_restore.py @@ -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): diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index 94bed647..5dbd7bc0 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -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] == []