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:
@@ -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)
|
||||||
@@ -219,11 +219,10 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| POST | `/api/notifications/:id/read` | Mark one read |
|
| POST | `/api/notifications/:id/read` | Mark one read |
|
||||||
| POST | `/api/notifications/read-all` | Mark all read |
|
| POST | `/api/notifications/read-all` | Mark all read |
|
||||||
|
|
||||||
## Profile and Settings
|
## Settings
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET / PUT | `/api/profile` | Read / update the per-user profile |
|
|
||||||
| GET / PUT | `/api/settings` | Read / update key-value settings |
|
| GET / PUT | `/api/settings` | Read / update key-value settings |
|
||||||
| GET | `/api/settings/search` | SearXNG configuration status |
|
| GET | `/api/settings/search` | SearXNG configuration status |
|
||||||
| GET / POST | `/api/settings/forge-connections` | Your git forge connections / add one |
|
| GET / POST | `/api/settings/forge-connections` | Your git forge connections / add one |
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ Email and password, plus optional OIDC single sign-on (Authentik, Keycloak, ...)
|
|||||||
|-----|----------|
|
|-----|----------|
|
||||||
| General | Timezone, trash retention, retrieval thresholds, moments, reply shapes |
|
| General | Timezone, trash retention, retrieval thresholds, moments, reply shapes |
|
||||||
| Account | Email, password, active sessions |
|
| Account | Email, password, active sessions |
|
||||||
| Profile | About you, response preferences, interests, work schedule |
|
|
||||||
| Notifications | Email notifications |
|
| Notifications | Email notifications |
|
||||||
| Integrations | SearXNG, git forge connections |
|
| Integrations | SearXNG, git forge connections |
|
||||||
| Data | Export, backup and restore, scheduled backups (admin) |
|
| Data | Export, backup and restore, scheduled backups (admin) |
|
||||||
|
|||||||
@@ -500,24 +500,6 @@ export const createApiKey = (name: string, scope: 'read' | 'write') =>
|
|||||||
|
|
||||||
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
|
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) ──────────────────────────────────────────────────
|
// ── Note Versions (pinning) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
import type { NoteVersion } from '../types/task'
|
import type { NoteVersion } from '../types/task'
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import { useAuthStore } from "@/stores/auth";
|
|||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
import { usePlatformsStore } from "@/stores/platforms";
|
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 type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
|
||||||
import MomentsSettings from "@/components/MomentsSettings.vue";
|
import MomentsSettings from "@/components/MomentsSettings.vue";
|
||||||
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
|
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
|
||||||
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
|
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
|
||||||
@@ -530,7 +529,7 @@ async function copyCommit() {
|
|||||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
// 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 _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||||
|
|
||||||
@@ -846,50 +845,6 @@ const searchLoading = ref(false);
|
|||||||
const searchError = ref("");
|
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 () => {
|
onMounted(async () => {
|
||||||
await store.fetchSettings();
|
await store.fetchSettings();
|
||||||
newEmail.value = authStore.user?.email ?? "";
|
newEmail.value = authStore.user?.email ?? "";
|
||||||
@@ -974,9 +929,6 @@ onMounted(async () => {
|
|||||||
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
|
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user profile
|
|
||||||
await loadProfile();
|
|
||||||
|
|
||||||
// Check SearXNG status
|
// Check SearXNG status
|
||||||
try {
|
try {
|
||||||
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
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">
|
||||||
<div class="sidebar-group-label">User</div>
|
<div class="sidebar-group-label">User</div>
|
||||||
<button
|
<button
|
||||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'apikeys']"
|
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'apikeys']"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||||
@click="activeTab = tab"
|
@click="activeTab = tab"
|
||||||
@@ -2366,111 +2318,6 @@ async function deleteUser(userId: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Notifications ── -->
|
<!-- ── 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">
|
<div v-show="activeTab === 'notifications'" class="settings-grid">
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
@@ -3724,18 +3571,6 @@ async function deleteUser(userId: number) {
|
|||||||
line-height: 1.5;
|
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 {
|
.field {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -3985,9 +3820,6 @@ async function deleteUser(userId: number) {
|
|||||||
.settings-section.full-width {
|
.settings-section.full-width {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
.assistant-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.smtp-grid {
|
.smtp-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -4455,32 +4287,6 @@ async function deleteUser(userId: number) {
|
|||||||
line-height: 1.5;
|
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 ──────────────────────────────────────────────────
|
/* ── Shared areas ──────────────────────────────────────────────────
|
||||||
The slug is shown deliberately: it is what decides whether two names are
|
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. */
|
the same area, and an admin renaming an entry needs to see it move. */
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from scribe.routes.in_app_notifications import notifications_bp
|
|||||||
from scribe.routes.users import users_bp
|
from scribe.routes.users import users_bp
|
||||||
from scribe.routes.api_keys import api_keys_bp
|
from scribe.routes.api_keys import api_keys_bp
|
||||||
from scribe.routes.search import search_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.knowledge import knowledge_bp
|
||||||
from scribe.routes.rulebooks import rulebooks_bp
|
from scribe.routes.rulebooks import rulebooks_bp
|
||||||
from scribe.routes.plugin import plugin_bp
|
from scribe.routes.plugin import plugin_bp
|
||||||
@@ -93,7 +92,6 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(users_bp)
|
app.register_blueprint(users_bp)
|
||||||
app.register_blueprint(api_keys_bp)
|
app.register_blueprint(api_keys_bp)
|
||||||
app.register_blueprint(search_bp)
|
app.register_blueprint(search_bp)
|
||||||
app.register_blueprint(profile_bp)
|
|
||||||
app.register_blueprint(knowledge_bp)
|
app.register_blueprint(knowledge_bp)
|
||||||
app.register_blueprint(lessons_bp)
|
app.register_blueprint(lessons_bp)
|
||||||
app.register_blueprint(rulebooks_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.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||||
from scribe.models.notification import Notification # 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.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.
|
# Imported before rulebook: rule_systems foreign-keys canonical_systems.
|
||||||
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
|
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
|
||||||
from scribe.models.rulebook import ( # 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.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__)
|
||||||
|
|
||||||
@@ -109,9 +108,10 @@ logger = logging.getLogger(__name__)
|
|||||||
# ledger and the decision log. Lose the ledger or the log and every
|
# 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
|
# assessment is owed again, with nothing to keep it consistent with the
|
||||||
# last.
|
# last.
|
||||||
# v26 user_profiles: what each user wrote on their Profile page. Every field
|
# v26 user_profiles: what each user wrote on their Profile page.
|
||||||
# is typed by its user, and none is derived or a credential.
|
# v27 drops user_profiles with the Profile page. Older archives carrying the
|
||||||
BACKUP_VERSION = 26
|
# 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
|
# 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
|
||||||
@@ -162,8 +162,6 @@ _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",
|
||||||
# What each user wrote on their Profile page.
|
|
||||||
"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
|
||||||
@@ -269,8 +267,6 @@ _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"},
|
||||||
@@ -373,7 +369,6 @@ _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",
|
||||||
},
|
},
|
||||||
@@ -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]:
|
def _retrieval_tuning_event_rows(rows) -> list[dict]:
|
||||||
"""The record of why a retrieval dial is where it is.
|
"""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(
|
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(
|
||||||
@@ -1141,7 +1115,6 @@ 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,9 +1300,6 @@ 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),
|
||||||
@@ -1432,7 +1402,6 @@ 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),
|
||||||
@@ -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:
|
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
|
||||||
@@ -2398,7 +2346,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, "user_profiles": 0,
|
"lesson_no_rule": 0, "moment_mappings": 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,
|
||||||
@@ -2520,14 +2468,6 @@ 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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Real-Postgres proof that the scheduled backup's FILE restores (milestone
|
"""Real-Postgres proof that the scheduled backup's FILE restores: a payload
|
||||||
523): a payload written as the tar, read back through `scribe.restore`'s
|
written as the tar, read back through `scribe.restore`'s loader, and fed to
|
||||||
loader, and fed to the real `restore_full_backup`.
|
the real `restore_full_backup`.
|
||||||
|
|
||||||
The unit lane already shows the tar hands back the dict it was given. What
|
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
|
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.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
|
||||||
@@ -52,7 +51,8 @@ async def _no_leftovers():
|
|||||||
async def written(tmp_path):
|
async def written(tmp_path):
|
||||||
"""A project with a milestone and a task in it, exported with the real
|
"""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
|
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:
|
async with async_session() as s:
|
||||||
user = await ensure_user(s, OWNER)
|
user = await ensure_user(s, OWNER)
|
||||||
project = Project(user_id=user.id, title="Restore drill")
|
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.",
|
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,
|
||||||
@@ -77,7 +73,8 @@ 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]),
|
"user_profiles": [{"user_id": user.id, "display_name": "Drill Owner",
|
||||||
|
"tone": "technical", "interests": ["backups"]}],
|
||||||
}
|
}
|
||||||
payload["users"][0]["username"] = RESTORED
|
payload["users"][0]["username"] = RESTORED
|
||||||
from datetime import datetime, timezone
|
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()
|
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; 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):
|
||||||
|
|||||||
@@ -21,11 +21,8 @@ from scribe.services import backup
|
|||||||
def test_backup_version_is_current():
|
def test_backup_version_is_current():
|
||||||
"""The bump is the point of the test — a payload section added without
|
"""The bump is the point of the test — a payload section added without
|
||||||
moving the version produces backups that are structurally different and
|
moving the version produces backups that are structurally different and
|
||||||
indistinguishable by inspection.
|
indistinguishable by inspection."""
|
||||||
|
assert backup.BACKUP_VERSION == 27
|
||||||
(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
|
|
||||||
|
|
||||||
|
|
||||||
def _exportable_note(**over):
|
def _exportable_note(**over):
|
||||||
@@ -164,7 +161,6 @@ 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),
|
||||||
@@ -212,7 +208,6 @@ 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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -324,7 +319,6 @@ 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,
|
||||||
@@ -593,9 +587,7 @@ async def test_export_full_backup_contains_every_declared_section():
|
|||||||
# v25: family canon.
|
# v25: family canon.
|
||||||
"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.
|
|
||||||
"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