Drift-audit remediation + Stored Processes + Dashboard #55
@@ -0,0 +1,49 @@
|
||||
"""partial-unique topic/rule titles (ignore soft-deleted rows)
|
||||
|
||||
Revision ID: 0061
|
||||
Revises: 0060
|
||||
Create Date: 2026-06-02
|
||||
|
||||
Topics and rules are soft-deleted (SoftDeleteMixin), but uq_topic_per_rulebook
|
||||
and uq_rule_per_topic were plain UNIQUE constraints. Trashing a topic/rule
|
||||
named "X" then creating a new "X" — or restoring into a reused title slot —
|
||||
collided with the dead row and raised an unhandled 500. Replace the full
|
||||
UNIQUE constraints with partial unique indexes that only consider live
|
||||
(deleted_at IS NULL) rows.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0061"
|
||||
down_revision = "0060"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("uq_topic_per_rulebook", "rulebook_topics", type_="unique")
|
||||
op.create_index(
|
||||
"uq_topic_per_rulebook",
|
||||
"rulebook_topics",
|
||||
["rulebook_id", "title"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
op.drop_constraint("uq_rule_per_topic", "rules", type_="unique")
|
||||
op.create_index(
|
||||
"uq_rule_per_topic",
|
||||
"rules",
|
||||
["topic_id", "title"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_rule_per_topic", table_name="rules")
|
||||
op.create_unique_constraint("uq_rule_per_topic", "rules", ["topic_id", "title"])
|
||||
op.drop_index("uq_topic_per_rulebook", table_name="rulebook_topics")
|
||||
op.create_unique_constraint(
|
||||
"uq_topic_per_rulebook", "rulebook_topics", ["rulebook_id", "title"]
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""drop dead UserProfile curator columns
|
||||
|
||||
Revision ID: 0062
|
||||
Revises: 0061
|
||||
Create Date: 2026-06-02
|
||||
|
||||
learned_summary, observations_raw, and observations_updated_at were written by
|
||||
the curator/LLM-profile machinery removed in the Phase-8 MCP pivot. Nothing has
|
||||
populated them since; the profile API returned permanently-empty fields. Drop
|
||||
the columns.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "0062"
|
||||
down_revision = "0061"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("user_profiles", "learned_summary")
|
||||
op.drop_column("user_profiles", "observations_raw")
|
||||
op.drop_column("user_profiles", "observations_updated_at")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("observations_raw", postgresql.JSONB(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("learned_summary", sa.Text(), nullable=True),
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
|
||||
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
|
||||
@@ -45,7 +45,8 @@ router.afterEach(() => {
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<div class="nav-center">
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
@@ -90,7 +91,8 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
|
||||
@@ -13,6 +13,23 @@ const typeIcon: Record<string, string> = {
|
||||
project_shared: '📁',
|
||||
note_shared: '📝',
|
||||
group_added: '👥',
|
||||
event_reminder: '⏰',
|
||||
}
|
||||
|
||||
function notifMessage(n: { type: string; payload: Record<string, unknown> }): string {
|
||||
const p = n.payload
|
||||
switch (n.type) {
|
||||
case 'project_shared':
|
||||
return ` shared "${p.project_title}" with you as ${p.permission}`
|
||||
case 'note_shared':
|
||||
return ` shared "${p.note_title}" with you as ${p.permission}`
|
||||
case 'group_added':
|
||||
return ` added you to "${p.group_name}" as ${p.role}`
|
||||
case 'event_reminder':
|
||||
return `Reminder: "${p.title}" is coming up`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
|
||||
@@ -49,11 +66,7 @@ onMounted(() => store.fetchAll())
|
||||
<div class="notif-body">
|
||||
<p class="notif-msg">
|
||||
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
|
||||
{{ n.type === 'project_shared'
|
||||
? ` shared "${n.payload.project_title}" with you as ${n.payload.permission}`
|
||||
: n.type === 'note_shared'
|
||||
? ` shared "${n.payload.note_title}" with you as ${n.payload.permission}`
|
||||
: ` added you to "${n.payload.group_name}" as ${n.payload.role}` }}
|
||||
{{ notifMessage(n) }}
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,15 @@ const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
// Knowledge is the landing page in the MCP-first architecture
|
||||
// (chat / journal / workspace surfaces have been removed).
|
||||
// The dashboard ("what to work on") is the landing page; Knowledge
|
||||
// remains as the exhaustive "Browse" surface.
|
||||
path: "/",
|
||||
redirect: "/knowledge",
|
||||
redirect: "/dashboard",
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "dashboard",
|
||||
component: () => import("@/views/DashboardView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/knowledge",
|
||||
|
||||
@@ -2,66 +2,16 @@ import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { Note, NoteListResponse } from "@/types/note";
|
||||
import type { Note } from "@/types/note";
|
||||
|
||||
// Single-note + mutation surface. The list/filter/sort/pagination surface that
|
||||
// backed the removed /notes list view was dropped in the 2026-06-02 drift-audit
|
||||
// cleanup (no consumers — KnowledgeView and the editors use single-entity and
|
||||
// mutation methods only).
|
||||
export const useNotesStore = defineStore("notes", () => {
|
||||
const notes = ref<Note[]>([]);
|
||||
const currentNote = ref<Note | null>(null);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
|
||||
// Filter / pagination / sort state
|
||||
const activeTagFilters = ref<string[]>([]);
|
||||
const limit = ref(20);
|
||||
const offset = ref(0);
|
||||
const sortField = ref("updated_at");
|
||||
const sortOrder = ref<"asc" | "desc">("desc");
|
||||
const searchQuery = ref("");
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchQuery.value) searchParams.set("q", searchQuery.value);
|
||||
for (const t of activeTagFilters.value) {
|
||||
searchParams.append("tag", t);
|
||||
}
|
||||
searchParams.set("sort", sortField.value);
|
||||
searchParams.set("order", sortOrder.value);
|
||||
searchParams.set("limit", String(limit.value));
|
||||
searchParams.set("offset", String(offset.value));
|
||||
|
||||
const qs = searchParams.toString();
|
||||
const data = await apiGet<NoteListResponse>(
|
||||
`/api/notes${qs ? `?${qs}` : ""}`
|
||||
);
|
||||
notes.value = data.notes;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load notes", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotes(params?: {
|
||||
q?: string;
|
||||
tag?: string[];
|
||||
sort?: string;
|
||||
order?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
if (params?.q !== undefined) searchQuery.value = params.q || "";
|
||||
if (params?.tag) activeTagFilters.value = params.tag;
|
||||
if (params?.sort) sortField.value = params.sort;
|
||||
if (params?.order) sortOrder.value = params.order as "asc" | "desc";
|
||||
if (params?.limit) limit.value = params.limit;
|
||||
if (params?.offset !== undefined) offset.value = params.offset;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function fetchNote(id: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -110,7 +60,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
async function deleteNote(id: number) {
|
||||
try {
|
||||
await apiDelete(`/api/notes/${id}`);
|
||||
notes.value = notes.value.filter((n) => n.id !== id);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = null;
|
||||
}
|
||||
@@ -120,50 +69,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function addTagFilter(tag: string) {
|
||||
if (!activeTagFilters.value.includes(tag)) {
|
||||
activeTagFilters.value.push(tag);
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function removeTagFilter(tag: string) {
|
||||
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function clearTagFilters() {
|
||||
activeTagFilters.value = [];
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setTagFilters(tags: string[]) {
|
||||
activeTagFilters.value = [...tags];
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setSort(field: string, order: "asc" | "desc") {
|
||||
sortField.value = field;
|
||||
sortOrder.value = order;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setOffset(newOffset: number) {
|
||||
offset.value = newOffset;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setSearch(q: string) {
|
||||
searchQuery.value = q;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function resolveTitle(title: string): Promise<Note> {
|
||||
return await apiPost<Note>("/api/notes/resolve-title", { title });
|
||||
}
|
||||
@@ -216,29 +121,12 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
notes,
|
||||
currentNote,
|
||||
total,
|
||||
loading,
|
||||
activeTagFilters,
|
||||
limit,
|
||||
offset,
|
||||
sortField,
|
||||
sortOrder,
|
||||
searchQuery,
|
||||
fetchNotes,
|
||||
fetchNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
addTagFilter,
|
||||
removeTagFilter,
|
||||
clearTagFilters,
|
||||
setTagFilters,
|
||||
setSort,
|
||||
setOffset,
|
||||
setSearch,
|
||||
refresh,
|
||||
resolveTitle,
|
||||
convertToTask,
|
||||
convertToNote,
|
||||
|
||||
@@ -2,53 +2,15 @@ import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { Task, TaskListResponse, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
||||
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
||||
|
||||
// Single-task + mutation surface. The list/filter/sort/pagination surface that
|
||||
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
|
||||
// cleanup (no consumers — ProjectView's kanban keeps its own local task list).
|
||||
export const useTasksStore = defineStore("tasks", () => {
|
||||
const tasks = ref<Task[]>([]);
|
||||
const currentTask = ref<Task | null>(null);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
|
||||
// Filter / pagination / sort state
|
||||
const activeTagFilters = ref<string[]>([]);
|
||||
const statusFilter = ref<TaskStatus[]>([]);
|
||||
const priorityFilter = ref<TaskPriority[]>([]);
|
||||
const limit = ref(20);
|
||||
const offset = ref(0);
|
||||
const sortField = ref("updated_at");
|
||||
const sortOrder = ref<"asc" | "desc">("desc");
|
||||
const searchQuery = ref("");
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchQuery.value) searchParams.set("q", searchQuery.value);
|
||||
for (const t of activeTagFilters.value) {
|
||||
searchParams.append("tag", t);
|
||||
}
|
||||
for (const s of statusFilter.value) searchParams.append("status", s);
|
||||
for (const p of priorityFilter.value) searchParams.append("priority", p);
|
||||
searchParams.set("sort", sortField.value);
|
||||
searchParams.set("order", sortOrder.value);
|
||||
searchParams.set("limit", String(limit.value));
|
||||
searchParams.set("offset", String(offset.value));
|
||||
|
||||
const qs = searchParams.toString();
|
||||
const data = await apiGet<TaskListResponse>(
|
||||
`/api/tasks${qs ? `?${qs}` : ""}`
|
||||
);
|
||||
tasks.value = data.tasks;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load tasks", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTask(id: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -102,10 +64,6 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
|
||||
try {
|
||||
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
|
||||
const idx = tasks.value.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) {
|
||||
tasks.value[idx] = task;
|
||||
}
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
}
|
||||
@@ -119,7 +77,6 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
async function deleteTask(id: number) {
|
||||
try {
|
||||
await apiDelete(`/api/tasks/${id}`);
|
||||
tasks.value = tasks.value.filter((t) => t.id !== id);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = null;
|
||||
}
|
||||
@@ -144,83 +101,14 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setStatusFilter(statuses: TaskStatus[]) {
|
||||
statusFilter.value = statuses;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setPriorityFilter(priorities: TaskPriority[]) {
|
||||
priorityFilter.value = priorities;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function addTagFilter(tag: string) {
|
||||
if (!activeTagFilters.value.includes(tag)) {
|
||||
activeTagFilters.value.push(tag);
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function removeTagFilter(tag: string) {
|
||||
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function clearTagFilters() {
|
||||
activeTagFilters.value = [];
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setSort(field: string, order: "asc" | "desc") {
|
||||
sortField.value = field;
|
||||
sortOrder.value = order;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setOffset(newOffset: number) {
|
||||
offset.value = newOffset;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setSearch(q: string) {
|
||||
searchQuery.value = q;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
currentTask,
|
||||
total,
|
||||
loading,
|
||||
activeTagFilters,
|
||||
statusFilter,
|
||||
priorityFilter,
|
||||
limit,
|
||||
offset,
|
||||
sortField,
|
||||
sortOrder,
|
||||
searchQuery,
|
||||
refresh,
|
||||
fetchTask,
|
||||
createTask,
|
||||
updateTask,
|
||||
patchStatus,
|
||||
deleteTask,
|
||||
startPlanning,
|
||||
setStatusFilter,
|
||||
setPriorityFilter,
|
||||
addTagFilter,
|
||||
removeTagFilter,
|
||||
clearTagFilters,
|
||||
setSort,
|
||||
setOffset,
|
||||
setSearch,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
export interface GenerationTiming {
|
||||
total_ms: number;
|
||||
intent_ms: number | null;
|
||||
ttft_ms: number | null;
|
||||
generation_ms: number | null;
|
||||
tools: Array<{ name: string; ms: number }>;
|
||||
}
|
||||
|
||||
export interface ToolCallRecord {
|
||||
function: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[]; requires_confirmation?: boolean; similar_note?: { id: number; title: string } };
|
||||
status: "running" | "success" | "error" | "declined";
|
||||
}
|
||||
|
||||
export interface ToolPendingRecord {
|
||||
function: string;
|
||||
arguments: Record<string, unknown>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
status?: "complete" | "generating" | "error";
|
||||
context_note_id: number | null;
|
||||
context_note_title?: string | null;
|
||||
tool_calls?: ToolCallRecord[] | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
timing?: GenerationTiming;
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
assistant_message_id: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
title: string;
|
||||
model: string;
|
||||
message_count: number;
|
||||
rag_project_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConversationDetail extends Conversation {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface ContextMeta {
|
||||
context_note_id: number | null;
|
||||
context_note_title: string | null;
|
||||
auto_notes: { id: number; title: string; score?: number | null; auto_injected?: boolean }[];
|
||||
auto_injected_notes?: { id: number; title: string; score?: number | null }[];
|
||||
}
|
||||
|
||||
export interface OllamaStatus {
|
||||
ollama: "available" | "unavailable";
|
||||
model: "loaded" | "cold" | "not_found";
|
||||
default_model: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type NoteType = "note" | "person" | "place" | "list";
|
||||
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Settings are free-form string key/value pairs keyed by setting name; the
|
||||
// backend has no fixed schema, so this is an open string map. (The former
|
||||
// assistant_name/default_model hints were dead after the Phase-8 chat removal.)
|
||||
export interface AppSettings {
|
||||
assistant_name?: string;
|
||||
default_model?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
|
||||
interface TaskRow { id: number; title: string; status: string; priority: string }
|
||||
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||
interface ActiveProject {
|
||||
id: number; title: string; color: string | null; last_activity: string;
|
||||
open_count: number; progress_pct: number;
|
||||
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||
}
|
||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
||||
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
||||
interface DashboardData {
|
||||
active_projects: ActiveProject[];
|
||||
recently_completed: DoneItem[];
|
||||
upcoming_events: UpcomingEvent[];
|
||||
week_stats: WeekStats;
|
||||
}
|
||||
|
||||
const data = ref<DashboardData | null>(null);
|
||||
const loading = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||
} catch {
|
||||
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function fmtEvent(e: UpcomingEvent): string {
|
||||
if (!e.start_dt) return "";
|
||||
const d = new Date(e.start_dt);
|
||||
const day = d.toLocaleDateString(undefined, { weekday: "short" });
|
||||
if (e.all_day) return day;
|
||||
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dash-root">
|
||||
<header class="dash-head">
|
||||
<h1>Dashboard</h1>
|
||||
<p class="dash-sub">What to work on, across your most-active projects.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="dash-empty">Loading…</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Done recently -->
|
||||
<section v-if="data.recently_completed.length" class="done-strip">
|
||||
<span class="dash-label">✓ Done recently</span>
|
||||
<router-link
|
||||
v-for="d in data.recently_completed"
|
||||
:key="d.id"
|
||||
:to="`/tasks/${d.id}`"
|
||||
class="done-chip"
|
||||
>
|
||||
{{ d.title }}
|
||||
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
|
||||
</router-link>
|
||||
</section>
|
||||
|
||||
<div class="dash-cols">
|
||||
<!-- Active now -->
|
||||
<main class="dash-main">
|
||||
<div class="dash-label">Active now</div>
|
||||
|
||||
<div v-if="!data.active_projects.length" class="dash-empty card">
|
||||
No active projects yet — <router-link to="/projects">create one</router-link>.
|
||||
</div>
|
||||
|
||||
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
|
||||
<header class="proj-head">
|
||||
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
||||
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
|
||||
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
|
||||
</header>
|
||||
<div class="bar"><div class="bar-fill" :style="{ width: p.progress_pct + '%' }" /></div>
|
||||
|
||||
<div v-for="m in p.milestones" :key="m.id" class="ms-block">
|
||||
<div class="ms-head">
|
||||
<span class="ms-title">{{ m.title }}</span>
|
||||
<span class="ms-pct">{{ m.progress_pct }}%</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="t in m.open_tasks"
|
||||
:key="t.id"
|
||||
:to="`/tasks/${t.id}`"
|
||||
class="task-row"
|
||||
:class="{ 'task-inprogress': t.status === 'in_progress' }"
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="p.no_milestone.length" class="ms-block">
|
||||
<div class="ms-head"><span class="ms-title ms-none">No milestone</span></div>
|
||||
<router-link
|
||||
v-for="t in p.no_milestone"
|
||||
:key="t.id"
|
||||
:to="`/tasks/${t.id}`"
|
||||
class="task-row"
|
||||
:class="{ 'task-inprogress': t.status === 'in_progress' }"
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<router-link :to="`/projects/${p.id}`" class="proj-more">+ more in {{ p.title }} →</router-link>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<!-- Right rail -->
|
||||
<aside class="dash-rail">
|
||||
<div class="dash-label">Upcoming · 7 days</div>
|
||||
<div class="rail-card">
|
||||
<template v-if="data.upcoming_events.length">
|
||||
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
|
||||
<span class="evt-when">{{ fmtEvent(e) }}</span>
|
||||
<span class="evt-title">{{ e.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="rail-empty">Nothing scheduled.</p>
|
||||
</div>
|
||||
|
||||
<div class="dash-label">This week</div>
|
||||
<div class="rail-card stats">
|
||||
<span>✓ {{ data.week_stats.completed_this_week }} done</span>
|
||||
<span>○ {{ data.week_stats.open_total }} open</span>
|
||||
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
|
||||
</div>
|
||||
|
||||
<div class="quick-add">
|
||||
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
|
||||
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
|
||||
<router-link to="/notes/new?type=process" class="qa-btn">+ Process</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
||||
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
||||
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
||||
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
||||
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
||||
|
||||
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
|
||||
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||
.done-chip:hover { border-color: var(--color-primary); }
|
||||
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
|
||||
|
||||
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||
.dash-main { flex: 1.7; min-width: 0; }
|
||||
.dash-rail { flex: 1; min-width: 240px; }
|
||||
|
||||
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
|
||||
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
|
||||
.proj-title:hover { color: var(--color-primary); }
|
||||
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
|
||||
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
|
||||
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
|
||||
|
||||
.ms-block { margin-top: 0.7rem; }
|
||||
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
|
||||
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
|
||||
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
|
||||
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
|
||||
|
||||
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
||||
.task-row:hover { background: var(--color-hover); }
|
||||
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
|
||||
.task-mark { color: var(--color-muted); }
|
||||
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
|
||||
.pri-high { color: #c0556b; border-color: #c0556b66; }
|
||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
||||
|
||||
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
|
||||
.evt-row:last-child { border-bottom: none; }
|
||||
.evt-when { color: var(--color-muted); white-space: nowrap; }
|
||||
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
||||
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
User,
|
||||
MapPin,
|
||||
List,
|
||||
Workflow,
|
||||
Search,
|
||||
Share2,
|
||||
ChevronLeft,
|
||||
@@ -23,7 +24,7 @@ const router = useRouter();
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "person" | "place" | "list" | "task";
|
||||
note_type: "note" | "person" | "place" | "list" | "task" | "process";
|
||||
title: string;
|
||||
snippet: string;
|
||||
tags: string[];
|
||||
@@ -61,7 +62,7 @@ interface UpcomingEvent {
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan">("");
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
|
||||
const activeTag = ref("");
|
||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
@@ -69,8 +70,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; total: number }
|
||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, total: 0 });
|
||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
|
||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||
|
||||
async function fetchCounts() {
|
||||
try {
|
||||
@@ -403,6 +404,10 @@ onUnmounted(() => {
|
||||
<List :size="16" />
|
||||
List
|
||||
</button>
|
||||
<button @click="createNew('process')">
|
||||
<Workflow :size="16" />
|
||||
Process
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -417,11 +422,11 @@ onUnmounted(() => {
|
||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])"
|
||||
:key="val"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeType === val }"
|
||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan')"
|
||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')"
|
||||
>
|
||||
<span class="filter-btn-label">{{ label }}</span>
|
||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||
@@ -494,6 +499,7 @@ onUnmounted(() => {
|
||||
<span v-else-if="item.note_type === 'person'">Person</span>
|
||||
<span v-else-if="item.note_type === 'place'">Place</span>
|
||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||
<span v-else>List</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ const titlePlaceholder = computed(() => {
|
||||
case 'person': return 'Name';
|
||||
case 'place': return 'Place name';
|
||||
case 'list': return 'List title';
|
||||
case 'process': return 'Process name';
|
||||
default: return 'Title';
|
||||
}
|
||||
});
|
||||
@@ -602,6 +603,18 @@ onUnmounted(() => assist.clearSelection());
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||||
<template v-else-if="noteType === 'process'">
|
||||
<label class="ef-label">Prompt</label>
|
||||
<textarea
|
||||
class="prompt-editor"
|
||||
:value="body"
|
||||
@input="onBodyUpdate(($event.target as HTMLTextAreaElement).value)"
|
||||
placeholder="The prompt to run when this process is fired (markdown). If it needs inputs, instruct Claude to ask up front."
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</template>
|
||||
|
||||
<!-- ── Generic note editor ──────────────────────────────── -->
|
||||
<template v-else>
|
||||
<div class="body-tabs-row">
|
||||
@@ -1038,6 +1051,26 @@ onUnmounted(() => assist.clearSelection());
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.prompt-editor {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
margin-top: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
/* Prompts are plain markdown — a code-style editor, not rich text. */
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
.prompt-editor:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.ef-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -14,25 +14,10 @@ const toastStore = useToastStore();
|
||||
const userTimezone = ref("");
|
||||
const savingTimezone = ref(false);
|
||||
const timezoneSaved = ref(false);
|
||||
const autoConsolidateTasks = ref(true);
|
||||
const savingAutoConsolidate = ref(false);
|
||||
const trashRetentionDays = ref("90");
|
||||
const savingRetention = ref(false);
|
||||
const retentionSaved = ref(false);
|
||||
|
||||
async function saveAutoConsolidate() {
|
||||
savingAutoConsolidate.value = true;
|
||||
try {
|
||||
await apiPut('/api/settings', {
|
||||
auto_consolidate_tasks: autoConsolidateTasks.value ? "true" : "false",
|
||||
});
|
||||
} catch {
|
||||
toastStore.show('Failed to save setting', 'error');
|
||||
} finally {
|
||||
savingAutoConsolidate.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// think_enabled setting removed 2026-05-23. The chat+curator architecture
|
||||
// has tools=[] on the chat model; think on a no-tools conversational pass
|
||||
// is pure latency cost. See generation_task.py:run_generation comment for
|
||||
@@ -403,8 +388,6 @@ onMounted(async () => {
|
||||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||||
userTimezone.value = allSettings.user_timezone ?? "";
|
||||
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
|
||||
// Default true if unset; explicit "false" disables auto-consolidation.
|
||||
autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
|
||||
if (allSettings.notify_task_reminders !== undefined) {
|
||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||
}
|
||||
@@ -992,29 +975,6 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
<!-- ── General ── -->
|
||||
<div v-show="activeTab === 'general'" class="settings-grid">
|
||||
<!-- Tasks -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Tasks</h2>
|
||||
<p class="section-desc">
|
||||
Task bodies are auto-summarized from accumulated work logs. The summary runs every few logs, plus on task close.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="autoConsolidateTasks"
|
||||
:disabled="savingAutoConsolidate"
|
||||
@change="saveAutoConsolidate"
|
||||
/>
|
||||
Auto-consolidate task bodies
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
When off, the task body is only refreshed when you click "Re-consolidate"
|
||||
on a task. Existing summaries remain in place.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Timezone -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Timezone</h2>
|
||||
|
||||
@@ -27,6 +27,7 @@ from fabledassistant.routes.profile import profile_bp
|
||||
from fabledassistant.routes.knowledge import knowledge_bp
|
||||
from fabledassistant.routes.rulebooks import rulebooks_bp
|
||||
from fabledassistant.routes.trash import trash_bp
|
||||
from fabledassistant.routes.dashboard import dashboard_bp
|
||||
from fabledassistant.mcp import mount_mcp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
@@ -87,6 +88,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(knowledge_bp)
|
||||
app.register_blueprint(rulebooks_bp)
|
||||
app.register_blueprint(trash_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
@@ -148,12 +150,14 @@ def create_app() -> Quart:
|
||||
async def startup():
|
||||
import asyncio
|
||||
|
||||
from fabledassistant.services.auth import start_auth_token_retention_loop
|
||||
from fabledassistant.services.embeddings import backfill_note_embeddings
|
||||
from fabledassistant.services.logging import start_log_retention_loop
|
||||
from fabledassistant.services.notifications import start_notification_loop
|
||||
|
||||
start_log_retention_loop()
|
||||
start_notification_loop()
|
||||
start_auth_token_retention_loop()
|
||||
|
||||
# Backfill embeddings for any notes that don't have one. Runs in the
|
||||
# background so it never blocks the server from accepting requests.
|
||||
|
||||
@@ -17,3 +17,21 @@ async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
|
||||
return None
|
||||
api_key = await lookup_key(raw_token)
|
||||
return api_key.user_id if api_key else None
|
||||
|
||||
|
||||
async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None:
|
||||
"""Resolve a Bearer token to (user_id, scope).
|
||||
|
||||
scope is 'read' or 'write'. Returns None for a missing/malformed/invalid
|
||||
token. The MCP dispatch layer uses scope to deny write-class tool calls
|
||||
from read-only keys — the same read/write boundary the REST API enforces.
|
||||
"""
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
raw_token = auth_header[len("Bearer "):].strip()
|
||||
if not raw_token:
|
||||
return None
|
||||
api_key = await lookup_key(raw_token)
|
||||
if api_key is None:
|
||||
return None
|
||||
return api_key.user_id, (api_key.scope or "write")
|
||||
|
||||
@@ -78,9 +78,76 @@ descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
||||
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
|
||||
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
|
||||
auto-purges after the operator's retention window.
|
||||
|
||||
Scribe stores reusable Processes — saved prompts/workflows (note_type
|
||||
"process"), e.g. a drift audit or a DRY pass. When the operator says "run the
|
||||
X process" or otherwise references a saved process, call list_processes() /
|
||||
get_process(name) and follow the returned prompt verbatim, including any
|
||||
"clarify first" steps it contains. Author a new one with create_process(title,
|
||||
body); edit with update_process.
|
||||
"""
|
||||
|
||||
|
||||
# Tools a read-only API key may call. Anything not listed is treated as a
|
||||
# write for read keys (default-deny), so a newly-added tool is locked down
|
||||
# until explicitly classified here.
|
||||
_READ_ONLY_TOOLS = frozenset({
|
||||
"get_event", "get_note", "get_project", "get_rule", "get_rulebook",
|
||||
"get_task", "get_recent", "enter_project",
|
||||
"list_events", "list_lists", "list_milestones", "list_notes",
|
||||
"list_persons", "list_places", "list_projects", "list_rulebooks",
|
||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||
"list_always_on_rules", "search",
|
||||
})
|
||||
|
||||
|
||||
async def _buffer_request_body(receive):
|
||||
"""Drain the ASGI request body and return (body_bytes, replay_receive).
|
||||
|
||||
The MCP sub-app still needs to read the body, so we return a fresh
|
||||
`receive` that replays the buffered bytes.
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
more = True
|
||||
while more:
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
chunks.append(message.get("body", b""))
|
||||
more = message.get("more_body", False)
|
||||
else: # http.disconnect
|
||||
more = False
|
||||
body = b"".join(chunks)
|
||||
|
||||
sent = False
|
||||
|
||||
async def replay():
|
||||
nonlocal sent
|
||||
if not sent:
|
||||
sent = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
return body, replay
|
||||
|
||||
|
||||
def _body_calls_write_tool(body: bytes) -> bool:
|
||||
"""True if the JSON-RPC body invokes a tool outside the read all-list."""
|
||||
import json
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except Exception:
|
||||
return False
|
||||
items = payload if isinstance(payload, list) else [payload]
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("method") == "tools/call":
|
||||
name = (item.get("params") or {}).get("name", "")
|
||||
if name and name not in _READ_ONLY_TOOLS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_mcp_server() -> FastMCP:
|
||||
"""Build the FastMCP instance with all tools registered.
|
||||
|
||||
@@ -128,7 +195,7 @@ def mount_mcp(app: Quart) -> None:
|
||||
inside Quart, we hook the session manager's `run()` async context manager
|
||||
into Quart's serving lifecycle (before_serving / after_serving).
|
||||
"""
|
||||
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
|
||||
from fabledassistant.mcp.auth import resolve_bearer
|
||||
|
||||
mcp = build_mcp_server()
|
||||
mcp_asgi = mcp.streamable_http_app()
|
||||
@@ -151,8 +218,8 @@ def mount_mcp(app: Quart) -> None:
|
||||
return await mcp_asgi(scope, receive, send)
|
||||
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
|
||||
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
||||
user_id = await resolve_bearer_to_user_id(headers.get("authorization"))
|
||||
if user_id is None:
|
||||
resolved = await resolve_bearer(headers.get("authorization"))
|
||||
if resolved is None:
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
@@ -166,6 +233,27 @@ def mount_mcp(app: Quart) -> None:
|
||||
"body": b'{"error":"unauthorized"}',
|
||||
})
|
||||
return
|
||||
user_id, key_scope = resolved
|
||||
|
||||
# Enforce read-only keys: REST blocks non-GET for scope='read', and the
|
||||
# MCP surface must match or the read-only guarantee is void. A tool call
|
||||
# arrives as a JSON-RPC POST; buffer the body, and if it invokes a tool
|
||||
# outside the read all-list, reject before dispatch. (default-deny: any
|
||||
# unknown/new tool is treated as a write for read keys.)
|
||||
if key_scope == "read" and scope.get("method") == "POST":
|
||||
body, receive = await _buffer_request_body(receive)
|
||||
if _body_calls_write_tool(body):
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 403,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b'{"error":"read-only API key cannot call write tools"}',
|
||||
})
|
||||
return
|
||||
|
||||
scope["scribe_user_id"] = user_id
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
token = _user_id_ctx.set(user_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||
from `mcp.server.build_mcp_server`.
|
||||
"""
|
||||
from fabledassistant.mcp.tools import (
|
||||
entities, events, milestones, notes, projects, recent, rulebooks, search, tags, tasks, trash,
|
||||
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@ def register_all(mcp) -> None:
|
||||
tags.register(mcp)
|
||||
recent.register(mcp)
|
||||
entities.register(mcp)
|
||||
processes.register(mcp)
|
||||
rulebooks.register(mcp)
|
||||
trash.register(mcp)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Stored-process MCP tools: reusable saved prompts (note_type='process').
|
||||
|
||||
A process is a Note whose body is a prompt the operator fires later
|
||||
("run the X process"). Mirrors entities.py — the tools wrap notes_svc directly.
|
||||
get_process is the fire mechanism: it returns the full prompt for Claude to run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fabledassistant.mcp._context import current_user_id
|
||||
from fabledassistant.services import knowledge as knowledge_svc
|
||||
from fabledassistant.services import notes as notes_svc
|
||||
|
||||
|
||||
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
||||
"""List stored processes (reusable saved prompts).
|
||||
|
||||
Args:
|
||||
q: Free-text search across title + body (optional).
|
||||
tag: Filter to a single tag (optional).
|
||||
limit: Max results (1-100).
|
||||
|
||||
Returns {"processes": [{id, title, tags, preview}], "total": int}.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
items, total = await knowledge_svc.query_knowledge(
|
||||
user_id=uid, note_type="process", tags=[tag] if tag else [],
|
||||
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
|
||||
)
|
||||
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
|
||||
"preview": it.get("snippet", "")} for it in items]
|
||||
return {"processes": procs, "total": total}
|
||||
|
||||
|
||||
async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict:
|
||||
"""Create a stored process (a reusable saved prompt).
|
||||
|
||||
Args:
|
||||
title: Process name, e.g. "Drift Audit" (required).
|
||||
body: The full prompt to run later (markdown). Required.
|
||||
tags: Plain-string tags, no # prefix.
|
||||
"""
|
||||
if not (title or "").strip() or not (body or "").strip():
|
||||
raise ValueError("create_process requires a non-empty title and body")
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.create_note(
|
||||
uid, title=title.strip(), body=body, note_type="process", tags=tags,
|
||||
)
|
||||
return note.to_dict()
|
||||
|
||||
|
||||
async def get_process(name_or_id: str) -> dict:
|
||||
"""Fetch a stored process by name or id and return its full prompt — the
|
||||
fire mechanism. The operator says "run the <name> process"; call this and
|
||||
follow the returned body (including any 'clarify first' steps it contains).
|
||||
|
||||
Resolution: numeric id → exact (case-insensitive) title → substring. On an
|
||||
ambiguous substring match, the best (most-recent) match is returned with an
|
||||
`other_matches` list so you can disambiguate with the operator.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
|
||||
if note is None:
|
||||
raise ValueError(f"process {name_or_id!r} not found")
|
||||
out = note.to_dict()
|
||||
if candidates:
|
||||
out["other_matches"] = candidates
|
||||
return out
|
||||
|
||||
|
||||
async def update_process(process_id: int, title: str = "", body: str = "",
|
||||
tags: list[str] | None = None) -> dict:
|
||||
"""Update a stored process. Only provided fields change — empty title/body
|
||||
leave that field unchanged; pass tags to replace the tag set."""
|
||||
uid = current_user_id()
|
||||
note = await notes_svc.get_note(uid, process_id)
|
||||
if note is None or note.note_type != "process":
|
||||
raise ValueError(f"process {process_id} not found")
|
||||
fields: dict = {}
|
||||
if title.strip():
|
||||
fields["title"] = title.strip()
|
||||
if body.strip():
|
||||
fields["body"] = body
|
||||
if tags is not None:
|
||||
fields["tags"] = tags
|
||||
updated = await notes_svc.update_note(uid, process_id, **fields)
|
||||
return updated.to_dict()
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (list_processes, create_process, get_process, update_process):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
@@ -27,7 +27,7 @@ from fabledassistant.services import trash as trash_svc
|
||||
async def list_projects() -> dict:
|
||||
"""List all Scribe projects for the current user.
|
||||
|
||||
Returns id, title, description, goal, status (active/archived), color,
|
||||
Returns id, title, description, goal, status (active/paused/completed/archived), color,
|
||||
and a short auto-generated summary for each project.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
@@ -142,7 +142,7 @@ async def create_project(
|
||||
title: Project name (required).
|
||||
description: Short summary of what the project is.
|
||||
goal: The desired outcome or definition of done for the project.
|
||||
status: active (default) or archived.
|
||||
status: one of active (default), paused, completed, archived.
|
||||
color: Optional hex colour for the project card (e.g. "#6366f1").
|
||||
"""
|
||||
uid = current_user_id()
|
||||
@@ -172,7 +172,7 @@ async def update_project(
|
||||
title: New title, or omit to leave unchanged.
|
||||
description: New description, or omit to leave unchanged.
|
||||
goal: New goal/definition-of-done, or omit to leave unchanged.
|
||||
status: New status — active or archived.
|
||||
status: New status — one of active, paused, completed, archived.
|
||||
color: New hex colour, or omit to leave unchanged.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
|
||||
@@ -38,7 +38,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
||||
items: list[dict] = []
|
||||
async with async_session() as session:
|
||||
notes = (await session.execute(
|
||||
select(Note).where(Note.user_id == uid, Note.updated_at >= since)
|
||||
select(Note).where(Note.user_id == uid, Note.updated_at >= since,
|
||||
Note.deleted_at.is_(None))
|
||||
.order_by(Note.updated_at.desc()).limit(limit)
|
||||
)).scalars().all()
|
||||
for n in notes:
|
||||
@@ -50,7 +51,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
||||
})
|
||||
projects = (await session.execute(
|
||||
select(Project).where(Project.user_id == uid,
|
||||
Project.updated_at >= since)
|
||||
Project.updated_at >= since,
|
||||
Project.deleted_at.is_(None))
|
||||
.order_by(Project.updated_at.desc()).limit(limit)
|
||||
)).scalars().all()
|
||||
for p in projects:
|
||||
@@ -62,7 +64,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
||||
})
|
||||
events = (await session.execute(
|
||||
select(Event).where(Event.user_id == uid,
|
||||
Event.updated_at >= since)
|
||||
Event.updated_at >= since,
|
||||
Event.deleted_at.is_(None))
|
||||
.order_by(Event.updated_at.desc()).limit(limit)
|
||||
)).scalars().all()
|
||||
for e in events:
|
||||
|
||||
@@ -322,7 +322,7 @@ async def update_rule(
|
||||
|
||||
|
||||
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
||||
"""Permanently delete a rule. Requires confirmed=True."""
|
||||
"""Move a rule to the trash (recoverable). Requires confirmed=True."""
|
||||
uid = current_user_id()
|
||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||
if rule is None:
|
||||
@@ -330,8 +330,8 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
||||
if not confirmed:
|
||||
return {
|
||||
"warning": (
|
||||
f"Rule {rule_id} ('{rule.title}') will be permanently deleted. "
|
||||
f"Pass confirmed=True to proceed."
|
||||
f"Rule {rule_id} ('{rule.title}') will be moved to the trash "
|
||||
f"(recoverable via restore). Pass confirmed=True to proceed."
|
||||
),
|
||||
"confirmed_required": True,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async def list_tags(limit: int = 50) -> dict:
|
||||
limit = max(1, min(limit, 200))
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Note.tags).where(Note.user_id == uid)
|
||||
select(Note.tags).where(Note.user_id == uid, Note.deleted_at.is_(None))
|
||||
)
|
||||
tag_lists = [row[0] for row in result.all()]
|
||||
counts = _aggregate_tag_counts(tag_lists)
|
||||
|
||||
@@ -8,6 +8,7 @@ from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
|
||||
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
paused = "paused"
|
||||
completed = "completed"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
|
||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
@@ -42,8 +42,13 @@ class Rulebook(Base, SoftDeleteMixin):
|
||||
|
||||
class RulebookTopic(Base, SoftDeleteMixin):
|
||||
__tablename__ = "rulebook_topics"
|
||||
# Partial unique: a title is unique among LIVE topics in a rulebook, so a
|
||||
# trashed topic doesn't block recreating/restoring the same title.
|
||||
__table_args__ = (
|
||||
UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
|
||||
Index(
|
||||
"uq_topic_per_rulebook", "rulebook_id", "title",
|
||||
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
@@ -76,8 +81,13 @@ class RulebookTopic(Base, SoftDeleteMixin):
|
||||
|
||||
class Rule(Base, SoftDeleteMixin):
|
||||
__tablename__ = "rules"
|
||||
# Partial unique: title unique among LIVE rules in a topic (soft-deleted
|
||||
# rules don't block recreating/restoring the same title).
|
||||
__table_args__ = (
|
||||
UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
|
||||
Index(
|
||||
"uq_rule_per_topic", "topic_id", "title",
|
||||
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -27,13 +25,6 @@ class UserProfile(Base, TimestampMixin):
|
||||
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)
|
||||
# LLM-consolidated summary of learned preferences
|
||||
learned_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# [{date: "YYYY-MM-DD", bullets: "..."}, ...]
|
||||
observations_raw: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||
observations_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -45,11 +36,4 @@ class UserProfile(Base, TimestampMixin):
|
||||
"tone": self.tone or "casual",
|
||||
"interests": self.interests or [],
|
||||
"work_schedule": self.work_schedule or {},
|
||||
"learned_summary": self.learned_summary or "",
|
||||
"observations_count": len(self.observations_raw or []),
|
||||
"observations_updated_at": (
|
||||
self.observations_updated_at.isoformat()
|
||||
if self.observations_updated_at
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Dashboard REST endpoint — the aggregated landing payload."""
|
||||
from quart import Blueprint, g, jsonify
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
from fabledassistant.services.dashboard import build_dashboard
|
||||
|
||||
dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/api/dashboard")
|
||||
|
||||
|
||||
@dashboard_bp.get("")
|
||||
@login_required
|
||||
async def get_dashboard():
|
||||
return jsonify(await build_dashboard(g.user.id))
|
||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
||||
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan"}
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan", "process"}
|
||||
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
|
||||
from fabledassistant.services.access import can_write_note
|
||||
from fabledassistant.services.notes import (
|
||||
build_note_graph,
|
||||
convert_note_to_task,
|
||||
convert_task_to_note,
|
||||
create_note,
|
||||
delete_note,
|
||||
get_all_tags,
|
||||
get_backlinks,
|
||||
get_note,
|
||||
@@ -192,6 +192,15 @@ async def get_note_route(note_id: int):
|
||||
@login_required
|
||||
async def update_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
# Share-aware: resolve through the ACL and write as the OWNER, so a shared
|
||||
# editor's save isn't rejected by the owner-scoped update service.
|
||||
result = await get_note_for_user(uid, note_id)
|
||||
if result is None:
|
||||
return not_found("Note")
|
||||
note_obj, _ = result
|
||||
if not await can_write_note(uid, note_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
owner_uid = note_obj.user_id
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
@@ -212,14 +221,14 @@ async def update_note_route(note_id: int):
|
||||
if "tags" in data:
|
||||
fields["tags"] = data["tags"]
|
||||
try:
|
||||
note = await update_note(uid, note_id, **fields)
|
||||
note = await update_note(owner_uid, note_id, **fields)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
||||
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@@ -227,6 +236,13 @@ async def update_note_route(note_id: int):
|
||||
@login_required
|
||||
async def patch_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, note_id)
|
||||
if result is None:
|
||||
return not_found("Note")
|
||||
note_obj, _ = result
|
||||
if not await can_write_note(uid, note_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
owner_uid = note_obj.user_id
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
@@ -245,14 +261,14 @@ async def patch_note_route(note_id: int):
|
||||
if "tags" in data:
|
||||
fields["tags"] = data["tags"]
|
||||
try:
|
||||
note = await update_note(uid, note_id, **fields)
|
||||
note = await update_note(owner_uid, note_id, **fields)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if note is None:
|
||||
return not_found("Note")
|
||||
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
|
||||
if text:
|
||||
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
|
||||
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
|
||||
@@ -260,8 +276,14 @@ async def patch_note_route(note_id: int):
|
||||
@login_required
|
||||
async def delete_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
result = await get_note_for_user(uid, note_id)
|
||||
if result is None:
|
||||
return not_found("Note")
|
||||
note_obj, _ = result
|
||||
if not await can_write_note(uid, note_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
from fabledassistant.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(uid, "note", note_id)
|
||||
batch = await trash_delete(note_obj.user_id, "note", note_id)
|
||||
if batch is None:
|
||||
return not_found("Note")
|
||||
return "", 204
|
||||
|
||||
@@ -53,7 +53,7 @@ async def create_project_route():
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
status = data.get("status", "active")
|
||||
if status not in ("active", "paused", "completed", "archived"):
|
||||
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
||||
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
|
||||
project = await create_project(
|
||||
uid,
|
||||
title=data["title"],
|
||||
@@ -90,7 +90,7 @@ async def update_project_route(project_id: int):
|
||||
allowed = {"title", "description", "goal", "status", "color"}
|
||||
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
|
||||
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
|
||||
return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
|
||||
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
|
||||
project = await update_project(uid, project_id, **fields)
|
||||
if project is None:
|
||||
return not_found("Project")
|
||||
@@ -116,6 +116,9 @@ async def get_project_notes_route(project_id: int):
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, _ = result
|
||||
# Use the project owner's uid so the ownership filter on notes/milestones
|
||||
# matches for shared collaborators (who'd otherwise see an empty panel).
|
||||
owner_uid = project.user_id or uid
|
||||
|
||||
# type filter: "note", "task", or None (both)
|
||||
type_filter = request.args.get("type")
|
||||
@@ -128,11 +131,11 @@ async def get_project_notes_route(project_id: int):
|
||||
elif type_filter == "note":
|
||||
is_task = False
|
||||
|
||||
ms_list = await list_milestones(uid, project_id)
|
||||
ms_list = await list_milestones(owner_uid, project_id)
|
||||
milestone_ids = [m.id for m in ms_list]
|
||||
|
||||
notes, total = await list_notes(
|
||||
uid,
|
||||
owner_uid,
|
||||
is_task=is_task,
|
||||
status=status_filter,
|
||||
project_id=project_id,
|
||||
|
||||
@@ -118,7 +118,8 @@ async def update_topic(topic_id: int):
|
||||
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
|
||||
@login_required
|
||||
async def delete_topic(topic_id: int):
|
||||
await trash_delete(_uid(), "topic", topic_id)
|
||||
if await trash_delete(_uid(), "topic", topic_id) is None:
|
||||
return jsonify({"error": "topic not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@@ -196,7 +197,8 @@ async def update_rule(rule_id: int):
|
||||
@rulebooks_bp.delete("/rules/<int:rule_id>")
|
||||
@login_required
|
||||
async def delete_rule(rule_id: int):
|
||||
await trash_delete(_uid(), "rule", rule_id)
|
||||
if await trash_delete(_uid(), "rule", rule_id) is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from fabledassistant.services.access import can_write_note
|
||||
from fabledassistant.services.embeddings import upsert_note_embedding
|
||||
from fabledassistant.services.notes import (
|
||||
create_note,
|
||||
delete_note,
|
||||
get_note,
|
||||
get_note_for_user,
|
||||
list_notes,
|
||||
|
||||
@@ -106,6 +106,11 @@ async def change_password(user_id: int, current_password: str, new_password: str
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
return None
|
||||
# OAuth-only accounts have no local password hash — verify_password
|
||||
# would crash on None. Treat as "no local credential to change" (the
|
||||
# route turns None into a clean 4xx, not a 500).
|
||||
if user.password_hash is None:
|
||||
return None
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
return None
|
||||
user.password_hash = hash_password(new_password)
|
||||
@@ -325,12 +330,23 @@ async def register_with_invitation(raw_token: str, username: str, password: str)
|
||||
if not invitation or invitation.used or invitation.expires_at < datetime.now(timezone.utc):
|
||||
return None
|
||||
|
||||
invitation.used = True
|
||||
await session.commit()
|
||||
invitation_id = invitation.id
|
||||
invite_email = invitation.email
|
||||
|
||||
# Create user outside the invitation session
|
||||
user = await create_user(username, password, invitation.email)
|
||||
logger.info("User '%s' registered via invitation for %s", username, invitation.email)
|
||||
# Create the user FIRST, then consume the token. create_user can fail (e.g.
|
||||
# a username collision raises and the route returns 409); marking the token
|
||||
# used before that would burn this single-use invite and lock the invitee
|
||||
# out of retrying. Ordering it after means a failed creation leaves the
|
||||
# token valid.
|
||||
user = await create_user(username, password, invite_email)
|
||||
|
||||
async with async_session() as session:
|
||||
invitation = await session.get(InvitationToken, invitation_id)
|
||||
if invitation is not None:
|
||||
invitation.used = True
|
||||
await session.commit()
|
||||
|
||||
logger.info("User '%s' registered via invitation for %s", username, invite_email)
|
||||
return user
|
||||
|
||||
|
||||
@@ -356,3 +372,46 @@ async def revoke_invitation(invitation_id: int) -> bool:
|
||||
await session.commit()
|
||||
logger.info("Invitation %d revoked", invitation_id)
|
||||
return True
|
||||
|
||||
|
||||
# ── Token retention ─────────────────────────────────────────────────────
|
||||
# Password-reset and invitation tokens are only ever flipped used=True and are
|
||||
# never pruned, so on a long-lived instance both tables grow without bound.
|
||||
# A daily sweep deletes any token whose validity window ended over grace_days
|
||||
# ago (covers both used and naturally-expired rows once they're cold).
|
||||
|
||||
_auth_retention_task = None
|
||||
|
||||
|
||||
async def purge_expired_auth_tokens(grace_days: int = 7) -> int:
|
||||
"""Delete password-reset / invitation tokens that expired > grace_days ago."""
|
||||
from sqlalchemy import delete
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=grace_days)
|
||||
removed = 0
|
||||
async with async_session() as session:
|
||||
for model in (PasswordResetToken, InvitationToken):
|
||||
result = await session.execute(
|
||||
delete(model).where(model.expires_at < cutoff)
|
||||
)
|
||||
removed += result.rowcount or 0
|
||||
await session.commit()
|
||||
return removed
|
||||
|
||||
|
||||
async def _auth_token_retention_loop() -> None:
|
||||
import asyncio
|
||||
while True:
|
||||
await asyncio.sleep(86400) # daily
|
||||
try:
|
||||
removed = await purge_expired_auth_tokens()
|
||||
if removed:
|
||||
logger.info("Auth token retention: deleted %d expired token(s)", removed)
|
||||
except Exception:
|
||||
logger.exception("Error in auth token retention cleanup")
|
||||
|
||||
|
||||
def start_auth_token_retention_loop() -> None:
|
||||
global _auth_retention_task
|
||||
import asyncio
|
||||
if _auth_retention_task is None or _auth_retention_task.done():
|
||||
_auth_retention_task = asyncio.create_task(_auth_token_retention_loop())
|
||||
|
||||
@@ -14,6 +14,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"]
|
||||
|
||||
# Sentinel: distinguishes "leave the RRULE untouched" from "clear it" (None/"")
|
||||
# in update_event, since None is a meaningful value for recurrence.
|
||||
_RECURRENCE_UNSET = object()
|
||||
|
||||
|
||||
async def get_caldav_config(user_id: int) -> dict[str, str]:
|
||||
"""Return the user's CalDAV config from their settings."""
|
||||
@@ -214,14 +218,22 @@ async def create_event(
|
||||
result_end = d_end.isoformat()
|
||||
else:
|
||||
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
|
||||
event.add("dtstart", dt_start)
|
||||
result_start = dt_start.isoformat()
|
||||
if end:
|
||||
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
|
||||
elif duration:
|
||||
dt_end = dt_start + timedelta(minutes=duration)
|
||||
else:
|
||||
dt_end = dt_start + timedelta(minutes=duration or 60)
|
||||
event.add("dtstart", dt_start)
|
||||
event.add("dtend", dt_end)
|
||||
result_start = dt_start.isoformat()
|
||||
result_end = dt_end.isoformat()
|
||||
dt_end = None
|
||||
if dt_end is not None:
|
||||
event.add("dtend", dt_end)
|
||||
result_end = dt_end.isoformat()
|
||||
else:
|
||||
# Point event (no end, no duration): emit DTSTART only. Fabricating
|
||||
# a 60-min DTEND here would round-trip back on the next pull as
|
||||
# duration_minutes=60, silently lengthening a point event.
|
||||
result_end = None
|
||||
|
||||
if description:
|
||||
event.add("description", description)
|
||||
@@ -325,8 +337,14 @@ async def update_event(
|
||||
location: str | None = None,
|
||||
timezone: str | None = None,
|
||||
calendar_name: str | None = None,
|
||||
recurrence: str | None | object = _RECURRENCE_UNSET,
|
||||
) -> dict:
|
||||
"""Update a calendar event matching the query."""
|
||||
"""Update a calendar event matching the query.
|
||||
|
||||
``recurrence``: leave at the sentinel to keep the existing RRULE; pass an
|
||||
RRULE string to set it, or None/"" to remove it. The push path passes the
|
||||
local event's recurrence so RRULE edits propagate to the server.
|
||||
"""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
tz = timezone or config.get("caldav_timezone") or None
|
||||
@@ -382,6 +400,18 @@ async def update_event(
|
||||
if "LOCATION" in component:
|
||||
del component["LOCATION"]
|
||||
component.add("location", location)
|
||||
if recurrence is not _RECURRENCE_UNSET:
|
||||
# Authoritatively sync the RRULE to the local event: drop the old
|
||||
# rule, then re-add if a non-empty rule was provided (else clear it).
|
||||
if "RRULE" in component:
|
||||
del component["RRULE"]
|
||||
if recurrence:
|
||||
rrule_parts = {}
|
||||
for part in str(recurrence).split(";"):
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
rrule_parts[key.strip().lower()] = value.strip()
|
||||
component.add("rrule", rrule_parts)
|
||||
|
||||
# Rebuild ical data and save
|
||||
cal_data = icalendar.Calendar()
|
||||
|
||||
@@ -11,7 +11,7 @@ import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
@@ -20,6 +20,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_SYNC_PAST_DAYS = 30
|
||||
_SYNC_FUTURE_DAYS = 180
|
||||
# Wall-clock cap on the blocking CalDAV fetch so a hung/slow server can't
|
||||
# wedge the hourly sweep indefinitely.
|
||||
_SYNC_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
def _parse_dt(val: Any) -> datetime | None:
|
||||
@@ -116,16 +119,24 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
|
||||
config = await get_caldav_config(user_id)
|
||||
|
||||
started = datetime.now(timezone.utc)
|
||||
range_start = started - timedelta(days=_SYNC_PAST_DAYS)
|
||||
range_end = started + timedelta(days=_SYNC_FUTURE_DAYS)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
remote_events: list[dict] = await loop.run_in_executor(
|
||||
None, _sync_one_user, config, user_id
|
||||
remote_events: list[dict] = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, _sync_one_user, config, user_id),
|
||||
timeout=_SYNC_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("CalDAV pull sync timed out for user %d after %ds", user_id, _SYNC_TIMEOUT_SECONDS)
|
||||
return {"error": "CalDAV fetch timed out"}
|
||||
except Exception:
|
||||
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
|
||||
return {"error": "CalDAV fetch failed"}
|
||||
|
||||
created = updated = unchanged = 0
|
||||
created = updated = unchanged = skipped = deleted = 0
|
||||
|
||||
async with async_session() as session:
|
||||
for ev in remote_events:
|
||||
@@ -149,6 +160,14 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None and existing.deleted_at is not None:
|
||||
# The user trashed this event locally. Don't resurrect it by
|
||||
# updating, and don't create a duplicate live copy — leave it
|
||||
# in the trash. (Propagating the delete to the remote server is
|
||||
# tracked separately.)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if existing is None:
|
||||
# Create new event
|
||||
new_ev = Event(
|
||||
@@ -177,13 +196,38 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
# Reconcile deletions: a previously-synced event (has a caldav_uid)
|
||||
# that no longer appears remotely within the synced window is
|
||||
# soft-deleted, so a delete on the remote propagates locally instead
|
||||
# of orphaning forever. Guarded on a non-empty fetch so a spurious
|
||||
# empty result can't wipe every local copy.
|
||||
if remote_events:
|
||||
remote_uids = {e["caldav_uid"] for e in remote_events}
|
||||
orphan_batch = str(uuid.uuid4())
|
||||
orphan_res = await session.execute(
|
||||
update(Event)
|
||||
.where(
|
||||
Event.user_id == user_id,
|
||||
Event.caldav_uid.isnot(None),
|
||||
Event.caldav_uid.notin_(remote_uids),
|
||||
Event.deleted_at.is_(None),
|
||||
Event.start_dt >= range_start,
|
||||
Event.start_dt <= range_end,
|
||||
)
|
||||
.values(deleted_at=datetime.now(timezone.utc), deleted_batch_id=orphan_batch)
|
||||
)
|
||||
deleted = orphan_res.rowcount or 0
|
||||
|
||||
await session.commit()
|
||||
|
||||
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
|
||||
logger.info(
|
||||
"CalDAV sync user %d: %d created, %d updated, %d unchanged",
|
||||
user_id, created, updated, unchanged,
|
||||
"CalDAV sync user %d: %d created, %d updated, %d unchanged, %d skipped (trashed), "
|
||||
"%d deleted (orphaned) in %.1fs",
|
||||
user_id, created, updated, unchanged, skipped, deleted, elapsed,
|
||||
)
|
||||
return {"created": created, "updated": updated, "unchanged": unchanged}
|
||||
return {"created": created, "updated": updated, "unchanged": unchanged,
|
||||
"skipped": skipped, "deleted": deleted}
|
||||
|
||||
|
||||
async def sync_all_users() -> None:
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
"""Calendar sync service: bridges the DB Event table with local Radicale.
|
||||
|
||||
Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/
|
||||
The app connects without auth (Radicale runs with auth.type = none inside Docker).
|
||||
|
||||
External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232.
|
||||
Their CalDAV URL is: http://<host>:5232/user_{user_id}/calendar/
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import icalendar
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Internal Radicale URL (Docker service name)
|
||||
_RADICALE_INTERNAL = "http://radicale:5232"
|
||||
|
||||
|
||||
def _user_calendar_path(user_id: int) -> str:
|
||||
return f"/user_{user_id}/calendar/"
|
||||
|
||||
|
||||
def _user_calendar_url(user_id: int) -> str:
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
return f"{base}/user_{user_id}/calendar/"
|
||||
|
||||
|
||||
async def setup_user_calendar(user_id: int) -> bool:
|
||||
"""Create Radicale collection for user if it doesn't exist.
|
||||
|
||||
Returns True on success or if already exists, False on error.
|
||||
"""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
user_url = f"{base}/user_{user_id}/"
|
||||
cal_url = f"{base}/user_{user_id}/calendar/"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Create user home collection
|
||||
await client.request(
|
||||
"MKCOL",
|
||||
user_url,
|
||||
headers={"Content-Type": "text/xml"},
|
||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
||||
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<set><prop><resourcetype><collection/></resourcetype></prop></set>
|
||||
</mkcol>""",
|
||||
)
|
||||
# Create calendar collection
|
||||
resp = await client.request(
|
||||
"MKCOL",
|
||||
cal_url,
|
||||
headers={"Content-Type": "text/xml"},
|
||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
||||
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<set>
|
||||
<prop>
|
||||
<resourcetype><collection/><C:calendar/></resourcetype>
|
||||
<displayname>Fabled Scribe</displayname>
|
||||
</prop>
|
||||
</set>
|
||||
</mkcol>""",
|
||||
)
|
||||
if resp.status_code in (201, 405): # 405 = already exists
|
||||
logger.info("Calendar collection ready for user %d", user_id)
|
||||
return True
|
||||
logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _make_vcalendar(event: Event) -> str:
|
||||
"""Build iCalendar string from DB Event."""
|
||||
cal = icalendar.Calendar()
|
||||
cal.add("prodid", "-//FabledAssistant//EN")
|
||||
cal.add("version", "2.0")
|
||||
|
||||
vevent = icalendar.Event()
|
||||
vevent.add("uid", event.uid)
|
||||
vevent.add("summary", event.title)
|
||||
|
||||
if event.all_day:
|
||||
vevent.add("dtstart", event.start_dt.date())
|
||||
if event.end_dt:
|
||||
vevent.add("dtend", event.end_dt.date())
|
||||
else:
|
||||
vevent.add("dtstart", event.start_dt)
|
||||
if event.end_dt:
|
||||
vevent.add("dtend", event.end_dt)
|
||||
|
||||
if event.description:
|
||||
vevent.add("description", event.description)
|
||||
if event.location:
|
||||
vevent.add("location", event.location)
|
||||
if event.recurrence:
|
||||
rrule_parts: dict = {}
|
||||
for part in event.recurrence.split(";"):
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
rrule_parts[key.strip().lower()] = value.strip()
|
||||
if rrule_parts:
|
||||
vevent.add("rrule", rrule_parts)
|
||||
|
||||
vevent.add("dtstamp", datetime.now(timezone.utc))
|
||||
cal.add_component(vevent)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
|
||||
async def push_event_to_radicale(user_id: int, event: Event) -> bool:
|
||||
"""Write a DB Event to Radicale. Creates or updates the resource.
|
||||
|
||||
Returns True on success.
|
||||
"""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics"
|
||||
ical_data = _make_vcalendar(event)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.put(
|
||||
resource_url,
|
||||
content=ical_data.encode("utf-8"),
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
if resp.status_code in (200, 201, 204):
|
||||
return True
|
||||
logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid)
|
||||
return False
|
||||
except Exception:
|
||||
logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def delete_event_from_radicale(user_id: int, uid: str) -> bool:
|
||||
"""Delete an event from Radicale by UID."""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.delete(resource_url)
|
||||
return resp.status_code in (200, 204, 404) # 404 is ok (already gone)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
"""Fetch an event from Radicale and upsert into the DB Event table.
|
||||
|
||||
Returns the upserted Event or None on error.
|
||||
"""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(resource_url)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
ical_data = resp.text
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
cal = icalendar.Calendar.from_ical(ical_data)
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
title = str(component.get("SUMMARY", ""))
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
description = str(component.get("DESCRIPTION", ""))
|
||||
location = str(component.get("LOCATION", ""))
|
||||
rrule = component.get("RRULE")
|
||||
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
||||
|
||||
all_day = False
|
||||
if dtstart and isinstance(dtstart.dt, datetime):
|
||||
start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc)
|
||||
elif dtstart:
|
||||
from datetime import date
|
||||
d = dtstart.dt
|
||||
start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
all_day = True
|
||||
else:
|
||||
continue
|
||||
|
||||
end_dt = None
|
||||
if dtend and isinstance(dtend.dt, datetime):
|
||||
end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc)
|
||||
elif dtend:
|
||||
from datetime import date
|
||||
d = dtend.dt
|
||||
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
|
||||
# Storage uses duration, not end_dt. Convert iCal DTEND to a
|
||||
# minute count anchored on DTSTART. Treat invalid (end <= start)
|
||||
# incoming data as a point event rather than rejecting; we
|
||||
# don't control external CalDAV writers.
|
||||
duration_minutes = None
|
||||
if end_dt is not None and end_dt > start_dt:
|
||||
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.start_dt = start_dt
|
||||
existing.duration_minutes = duration_minutes
|
||||
existing.all_day = all_day
|
||||
existing.description = description
|
||||
existing.location = location
|
||||
existing.recurrence = recurrence
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
else:
|
||||
event_obj = Event(
|
||||
user_id=user_id,
|
||||
uid=ical_uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
duration_minutes=duration_minutes,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
recurrence=recurrence,
|
||||
)
|
||||
session.add(event_obj)
|
||||
await session.commit()
|
||||
await session.refresh(event_obj)
|
||||
return event_obj
|
||||
except Exception:
|
||||
logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def sync_all_events_from_radicale(user_id: int) -> int:
|
||||
"""Full sync: fetch all events from Radicale calendar and upsert into DB.
|
||||
|
||||
Returns number of events synced.
|
||||
"""
|
||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
||||
cal_url = f"{base}/user_{user_id}/calendar/"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# PROPFIND to get all .ics resources
|
||||
resp = await client.request(
|
||||
"PROPFIND",
|
||||
cal_url,
|
||||
headers={"Depth": "1", "Content-Type": "text/xml"},
|
||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:"><prop><getetag/><getcontenttype/></prop></propfind>""",
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
logger.info("No calendar found for user %d — setting up", user_id)
|
||||
await setup_user_calendar(user_id)
|
||||
return 0
|
||||
if resp.status_code != 207:
|
||||
logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id)
|
||||
return 0
|
||||
# Extract UIDs from href paths
|
||||
import re
|
||||
hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)</[Dd]:[Hh]ref>", resp.text)
|
||||
uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs]
|
||||
except Exception:
|
||||
logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True)
|
||||
return 0
|
||||
|
||||
synced = 0
|
||||
for uid in uids:
|
||||
if uid:
|
||||
event = await sync_event_to_db(user_id, uid)
|
||||
if event:
|
||||
synced += 1
|
||||
|
||||
logger.info("Synced %d events from Radicale for user %d", synced, user_id)
|
||||
return synced
|
||||
|
||||
|
||||
def get_external_caldav_url(user_id: int) -> str:
|
||||
"""Return the CalDAV URL for external clients (iOS, macOS, Thunderbird).
|
||||
|
||||
Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL.
|
||||
"""
|
||||
external = Config.RADICALE_EXTERNAL_URL
|
||||
if external:
|
||||
return f"{external.rstrip('/')}/user_{user_id}/calendar/"
|
||||
# Fall back to BASE_URL host with port 5232
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(Config.BASE_URL)
|
||||
host = parsed.hostname or "localhost"
|
||||
return f"http://{host}:5232/user_{user_id}/calendar/"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Dashboard aggregation — assembles the /dashboard landing payload.
|
||||
|
||||
One call: most-recently-active projects (each -> active milestones -> open
|
||||
tasks), recently-completed tasks, upcoming events, week stats. Owner-scoped,
|
||||
trashed rows excluded. Each section is independent — a failure returns its
|
||||
empty value rather than blanking the page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
from fabledassistant.services import milestones as milestones_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
N_PROJECTS = 3 # most-recently-active projects shown
|
||||
TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group
|
||||
RECENT_DONE_LIMIT = 8 # recently-completed tasks shown
|
||||
WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window
|
||||
_OPEN = ["todo", "in_progress"]
|
||||
|
||||
|
||||
def _open_order():
|
||||
"""in-progress first -> priority high..none -> most-recently-updated."""
|
||||
status_rank = case((Note.status == "in_progress", 0), else_=1)
|
||||
priority_rank = case(
|
||||
(Note.priority == "high", 0), (Note.priority == "medium", 1),
|
||||
(Note.priority == "low", 2), else_=3,
|
||||
)
|
||||
return status_rank, priority_rank, Note.updated_at.desc()
|
||||
|
||||
|
||||
def _task_row(n: Note) -> dict:
|
||||
return {"id": n.id, "title": n.title, "status": n.status,
|
||||
"priority": n.priority or "none"}
|
||||
|
||||
|
||||
async def _safe(coro, empty):
|
||||
try:
|
||||
return await coro
|
||||
except Exception:
|
||||
logger.warning("dashboard section failed", exc_info=True)
|
||||
return empty
|
||||
|
||||
|
||||
async def build_dashboard(user_id: int) -> dict:
|
||||
return {
|
||||
"active_projects": await _safe(_active_projects(user_id), []),
|
||||
"recently_completed": await _safe(_recently_completed(user_id), []),
|
||||
"upcoming_events": await _safe(_upcoming_events(user_id), []),
|
||||
"week_stats": await _safe(_week_stats(user_id), {}),
|
||||
}
|
||||
|
||||
|
||||
async def _active_projects(user_id: int) -> list[dict]:
|
||||
so, po, ro = _open_order()
|
||||
async with async_session() as session:
|
||||
recency = (
|
||||
select(Note.project_id, func.max(Note.updated_at).label("last"))
|
||||
.where(Note.user_id == user_id, Note.deleted_at.is_(None),
|
||||
Note.project_id.isnot(None))
|
||||
.group_by(Note.project_id).subquery()
|
||||
)
|
||||
prows = (await session.execute(
|
||||
select(Project, recency.c.last)
|
||||
.outerjoin(recency, Project.id == recency.c.project_id)
|
||||
.where(Project.user_id == user_id, Project.status == "active",
|
||||
Project.deleted_at.is_(None))
|
||||
.order_by(func.coalesce(recency.c.last, Project.updated_at).desc())
|
||||
.limit(N_PROJECTS)
|
||||
)).all()
|
||||
|
||||
out = []
|
||||
for project, last in prows:
|
||||
counts = (await session.execute(
|
||||
select(
|
||||
func.count(Note.id).filter(Note.status.in_(_OPEN)),
|
||||
func.count(Note.id).filter(Note.status == "done"),
|
||||
func.count(Note.id).filter(Note.status.in_(_OPEN + ["done"])),
|
||||
).where(Note.user_id == user_id, Note.project_id == project.id,
|
||||
Note.deleted_at.is_(None))
|
||||
)).one()
|
||||
open_count, done_count, resolved_total = counts
|
||||
progress_pct = round(done_count / resolved_total * 100, 1) if resolved_total else 0.0
|
||||
|
||||
mrows = (await session.execute(
|
||||
select(Milestone).where(
|
||||
Milestone.user_id == user_id, Milestone.project_id == project.id,
|
||||
Milestone.status == "active", Milestone.deleted_at.is_(None))
|
||||
.order_by(Milestone.order_index.asc())
|
||||
)).scalars().all()
|
||||
milestones = []
|
||||
for m in mrows:
|
||||
tasks = (await session.execute(
|
||||
select(Note).where(
|
||||
Note.user_id == user_id, Note.milestone_id == m.id,
|
||||
Note.status.in_(_OPEN), Note.deleted_at.is_(None))
|
||||
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
|
||||
)).scalars().all()
|
||||
if not tasks:
|
||||
continue
|
||||
prog = await milestones_svc.get_milestone_progress(m.id)
|
||||
milestones.append({
|
||||
"id": m.id, "title": m.title, "progress_pct": prog["pct"],
|
||||
"open_tasks": [_task_row(t) for t in tasks],
|
||||
})
|
||||
|
||||
no_ms = (await session.execute(
|
||||
select(Note).where(
|
||||
Note.user_id == user_id, Note.project_id == project.id,
|
||||
Note.milestone_id.is_(None), Note.status.in_(_OPEN),
|
||||
Note.deleted_at.is_(None))
|
||||
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
|
||||
)).scalars().all()
|
||||
|
||||
out.append({
|
||||
"id": project.id, "title": project.title, "color": project.color,
|
||||
"last_activity": (last or project.updated_at).isoformat(),
|
||||
"open_count": open_count, "progress_pct": progress_pct,
|
||||
"milestones": milestones,
|
||||
"no_milestone": [_task_row(t) for t in no_ms],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def _recently_completed(user_id: int) -> list[dict]:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Note, Project.title)
|
||||
.outerjoin(Project, Note.project_id == Project.id)
|
||||
.where(Note.user_id == user_id, Note.status == "done",
|
||||
Note.deleted_at.is_(None), Note.completed_at.isnot(None),
|
||||
Note.completed_at >= cutoff)
|
||||
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
|
||||
)).all()
|
||||
return [{"id": n.id, "title": n.title, "project_title": ptitle,
|
||||
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows]
|
||||
|
||||
|
||||
async def _upcoming_events(user_id: int) -> list[dict]:
|
||||
from fabledassistant.services import events as events_svc
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS))
|
||||
out = []
|
||||
for e in rows:
|
||||
d = e if isinstance(e, dict) else e.to_dict()
|
||||
out.append({"id": d["id"], "title": d["title"],
|
||||
"start_dt": d.get("start_dt"), "all_day": d.get("all_day", False)})
|
||||
return out
|
||||
|
||||
|
||||
async def _week_stats(user_id: int) -> dict:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
|
||||
async with async_session() as session:
|
||||
row = (await session.execute(
|
||||
select(
|
||||
func.count(Note.id).filter(Note.status == "done", Note.completed_at >= cutoff),
|
||||
func.count(Note.id).filter(Note.status.in_(_OPEN)),
|
||||
func.count(Note.id).filter(Note.status == "in_progress"),
|
||||
func.count(Note.id).filter(Note.task_kind == "plan", Note.status.in_(_OPEN)),
|
||||
).where(Note.user_id == user_id, Note.deleted_at.is_(None))
|
||||
)).one()
|
||||
return {"completed_this_week": row[0], "open_total": row[1],
|
||||
"in_progress": row[2], "active_plans": row[3]}
|
||||
@@ -153,17 +153,22 @@ async def semantic_search_notes(
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
scored: list[tuple[float, Note]] = []
|
||||
for ne, note in rows:
|
||||
try:
|
||||
sim = _cosine_similarity(query_vec, ne.embedding)
|
||||
except Exception:
|
||||
continue
|
||||
if sim >= threshold:
|
||||
scored.append((sim, note))
|
||||
def _score() -> list[tuple[float, Note]]:
|
||||
out: list[tuple[float, Note]] = []
|
||||
for ne, note in rows:
|
||||
try:
|
||||
sim = _cosine_similarity(query_vec, ne.embedding)
|
||||
except Exception:
|
||||
continue
|
||||
if sim >= threshold:
|
||||
out.append((sim, note))
|
||||
out.sort(key=lambda x: x[0], reverse=True)
|
||||
return out[:limit]
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return scored[:limit]
|
||||
# Offload the O(rows) cosine scoring off the event loop so a large corpus
|
||||
# doesn't stall other requests while ranking. Results are unchanged; the
|
||||
# real scaling fix (ORDER BY / LIMIT in pgvector) is a separate effort.
|
||||
return await asyncio.to_thread(_score)
|
||||
|
||||
|
||||
async def backfill_note_embeddings() -> None:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Scheduler jobs for background maintenance tasks.
|
||||
|
||||
- Reminder notifications: checks every 5 minutes for due event reminders.
|
||||
- Reminder notifications: checks every 5 minutes for due event reminders and
|
||||
delivers them to the in-app notification feed.
|
||||
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
|
||||
- Chat retention cleanup: runs daily, deleting old conversations per user setting.
|
||||
- Recurring-task spawn: every 15 minutes, creates the next occurrence of any
|
||||
recurring task whose spawn time has arrived.
|
||||
|
||||
Uses the same BackgroundScheduler pattern as briefing_scheduler.py.
|
||||
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +16,8 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import and_, or_, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
@@ -30,7 +33,13 @@ _loop: asyncio.AbstractEventLoop | None = None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _fire_reminders() -> None:
|
||||
"""Find events with reminders due in the next 5 minutes and fire push notifications."""
|
||||
"""Fire in-app reminders for events whose reminder time has arrived.
|
||||
|
||||
One-shot events fire once (gated on reminder_sent_at IS NULL). Recurring
|
||||
events fire once PER OCCURRENCE: reminder_sent_at stores the start of the
|
||||
occurrence we last reminded about, so each new occurrence re-arms the
|
||||
reminder instead of the whole series firing only once.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
window_end = now + timedelta(minutes=5)
|
||||
|
||||
@@ -38,36 +47,63 @@ async def _fire_reminders() -> None:
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.reminder_minutes.isnot(None),
|
||||
Event.reminder_sent_at.is_(None),
|
||||
Event.start_dt > now, # event hasn't started yet
|
||||
# reminder fires when now >= start_dt - reminder_minutes
|
||||
# i.e. start_dt <= now + reminder_minutes (approximated by window_end check)
|
||||
Event.deleted_at.is_(None),
|
||||
or_(
|
||||
# Recurring events are evaluated every sweep against their
|
||||
# next occurrence (the base start_dt is long past).
|
||||
Event.recurrence.isnot(None),
|
||||
# One-shot events: classic gate.
|
||||
and_(Event.reminder_sent_at.is_(None), Event.start_dt > now),
|
||||
),
|
||||
)
|
||||
)
|
||||
candidates = list(result.scalars().all())
|
||||
|
||||
to_notify: list[Event] = []
|
||||
# (event_id, occurrence_start) — occurrence_start is also the dedup marker
|
||||
# written to reminder_sent_at, so a given occurrence reminds exactly once.
|
||||
to_notify: list[tuple[int, datetime]] = []
|
||||
for event in candidates:
|
||||
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
|
||||
if reminder_dt <= window_end:
|
||||
to_notify.append(event)
|
||||
if event.recurrence:
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occ = rule.after(now, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d reminder", event.id, exc_info=True)
|
||||
continue
|
||||
if occ is None:
|
||||
continue
|
||||
reminder_dt = occ - timedelta(minutes=event.reminder_minutes)
|
||||
if reminder_dt <= window_end and event.reminder_sent_at != occ:
|
||||
to_notify.append((event.id, occ))
|
||||
else:
|
||||
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
|
||||
if reminder_dt <= window_end:
|
||||
to_notify.append((event.id, event.start_dt))
|
||||
|
||||
if not to_notify:
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
for event in to_notify:
|
||||
# Push delivery removed alongside the chat subsystem in Phase 8.
|
||||
# Event reminders are still flagged via in-app notifications
|
||||
# (see services/notifications.py).
|
||||
# Deliver via the in-app notification feed (push was removed in Phase 8).
|
||||
from fabledassistant.services.notifications import create_in_app_notification
|
||||
|
||||
# Mark as sent regardless of push success to avoid re-firing
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event.id)
|
||||
)
|
||||
ev = result.scalar_one_or_none()
|
||||
if ev:
|
||||
ev.reminder_sent_at = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
for event_id, occurrence_start in to_notify:
|
||||
ev = (await session.execute(
|
||||
select(Event).where(Event.id == event_id)
|
||||
)).scalar_one_or_none()
|
||||
# Skip if this exact occurrence was already reminded (covers a
|
||||
# concurrent sweep and the one-shot already-sent case).
|
||||
if ev is None or ev.reminder_sent_at == occurrence_start:
|
||||
continue
|
||||
await create_in_app_notification(ev.user_id, "event_reminder", {
|
||||
"event_id": ev.id,
|
||||
"title": ev.title,
|
||||
"start_dt": occurrence_start.isoformat(),
|
||||
"url": "/calendar",
|
||||
})
|
||||
# Stamp the occurrence marker only after the notification is
|
||||
# created, so a delivery failure leaves it eligible to retry.
|
||||
ev.reminder_sent_at = occurrence_start
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -91,6 +127,22 @@ def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recurring-task spawn job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_recurrence_spawn() -> None:
|
||||
from fabledassistant.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
|
||||
try:
|
||||
await spawn_recurring_tasks()
|
||||
except Exception:
|
||||
logger.warning("Recurring-task spawn job failed", exc_info=True)
|
||||
|
||||
|
||||
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,8 +172,22 @@ def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Spawn the next occurrence of due recurring tasks every 15 minutes.
|
||||
# Without this job, recurrence_next_spawn_at is armed on completion but
|
||||
# never drained, so recurring tasks never recur.
|
||||
_scheduler.add_job(
|
||||
_run_recurrence_spawn_threadsafe,
|
||||
trigger=IntervalTrigger(minutes=15),
|
||||
args=[loop],
|
||||
id="recurrence_spawn",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)")
|
||||
logger.info(
|
||||
"Event scheduler started (reminders every 5m, CalDAV sync every 1h, "
|
||||
"recurring-task spawn every 15m)"
|
||||
)
|
||||
|
||||
|
||||
def stop_event_scheduler() -> None:
|
||||
|
||||
@@ -68,6 +68,19 @@ def _normalize_duration(
|
||||
return None
|
||||
|
||||
|
||||
async def _localize_naive(user_id: int, dt: datetime | None) -> datetime | None:
|
||||
"""Anchor a naive datetime in the user's timezone; pass tz-aware through.
|
||||
|
||||
Naive datetimes are the user's local wall-clock time (the MCP create/update
|
||||
tools combine date+time without a zone). Attaching the user's tzinfo lets
|
||||
asyncpg store the correct UTC instant, matching the REST/UI path.
|
||||
"""
|
||||
if dt is not None and dt.tzinfo is None:
|
||||
from fabledassistant.services.tz import get_user_tz # noqa: PLC0415
|
||||
return dt.replace(tzinfo=await get_user_tz(user_id))
|
||||
return dt
|
||||
|
||||
|
||||
async def create_event(
|
||||
user_id: int,
|
||||
title: str,
|
||||
@@ -97,6 +110,13 @@ async def create_event(
|
||||
"""
|
||||
if duration is not None and duration_minutes is None:
|
||||
duration_minutes = duration
|
||||
# Canonical localization point: a naive datetime (e.g. from the MCP tool's
|
||||
# date+time split) is the user's wall-clock time, so anchor it in their
|
||||
# timezone before storage. tz-aware inputs (REST, CalDAV pass-through) are
|
||||
# left untouched. Without this, MCP-created events landed at the same
|
||||
# wall-clock numerals in UTC and drifted from UI-created ones by the offset.
|
||||
start_dt = await _localize_naive(user_id, start_dt)
|
||||
end_dt = await _localize_naive(user_id, end_dt)
|
||||
duration_minutes = _normalize_duration(
|
||||
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
|
||||
)
|
||||
@@ -261,13 +281,21 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
||||
select(Event).where(
|
||||
Event.id == event_id, Event.user_id == user_id,
|
||||
Event.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
if event is None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
|
||||
# Localize a naive start_dt patch to the user's timezone (same canonical
|
||||
# rule as create_event) before it's used or persisted.
|
||||
if fields.get("start_dt") is not None:
|
||||
fields["start_dt"] = await _localize_naive(user_id, fields["start_dt"])
|
||||
|
||||
# Resolve any end_dt/duration_minutes inputs against the
|
||||
# post-update start_dt. If neither is in the patch, leave the
|
||||
# existing duration_minutes alone.
|
||||
@@ -303,6 +331,11 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
for key, value in fields.items():
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
# Re-arm the reminder when the timing changes, so an event moved to a
|
||||
# new (future) time — or given a new lead time — fires again instead of
|
||||
# being permanently suppressed by a stale reminder_sent_at.
|
||||
if "start_dt" in fields or "reminder_minutes" in fields:
|
||||
event.reminder_sent_at = None
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
|
||||
@@ -422,6 +455,9 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
# Propagate the (possibly cleared) RRULE so a local recurrence edit
|
||||
# isn't overwritten by the stale remote rule on the next pull.
|
||||
recurrence=event.recurrence,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
|
||||
|
||||
@@ -140,6 +140,14 @@ async def _semantic_knowledge_search(
|
||||
Exact keyword matches always rank above semantic-only matches so that
|
||||
searching for a name like "Weston" surfaces the note with that title
|
||||
before conceptually related notes.
|
||||
|
||||
BEST-EFFORT TOP-N, not exhaustive pagination: the ranked candidate set is
|
||||
capped (keyword limit*2 + up to ~200 semantic), so `total` is the size of
|
||||
that window, NOT the true match count, and matches beyond the cap are not
|
||||
reachable by paging. Each page also recomputes the full merge (O(corpus)
|
||||
per page). Acceptable for an interactive "best results" feed; a cached
|
||||
ranked-id list or pgvector ORDER BY/LIMIT is the fix if exhaustive,
|
||||
cheap pagination is ever needed.
|
||||
"""
|
||||
# 1. Keyword search — title and body ILIKE
|
||||
keyword_notes: list[Note] = []
|
||||
@@ -229,7 +237,7 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None))
|
||||
.where(Note.deleted_at.is_(None))
|
||||
.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
.where(Note.note_type.in_(["note", "person", "place", "list", "process"]))
|
||||
.group_by(Note.note_type)
|
||||
)
|
||||
if tags:
|
||||
@@ -265,9 +273,9 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
|
||||
plan_stmt = plan_stmt.where(Note.tags.contains([tag]))
|
||||
counts["plan"] = (await session.execute(plan_stmt)).scalar_one()
|
||||
|
||||
for t in ("note", "person", "place", "list", "task", "plan"):
|
||||
for t in ("note", "person", "place", "list", "task", "plan", "process"):
|
||||
counts.setdefault(t, 0)
|
||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
|
||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task", "process"))
|
||||
return counts
|
||||
|
||||
|
||||
|
||||
@@ -59,26 +59,6 @@ async def log_usage(
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def log_generation(
|
||||
user_id: int,
|
||||
conv_id: int,
|
||||
model: str,
|
||||
timing: dict,
|
||||
) -> None:
|
||||
"""Persist per-generation timing breakdown to app_logs for benchmarking."""
|
||||
async with async_session() as session:
|
||||
log = AppLog(
|
||||
category="generation",
|
||||
user_id=user_id,
|
||||
action="generation",
|
||||
endpoint=f"/chat/conversations/{conv_id}",
|
||||
duration_ms=timing.get("total_ms"),
|
||||
details=json.dumps({"model": model, "conv_id": conv_id, **timing}),
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def log_error(
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
|
||||
@@ -53,6 +53,7 @@ async def get_milestone_in_project(project_id: int, milestone_id: int) -> Milest
|
||||
select(Milestone).where(
|
||||
Milestone.id == milestone_id,
|
||||
Milestone.project_id == project_id,
|
||||
Milestone.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
@@ -108,7 +109,10 @@ async def list_milestones(
|
||||
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
|
||||
select(Milestone).where(
|
||||
Milestone.id == milestone_id, Milestone.user_id == user_id,
|
||||
Milestone.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
milestone = result.scalars().first()
|
||||
if milestone is None:
|
||||
@@ -151,8 +155,13 @@ async def get_milestone_progress(milestone_id: int) -> dict:
|
||||
status_counts[status] = count
|
||||
|
||||
total = sum(status_counts.values())
|
||||
cancelled = status_counts.get("cancelled", 0)
|
||||
completed = status_counts.get("done", 0)
|
||||
pct = round(completed / total * 100, 1) if total > 0 else 0.0
|
||||
# Cancelled tasks are resolved work, not pending — exclude them from the
|
||||
# percent-complete denominator so a milestone whose only open task was
|
||||
# cancelled still reaches 100% (and auto-collapses) instead of stalling.
|
||||
active_total = total - cancelled
|
||||
pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
@@ -162,6 +171,7 @@ async def get_milestone_progress(milestone_id: int) -> dict:
|
||||
"todo": status_counts.get("todo", 0),
|
||||
"in_progress": status_counts.get("in_progress", 0),
|
||||
"done": status_counts.get("done", 0),
|
||||
"cancelled": cancelled,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,20 @@ async def create_note(
|
||||
entity_meta: dict | None = None,
|
||||
task_kind: str = "work",
|
||||
) -> Note:
|
||||
# Validate status/priority here so the MCP create_task path (which passes
|
||||
# them straight through) can't persist an out-of-enum value that the REST
|
||||
# route would have rejected — there's no DB CHECK on notes.status.
|
||||
if isinstance(status, str):
|
||||
try:
|
||||
status = TaskStatus(status).value
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid status: {status!r}. Must be one of: {[s.value for s in TaskStatus]}")
|
||||
if isinstance(priority, str):
|
||||
try:
|
||||
priority = TaskPriority(priority).value
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}")
|
||||
|
||||
# Auto-populate project_id from milestone when not explicitly provided
|
||||
if milestone_id is not None and project_id is None:
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
@@ -384,6 +398,10 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note:
|
||||
note.status = None
|
||||
note.priority = None
|
||||
note.due_date = None
|
||||
# A plain note is not a task and must not recur — clear the rule and
|
||||
# any armed spawn timestamp so the recurrence sweep never picks it up.
|
||||
note.recurrence_rule = None
|
||||
note.recurrence_next_spawn_at = None
|
||||
note.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(note)
|
||||
@@ -391,35 +409,37 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note:
|
||||
return note
|
||||
|
||||
|
||||
async def search_notes_for_context(
|
||||
user_id: int,
|
||||
keywords: list[str],
|
||||
exclude_ids: set[int] | None = None,
|
||||
limit: int = 3,
|
||||
project_id: int | None = None,
|
||||
orphan_only: bool = False,
|
||||
) -> list[Note]:
|
||||
"""Search notes by keywords with OR logic. Optimized for context building — no count query."""
|
||||
async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[dict]]:
|
||||
"""Resolve a stored process by id or name.
|
||||
|
||||
Owner-scoped, note_type='process', non-trashed. Precedence: numeric id →
|
||||
exact case-insensitive title → substring. Returns (note, other_candidates);
|
||||
on a substring tie with no exact hit, `note` is the most-recently-updated
|
||||
match and `other_candidates` lists the rest as [{id, title}] so the caller
|
||||
can disambiguate. Returns (None, []) when nothing matches.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
keyword_filters = []
|
||||
for kw in keywords:
|
||||
escaped = kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped}%"
|
||||
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
|
||||
|
||||
query = select(Note).where(
|
||||
Note.user_id == user_id, or_(*keyword_filters), Note.deleted_at.is_(None)
|
||||
base = select(Note).where(
|
||||
Note.user_id == user_id,
|
||||
Note.note_type == "process",
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
if orphan_only:
|
||||
query = query.where(Note.project_id.is_(None))
|
||||
elif project_id is not None:
|
||||
query = query.where(Note.project_id == project_id)
|
||||
if exclude_ids:
|
||||
query = query.where(Note.id.notin_(exclude_ids))
|
||||
query = query.order_by(Note.updated_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
s = str(name_or_id).strip()
|
||||
if s.isdigit():
|
||||
row = (await session.execute(base.where(Note.id == int(s)))).scalars().first()
|
||||
if row is not None:
|
||||
return row, []
|
||||
exact = (await session.execute(
|
||||
base.where(func.lower(Note.title) == s.lower()).order_by(Note.updated_at.desc())
|
||||
)).scalars().first()
|
||||
if exact is not None:
|
||||
return exact, []
|
||||
matches = (await session.execute(
|
||||
base.where(Note.title.ilike(f"%{s}%")).order_by(Note.updated_at.desc())
|
||||
)).scalars().all()
|
||||
if not matches:
|
||||
return None, []
|
||||
return matches[0], [{"id": n.id, "title": n.title} for n in matches[1:]]
|
||||
|
||||
|
||||
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
|
||||
|
||||
@@ -236,6 +236,28 @@ async def check_due_tasks() -> None:
|
||||
logger.exception("Failed to send task reminder for user %d", user_id)
|
||||
|
||||
|
||||
# Read notifications are kept this long before the hourly sweep deletes them;
|
||||
# unread are kept regardless. Without a sweep the table grows without bound.
|
||||
_NOTIFICATION_RETENTION_DAYS = 30
|
||||
|
||||
|
||||
async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int:
|
||||
"""Delete already-read in-app notifications older than retention_days."""
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import delete
|
||||
from fabledassistant.models.notification import Notification
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
delete(Notification).where(
|
||||
Notification.read_at.isnot(None),
|
||||
Notification.read_at < cutoff,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def _notification_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(3600) # hourly
|
||||
@@ -243,6 +265,12 @@ async def _notification_loop() -> None:
|
||||
await check_due_tasks()
|
||||
except Exception:
|
||||
logger.exception("Error in notification loop")
|
||||
try:
|
||||
removed = await purge_old_read_notifications()
|
||||
if removed:
|
||||
logger.info("Notification retention: deleted %d read notification(s)", removed)
|
||||
except Exception:
|
||||
logger.exception("Error in notification retention cleanup")
|
||||
|
||||
|
||||
def start_notification_loop() -> None:
|
||||
@@ -266,12 +294,6 @@ async def create_in_app_notification(user_id: int, notif_type: str, payload: dic
|
||||
return n
|
||||
|
||||
|
||||
async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None:
|
||||
# Push delivery was removed alongside the chat subsystem (Phase 8).
|
||||
# In-app notifications still flow through the bell-icon feed.
|
||||
return None
|
||||
|
||||
|
||||
async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
|
||||
try:
|
||||
if not await is_smtp_configured():
|
||||
@@ -325,7 +347,6 @@ async def notify_project_shared(
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(uid, "Project shared with you", msg, url))
|
||||
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a project with you", msg))
|
||||
|
||||
|
||||
@@ -361,7 +382,6 @@ async def notify_note_shared(
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(uid, "Note shared with you", msg, url))
|
||||
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a note with you", msg))
|
||||
|
||||
|
||||
@@ -385,7 +405,6 @@ async def notify_group_added(
|
||||
"invited_by": inviter.username,
|
||||
"url": url,
|
||||
})
|
||||
asyncio.create_task(_fire_push_notif(target_user_id, "Added to a group", msg, url))
|
||||
asyncio.create_task(_fire_share_email(target_user_id, "[Fabled] You've been added to a group", msg))
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,25 @@ from sqlalchemy import func, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.project import Project, ProjectStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_status(status: str) -> str:
|
||||
"""Coerce/validate a project status against ProjectStatus.
|
||||
|
||||
Canonical gate so the MCP create/update_project path (which passes status
|
||||
straight through) can't persist an out-of-enum value — there's no DB CHECK.
|
||||
"""
|
||||
try:
|
||||
return ProjectStatus(status).value
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid status: {status!r}. Must be one of: {[s.value for s in ProjectStatus]}"
|
||||
)
|
||||
|
||||
|
||||
async def create_project(
|
||||
user_id: int,
|
||||
title: str,
|
||||
@@ -19,6 +33,7 @@ async def create_project(
|
||||
color: str | None = None,
|
||||
status: str = "active",
|
||||
) -> Project:
|
||||
status = _validate_status(status)
|
||||
async with async_session() as session:
|
||||
project = Project(
|
||||
user_id=user_id,
|
||||
@@ -79,11 +94,16 @@ async def list_projects(user_id: int, status: str | None = None) -> list[Project
|
||||
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
||||
select(Project).where(
|
||||
Project.id == project_id, Project.user_id == user_id,
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
if project is None:
|
||||
return None
|
||||
if "status" in fields and fields["status"] is not None:
|
||||
fields["status"] = _validate_status(fields["status"])
|
||||
for key, value in fields.items():
|
||||
if hasattr(project, key):
|
||||
setattr(project, key, value)
|
||||
|
||||
@@ -115,6 +115,9 @@ async def spawn_recurring_tasks() -> int:
|
||||
and_(
|
||||
Note.recurrence_rule.isnot(None),
|
||||
Note.recurrence_next_spawn_at <= now,
|
||||
# Never spawn children off a trashed parent — that would
|
||||
# resurrect work the user explicitly deleted.
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -498,6 +498,7 @@ async def subscribe_project(
|
||||
from fabledassistant.models.rulebook import project_rulebook_subscriptions
|
||||
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
await _assert_rulebook_owned(session, rulebook_id, user_id)
|
||||
# ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
|
||||
try:
|
||||
@@ -517,6 +518,7 @@ async def unsubscribe_project(
|
||||
from fabledassistant.models.rulebook import project_rulebook_subscriptions
|
||||
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
await _assert_rulebook_owned(session, rulebook_id, user_id)
|
||||
await session.execute(
|
||||
sql_delete(project_rulebook_subscriptions).where(
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import or_, select, update
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
@@ -19,19 +19,50 @@ from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
from fabledassistant.models.rulebook import Rulebook, RulebookTopic, Rule
|
||||
|
||||
# entity_type -> (Model, owner_column_name or None). Used by the existence check.
|
||||
_OWNER = {
|
||||
"note": (Note, "user_id"),
|
||||
"task": (Note, "user_id"),
|
||||
"event": (Event, "user_id"),
|
||||
"project": (Project, "user_id"),
|
||||
"milestone": (Milestone, "user_id"),
|
||||
"rulebook": (Rulebook, "owner_user_id"),
|
||||
"topic": (RulebookTopic, None),
|
||||
"rule": (Rule, None),
|
||||
# entity_type -> Model. Used to resolve which table a trash op targets.
|
||||
_MODEL_FOR = {
|
||||
"note": Note,
|
||||
"task": Note,
|
||||
"event": Event,
|
||||
"project": Project,
|
||||
"milestone": Milestone,
|
||||
"rulebook": Rulebook,
|
||||
"topic": RulebookTopic,
|
||||
"rule": Rule,
|
||||
}
|
||||
|
||||
|
||||
def _owner_clause(model, user_id: int):
|
||||
"""Boolean expr scoping `model` rows to the ones `user_id` owns.
|
||||
|
||||
EVERY trash query (exists-check, restore, purge, list, retention sweep)
|
||||
must carry this — a batch_id is a bearer token, so without an owner
|
||||
predicate a leaked/guessed id lets one tenant read, restore, or
|
||||
permanently destroy another's content. Topics and rules carry no
|
||||
user_id of their own; ownership is derived through the parent rulebook
|
||||
(or, for project-scoped rules, the owning project).
|
||||
"""
|
||||
if model is Rulebook:
|
||||
return Rulebook.owner_user_id == user_id
|
||||
if model is RulebookTopic:
|
||||
return RulebookTopic.rulebook_id.in_(
|
||||
select(Rulebook.id).where(Rulebook.owner_user_id == user_id)
|
||||
)
|
||||
if model is Rule:
|
||||
return or_(
|
||||
Rule.topic_id.in_(
|
||||
select(RulebookTopic.id)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(Rulebook.owner_user_id == user_id)
|
||||
),
|
||||
Rule.project_id.in_(
|
||||
select(Project.id).where(Project.user_id == user_id)
|
||||
),
|
||||
)
|
||||
# Note, Event, Project, Milestone all carry user_id directly.
|
||||
return model.user_id == user_id
|
||||
|
||||
|
||||
async def _set(session, model, where, batch, now) -> None:
|
||||
"""Stamp deleted_at + batch on live rows matching `where`."""
|
||||
await session.execute(
|
||||
@@ -42,10 +73,8 @@ async def _set(session, model, where, batch, now) -> None:
|
||||
|
||||
|
||||
async def _exists_alive(session, user_id: int, etype: str, eid: int) -> bool:
|
||||
model, owner = _OWNER[etype]
|
||||
where = [model.id == eid, model.deleted_at.is_(None)]
|
||||
if owner:
|
||||
where.append(getattr(model, owner) == user_id)
|
||||
model = _MODEL_FOR[etype]
|
||||
where = [model.id == eid, model.deleted_at.is_(None), _owner_clause(model, user_id)]
|
||||
return (await session.execute(select(model.id).where(*where))).first() is not None
|
||||
|
||||
|
||||
@@ -77,8 +106,21 @@ async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now)
|
||||
await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now)
|
||||
await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.id == eid], batch, now)
|
||||
elif etype in ("note", "task"):
|
||||
await _set(session, Note, [Note.user_id == user_id, Note.parent_id == eid], batch, now) # sub-tasks
|
||||
await _set(session, Note, [Note.user_id == user_id, Note.id == eid], batch, now)
|
||||
# Stamp the entire sub-task subtree (not just direct children) so a
|
||||
# deeply nested task and all its descendants trash/restore as one batch.
|
||||
ids = [eid]
|
||||
frontier = [eid]
|
||||
while frontier:
|
||||
children = (await session.execute(
|
||||
select(Note.id).where(
|
||||
Note.user_id == user_id,
|
||||
Note.parent_id.in_(frontier),
|
||||
Note.deleted_at.is_(None),
|
||||
)
|
||||
)).scalars().all()
|
||||
frontier = [c for c in children if c not in ids]
|
||||
ids.extend(frontier)
|
||||
await _set(session, Note, [Note.user_id == user_id, Note.id.in_(ids)], batch, now)
|
||||
elif etype == "event":
|
||||
await _set(session, Event, [Event.user_id == user_id, Event.id == eid], batch, now)
|
||||
elif etype == "rulebook":
|
||||
@@ -107,11 +149,28 @@ async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None:
|
||||
"""
|
||||
batch = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
caldav_event: tuple[str, str] | None = None
|
||||
async with async_session() as session:
|
||||
if not await _exists_alive(session, user_id, entity_type, entity_id):
|
||||
return None
|
||||
# Capture CalDAV linkage before soft-deleting so we can propagate the
|
||||
# deletion to the external server (the row stays present locally).
|
||||
if entity_type == "event":
|
||||
row = (await session.execute(
|
||||
select(Event.caldav_uid, Event.title).where(
|
||||
Event.id == entity_id, Event.user_id == user_id
|
||||
)
|
||||
)).first()
|
||||
if row and row[0]:
|
||||
caldav_event = (row[0], row[1])
|
||||
await _cascade(session, user_id, entity_type, entity_id, batch, now)
|
||||
await session.commit()
|
||||
# Without this the soft-delete only hides the event locally and the remote
|
||||
# copy lingers forever (and re-appears on any client syncing that server).
|
||||
if caldav_event:
|
||||
import asyncio
|
||||
from fabledassistant.services.events import _push_delete
|
||||
asyncio.create_task(_push_delete(caldav_event[0], caldav_event[1], user_id))
|
||||
return batch
|
||||
|
||||
|
||||
@@ -135,7 +194,7 @@ async def restore(user_id: int, batch_id: str) -> int:
|
||||
for model in _ALL:
|
||||
res = await session.execute(
|
||||
update(model)
|
||||
.where(model.deleted_batch_id == batch_id)
|
||||
.where(model.deleted_batch_id == batch_id, _owner_clause(model, user_id))
|
||||
.values(deleted_at=None, deleted_batch_id=None)
|
||||
)
|
||||
n += res.rowcount or 0
|
||||
@@ -150,7 +209,9 @@ async def purge(user_id: int, batch_id: str) -> int:
|
||||
async with async_session() as session:
|
||||
for model in _ALL:
|
||||
res = await session.execute(
|
||||
sql_delete(model).where(model.deleted_batch_id == batch_id)
|
||||
sql_delete(model).where(
|
||||
model.deleted_batch_id == batch_id, _owner_clause(model, user_id)
|
||||
)
|
||||
)
|
||||
n += res.rowcount or 0
|
||||
await session.commit()
|
||||
@@ -163,7 +224,9 @@ async def list_trash(user_id: int) -> list[dict]:
|
||||
async with async_session() as session:
|
||||
for model in _ALL:
|
||||
rows = (await session.execute(
|
||||
select(model).where(model.deleted_at.isnot(None))
|
||||
select(model).where(
|
||||
model.deleted_at.isnot(None), _owner_clause(model, user_id)
|
||||
)
|
||||
)).scalars().all()
|
||||
for r in rows:
|
||||
grp = batches.setdefault(
|
||||
@@ -185,9 +248,12 @@ async def list_trash(user_id: int) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
async def purge_expired(retention_days: int) -> int:
|
||||
"""Cron entry: hard-delete rows trashed more than retention_days ago.
|
||||
async def purge_expired(user_id: int, retention_days: int) -> int:
|
||||
"""Cron entry: hard-delete THIS user's rows trashed more than retention_days ago.
|
||||
|
||||
Scoped to one owner so the scheduler can apply each user's own
|
||||
`trash_retention_days` window — a single global sweep would let one
|
||||
user's short window prematurely destroy another's data.
|
||||
retention_days <= 0 disables auto-purge (returns 0 without touching anything).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
@@ -200,7 +266,9 @@ async def purge_expired(retention_days: int) -> int:
|
||||
for model in _ALL:
|
||||
res = await session.execute(
|
||||
sql_delete(model).where(
|
||||
model.deleted_at.isnot(None), model.deleted_at < cutoff
|
||||
model.deleted_at.isnot(None),
|
||||
model.deleted_at < cutoff,
|
||||
_owner_clause(model, user_id),
|
||||
)
|
||||
)
|
||||
n += res.rowcount or 0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Daily APScheduler cron that purges expired trash.
|
||||
|
||||
Mirrors version_pinning_scheduler.py: a single global BackgroundScheduler job
|
||||
at 03:30 UTC bridges into the asyncio loop to run the async purge. Reads the
|
||||
operator's `trash_retention_days` setting (single-tenant: user 1); 0 disables
|
||||
auto-purge.
|
||||
at 03:30 UTC bridges into the asyncio loop to run the async purge. Iterates
|
||||
every user and applies that user's own `trash_retention_days` setting; 0
|
||||
disables auto-purge for that user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -30,12 +30,22 @@ def _run_purge_threadsafe() -> None:
|
||||
|
||||
async def _runner():
|
||||
try:
|
||||
raw = await get_setting(1, "trash_retention_days", "90")
|
||||
try:
|
||||
days = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
days = 90
|
||||
purged = await trash_svc.purge_expired(days)
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
async with async_session() as session:
|
||||
user_ids = (await session.execute(select(User.id))).scalars().all()
|
||||
|
||||
purged = 0
|
||||
for uid in user_ids:
|
||||
raw = await get_setting(uid, "trash_retention_days", "90")
|
||||
try:
|
||||
days = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
days = 90
|
||||
purged += await trash_svc.purge_expired(uid, days)
|
||||
if purged:
|
||||
logger.info("trash purge: removed %d expired row(s)", purged)
|
||||
else:
|
||||
|
||||
+44
-1
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
|
||||
from fabledassistant.mcp.auth import resolve_bearer, resolve_bearer_to_user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -49,3 +49,46 @@ async def test_resolve_bearer_calls_lookup_with_stripped_token():
|
||||
with patch("fabledassistant.mcp.auth.lookup_key", mock_lookup):
|
||||
await resolve_bearer_to_user_id("Bearer fmcp_abc123 ")
|
||||
mock_lookup.assert_awaited_once_with("fmcp_abc123")
|
||||
|
||||
|
||||
# ── resolve_bearer (user_id + scope) ────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_bearer_returns_user_id_and_scope():
|
||||
fake_key = MagicMock()
|
||||
fake_key.user_id = 9
|
||||
fake_key.scope = "read"
|
||||
with patch("fabledassistant.mcp.auth.lookup_key", AsyncMock(return_value=fake_key)):
|
||||
assert await resolve_bearer("Bearer fmcp_x") == (9, "read")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_bearer_none_for_invalid():
|
||||
with patch("fabledassistant.mcp.auth.lookup_key", AsyncMock(return_value=None)):
|
||||
assert await resolve_bearer("Bearer nope") is None
|
||||
assert await resolve_bearer(None) is None
|
||||
|
||||
|
||||
# ── read-only scope gate ────────────────────────────────────────────────
|
||||
|
||||
def test_body_calls_write_tool_classifies_correctly():
|
||||
import json
|
||||
from fabledassistant.mcp.server import _body_calls_write_tool
|
||||
|
||||
def call(name):
|
||||
return json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": {"name": name, "arguments": {}}}).encode()
|
||||
|
||||
# Write-class tools are gated.
|
||||
assert _body_calls_write_tool(call("create_note")) is True
|
||||
assert _body_calls_write_tool(call("delete_project")) is True
|
||||
assert _body_calls_write_tool(call("purge_trash")) is True
|
||||
# An unknown/new tool defaults to write (default-deny for read keys).
|
||||
assert _body_calls_write_tool(call("brand_new_tool")) is True
|
||||
# Read tools and non-call methods pass.
|
||||
assert _body_calls_write_tool(call("list_notes")) is False
|
||||
assert _body_calls_write_tool(call("get_recent")) is False
|
||||
assert _body_calls_write_tool(
|
||||
json.dumps({"method": "tools/list"}).encode()
|
||||
) is False
|
||||
assert _body_calls_write_tool(b"not json") is False
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for MCP process tools — patches the service layer."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_user():
|
||||
token = _user_id_ctx.set(7)
|
||||
yield
|
||||
_user_id_ctx.reset(token)
|
||||
|
||||
|
||||
def _fake_note(id=1, title="Drift Audit", note_type="process"):
|
||||
n = MagicMock()
|
||||
n.id = id
|
||||
n.title = title
|
||||
n.note_type = note_type
|
||||
n.to_dict.return_value = {"id": id, "title": title, "note_type": note_type}
|
||||
return n
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_process_requires_title_and_body():
|
||||
from fabledassistant.mcp.tools.processes import create_process
|
||||
with pytest.raises(ValueError):
|
||||
await create_process(title="", body="something")
|
||||
with pytest.raises(ValueError):
|
||||
await create_process(title="X", body=" ")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_process_sets_note_type():
|
||||
created = _fake_note()
|
||||
with patch("fabledassistant.services.notes.create_note",
|
||||
AsyncMock(return_value=created)) as mock_create:
|
||||
from fabledassistant.mcp.tools.processes import create_process
|
||||
out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"])
|
||||
assert out["note_type"] == "process"
|
||||
# the service was asked to create a process
|
||||
assert mock_create.await_args.kwargs["note_type"] == "process"
|
||||
assert mock_create.await_args.kwargs["title"] == "Drift Audit"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_process_returns_body_and_candidates():
|
||||
note = _fake_note(id=7)
|
||||
with patch("fabledassistant.services.notes.resolve_process",
|
||||
AsyncMock(return_value=(note, [{"id": 9, "title": "Drift Audit Notes"}]))):
|
||||
from fabledassistant.mcp.tools.processes import get_process
|
||||
out = await get_process("drift")
|
||||
assert out["id"] == 7
|
||||
assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_process_not_found_raises():
|
||||
with patch("fabledassistant.services.notes.resolve_process",
|
||||
AsyncMock(return_value=(None, []))):
|
||||
from fabledassistant.mcp.tools.processes import get_process
|
||||
with pytest.raises(ValueError):
|
||||
await get_process("missing")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_process_rejects_non_process_note():
|
||||
plain = _fake_note(id=3, note_type="note")
|
||||
with patch("fabledassistant.services.notes.get_note",
|
||||
AsyncMock(return_value=plain)):
|
||||
from fabledassistant.mcp.tools.processes import update_process
|
||||
with pytest.raises(ValueError):
|
||||
await update_process(process_id=3, title="x")
|
||||
|
||||
|
||||
def test_register_attaches_four_tools():
|
||||
from fabledassistant.mcp.tools import processes
|
||||
names: list[str] = []
|
||||
|
||||
class FakeMcp:
|
||||
def tool(self, name):
|
||||
names.append(name)
|
||||
def deco(fn):
|
||||
return fn
|
||||
return deco
|
||||
|
||||
processes.register(FakeMcp())
|
||||
assert set(names) == {
|
||||
"list_processes", "create_process", "get_process", "update_process",
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Structural tests for the dashboard blueprint."""
|
||||
|
||||
|
||||
def test_dashboard_blueprint_registered():
|
||||
from fabledassistant.routes.dashboard import dashboard_bp
|
||||
assert dashboard_bp.name == "dashboard"
|
||||
assert dashboard_bp.url_prefix == "/api/dashboard"
|
||||
|
||||
|
||||
def test_dashboard_blueprint_registered_in_app():
|
||||
from fabledassistant.app import create_app
|
||||
app = create_app()
|
||||
assert "dashboard" in app.blueprints
|
||||
|
||||
|
||||
def test_dashboard_handler_callable():
|
||||
from fabledassistant.routes import dashboard as dashboard_routes
|
||||
assert callable(dashboard_routes.get_dashboard)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""build_dashboard composition + helpers (services/dashboard.py).
|
||||
|
||||
Query semantics (ranking/caps/owner-scope) are exercised by manual smoke —
|
||||
the repo's unit tests mock the DB, so SQL isn't executed here. These cover the
|
||||
pure helpers and the section-isolation contract.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_task_row_maps_fields():
|
||||
from fabledassistant.services.dashboard import _task_row
|
||||
n = MagicMock()
|
||||
n.id = 5
|
||||
n.title = "Wire reminders"
|
||||
n.status = "in_progress"
|
||||
n.priority = None
|
||||
assert _task_row(n) == {
|
||||
"id": 5, "title": "Wire reminders", "status": "in_progress", "priority": "none",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_returns_value_then_empty_on_error():
|
||||
from fabledassistant.services.dashboard import _safe
|
||||
|
||||
async def ok():
|
||||
return [1, 2, 3]
|
||||
|
||||
async def boom():
|
||||
raise RuntimeError("section blew up")
|
||||
|
||||
assert await _safe(ok(), []) == [1, 2, 3]
|
||||
# a failing section returns the supplied empty default, not an exception
|
||||
assert await _safe(boom(), []) == []
|
||||
assert await _safe(boom(), {}) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_dashboard_composes_sections():
|
||||
import fabledassistant.services.dashboard as dash
|
||||
with patch.object(dash, "_active_projects", AsyncMock(return_value=["P"])), \
|
||||
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
|
||||
patch.object(dash, "_upcoming_events", AsyncMock(return_value=["evt"])), \
|
||||
patch.object(dash, "_week_stats", AsyncMock(return_value={"open_total": 4})):
|
||||
out = await dash.build_dashboard(user_id=1)
|
||||
assert out == {
|
||||
"active_projects": ["P"],
|
||||
"recently_completed": ["done"],
|
||||
"upcoming_events": ["evt"],
|
||||
"week_stats": {"open_total": 4},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_dashboard_isolates_failing_section():
|
||||
import fabledassistant.services.dashboard as dash
|
||||
with patch.object(dash, "_active_projects", AsyncMock(side_effect=RuntimeError("db down"))), \
|
||||
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
|
||||
patch.object(dash, "_upcoming_events", AsyncMock(return_value=[])), \
|
||||
patch.object(dash, "_week_stats", AsyncMock(return_value={})):
|
||||
out = await dash.build_dashboard(user_id=1)
|
||||
# failing section degrades to its empty default; others still populate
|
||||
assert out["active_projects"] == []
|
||||
assert out["recently_completed"] == ["done"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""get_knowledge_counts includes the 'process' type and counts it in total."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_mock_session():
|
||||
s = AsyncMock()
|
||||
s.__aenter__ = AsyncMock(return_value=s)
|
||||
s.__aexit__ = AsyncMock(return_value=False)
|
||||
return s
|
||||
|
||||
|
||||
def _grouped(rows):
|
||||
r = MagicMock()
|
||||
r.all.return_value = rows
|
||||
return r
|
||||
|
||||
|
||||
def _scalar(n):
|
||||
r = MagicMock()
|
||||
r.scalar_one.return_value = n
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counts_include_process_in_facet_and_total():
|
||||
session = _make_mock_session()
|
||||
# 1) grouped non-task counts, 2) task count, 3) plan count
|
||||
session.execute = AsyncMock(side_effect=[
|
||||
_grouped([("note", 3), ("process", 2)]),
|
||||
_scalar(1), # tasks
|
||||
_scalar(0), # plans
|
||||
])
|
||||
with patch("fabledassistant.services.knowledge.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.knowledge import get_knowledge_counts
|
||||
counts = await get_knowledge_counts(user_id=1)
|
||||
|
||||
assert counts["process"] == 2
|
||||
# facet keys all present (setdefault)
|
||||
for key in ("note", "person", "place", "list", "task", "plan", "process"):
|
||||
assert key in counts
|
||||
# total = note(3) + person(0) + place(0) + list(0) + task(1) + process(2)
|
||||
assert counts["total"] == 6
|
||||
@@ -0,0 +1,87 @@
|
||||
"""resolve_process precedence (id → exact title → substring) in services/notes.py.
|
||||
|
||||
Mocks async_session — no real DB, matching the other notes-service tests.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_mock_session():
|
||||
s = AsyncMock()
|
||||
s.__aenter__ = AsyncMock(return_value=s)
|
||||
s.__aexit__ = AsyncMock(return_value=False)
|
||||
return s
|
||||
|
||||
|
||||
def _result(first=None, all_=None):
|
||||
"""A SQLAlchemy-result mock exposing .scalars().first()/.all()."""
|
||||
r = MagicMock()
|
||||
r.scalars.return_value.first.return_value = first
|
||||
r.scalars.return_value.all.return_value = all_ or []
|
||||
return r
|
||||
|
||||
|
||||
def _note(id, title):
|
||||
n = MagicMock()
|
||||
n.id = id
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_process_by_numeric_id():
|
||||
note = _note(5, "Drift Audit")
|
||||
session = _make_mock_session()
|
||||
# numeric id → first execute (id lookup) hits
|
||||
session.execute = AsyncMock(side_effect=[_result(first=note)])
|
||||
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.notes import resolve_process
|
||||
found, candidates = await resolve_process(1, "5")
|
||||
assert found is note
|
||||
assert candidates == []
|
||||
assert session.execute.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_process_exact_title_beats_substring():
|
||||
note = _note(7, "Drift Audit")
|
||||
session = _make_mock_session()
|
||||
# non-digit → exact-title query (first execute) hits; substring never runs
|
||||
session.execute = AsyncMock(side_effect=[_result(first=note)])
|
||||
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.notes import resolve_process
|
||||
found, candidates = await resolve_process(1, "Drift Audit")
|
||||
assert found is note
|
||||
assert candidates == []
|
||||
assert session.execute.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_process_substring_returns_candidates():
|
||||
n1 = _note(7, "Drift Audit Remediation")
|
||||
n2 = _note(9, "Drift Audit Notes")
|
||||
session = _make_mock_session()
|
||||
# exact miss, then substring returns two (most-recent first)
|
||||
session.execute = AsyncMock(side_effect=[_result(first=None), _result(all_=[n1, n2])])
|
||||
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.notes import resolve_process
|
||||
found, candidates = await resolve_process(1, "drift")
|
||||
assert found is n1
|
||||
assert candidates == [{"id": 9, "title": "Drift Audit Notes"}]
|
||||
assert session.execute.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_process_no_match():
|
||||
session = _make_mock_session()
|
||||
session.execute = AsyncMock(side_effect=[_result(first=None), _result(all_=[])])
|
||||
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.notes import resolve_process
|
||||
found, candidates = await resolve_process(1, "nope")
|
||||
assert found is None
|
||||
assert candidates == []
|
||||
@@ -21,15 +21,18 @@ def _exists_result(found=True):
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note_returns_batch_and_commits():
|
||||
session = _make_mock_session()
|
||||
# 1 execute for _exists_alive, then 2 for the note cascade (sub-tasks + self)
|
||||
session.execute = AsyncMock(side_effect=[_exists_result(True), MagicMock(), MagicMock()])
|
||||
# exists-check, then the subtree descent: one child-lookup (no children
|
||||
# here) + one _set stamping the whole subtree.
|
||||
no_children = MagicMock()
|
||||
no_children.scalars.return_value.all.return_value = []
|
||||
session.execute = AsyncMock(side_effect=[_exists_result(True), no_children, MagicMock()])
|
||||
with patch("fabledassistant.services.trash.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.trash import delete
|
||||
batch = await delete(user_id=1, entity_type="note", entity_id=5)
|
||||
assert isinstance(batch, str) and len(batch) > 0
|
||||
assert session.commit.called
|
||||
# exists-check + 2 cascade updates
|
||||
# exists-check + subtree-descent query + subtree _set
|
||||
assert session.execute.await_count == 3
|
||||
|
||||
|
||||
@@ -112,7 +115,7 @@ async def test_purge_expired_skips_when_retention_zero():
|
||||
with patch("fabledassistant.services.trash.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.trash import purge_expired
|
||||
n = await purge_expired(0)
|
||||
n = await purge_expired(1, 0)
|
||||
assert n == 0
|
||||
assert not session.execute.called # never opens a delete
|
||||
|
||||
@@ -124,11 +127,34 @@ async def test_purge_expired_deletes_across_models_when_positive():
|
||||
with patch("fabledassistant.services.trash.async_session") as cls:
|
||||
cls.return_value = session
|
||||
from fabledassistant.services.trash import purge_expired
|
||||
n = await purge_expired(90)
|
||||
n = await purge_expired(1, 90)
|
||||
assert n == 7
|
||||
assert session.execute.await_count == 7
|
||||
|
||||
|
||||
def test_owner_clause_scopes_every_model():
|
||||
"""Regression: every trash op must owner-scope, including topics/rules
|
||||
which previously had NO owner check (IDOR across tenants)."""
|
||||
from fabledassistant.services.trash import _owner_clause, _MODEL_FOR
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.rulebook import Rulebook, RulebookTopic, Rule
|
||||
|
||||
# Models with a direct owner column.
|
||||
assert "user_id" in str(_owner_clause(Note, 7))
|
||||
assert "owner_user_id" in str(_owner_clause(Rulebook, 7))
|
||||
|
||||
# Topics/rules carry no user_id — ownership is derived through the
|
||||
# parent rulebook (and, for rules, the owning project).
|
||||
topic_sql = str(_owner_clause(RulebookTopic, 7))
|
||||
assert "rulebooks" in topic_sql and "owner_user_id" in topic_sql
|
||||
rule_sql = str(_owner_clause(Rule, 7))
|
||||
assert "owner_user_id" in rule_sql and "projects" in rule_sql
|
||||
|
||||
# Every entity type resolves to a model that yields a non-empty clause.
|
||||
for model in set(_MODEL_FOR.values()):
|
||||
assert _owner_clause(model, 1) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_trash_groups_by_batch():
|
||||
session = _make_mock_session()
|
||||
|
||||
Reference in New Issue
Block a user