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
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {},
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user