chore(dead-code): fix prod image, drop orphaned code, correct delete_rule doc
Drift-audit Group 7 (renamed/removed lingers) + Group 5 #9: - docker-compose.prod.yml pulled fabledassistant:latest, a tag CI stopped publishing after the rename. Point it at fabledscribe:latest (the name CI and quickstart use). The internal DB name stays fabledassistant by design. - Remove the unused hard-delete delete_note imports from the notes and tasks route modules (they delete via trash; the import was an attractive nuisance that bypassed soft-delete). - delete_rule MCP tool: docstring/warning said 'permanently delete' but the body moves the rule to recoverable trash. Corrected to match. - Delete services/calendar_sync.py: fully orphaned (zero importers) and it read Config attrs that no longer exist, so any re-wiring would crash. - Remove dead services: notes.search_notes_for_context and logging.log_generation (zero callers; log_generation wrote a 'generation' category no stats/UI surface). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ from fabledassistant.services.notes import (
|
||||
convert_note_to_task,
|
||||
convert_task_to_note,
|
||||
create_note,
|
||||
delete_note,
|
||||
get_all_tags,
|
||||
get_backlinks,
|
||||
get_note,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/"
|
||||
@@ -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,
|
||||
|
||||
@@ -409,37 +409,6 @@ 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 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)
|
||||
)
|
||||
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())
|
||||
|
||||
|
||||
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
|
||||
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
|
||||
if not note_ids:
|
||||
|
||||
Reference in New Issue
Block a user