Files
celery/app/routers/home.py
gilesb 8bf6f87c2a Implement ownership model for all cached content deletion
- cache_service.delete_content: Remove user's ownership link first,
  only delete actual file if no other owners remain

- cache_manager.discard_activity_outputs_only: Check if outputs and
  intermediates are used by other activities before deleting

- run_service.discard_run: Now cleans up run outputs/intermediates
  (only if not shared by other runs)

- home.py clear_user_data: Use ownership model for effects and media
  deletion instead of directly deleting files

The ownership model ensures:
1. Multiple users can "own" the same cached content
2. Deleting removes the user's ownership link (item_types entry)
3. Actual files only deleted when no owners remain (garbage collection)
4. Shared intermediates between runs are preserved

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:02:27 +00:00

263 lines
8.5 KiB
Python

"""
Home and root routes for L1 server.
"""
from pathlib import Path
import markdown
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
from artdag_common import render
from artdag_common.middleware import wants_html
from ..dependencies import get_templates, get_current_user
router = APIRouter()
async def get_user_stats(actor_id: str) -> dict:
"""Get stats for a user."""
import database
from ..services.recipe_service import RecipeService
from ..services.run_service import RunService
from ..dependencies import get_redis_client, get_cache_manager
stats = {}
try:
stats["media"] = await database.count_user_items(actor_id)
except Exception:
stats["media"] = 0
try:
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
recipes = await recipe_service.list_recipes(actor_id)
stats["recipes"] = len(recipes)
except Exception:
stats["recipes"] = 0
try:
run_service = RunService(database, get_redis_client(), get_cache_manager())
runs = await run_service.list_runs(actor_id)
stats["runs"] = len(runs)
except Exception:
stats["runs"] = 0
try:
storage_providers = await database.get_user_storage_providers(actor_id)
stats["storage"] = len(storage_providers) if storage_providers else 0
except Exception:
stats["storage"] = 0
try:
effects_dir = Path(get_cache_manager().cache_dir) / "_effects"
if effects_dir.exists():
stats["effects"] = len([d for d in effects_dir.iterdir() if d.is_dir()])
else:
stats["effects"] = 0
except Exception:
stats["effects"] = 0
return stats
@router.get("/api/stats")
async def api_stats(request: Request):
"""Get user stats as JSON for CLI and API clients."""
user = await get_current_user(request)
if not user:
raise HTTPException(401, "Authentication required")
stats = await get_user_stats(user.actor_id)
return stats
@router.delete("/api/clear-data")
async def clear_user_data(request: Request):
"""
Clear all user L1 data except storage configuration.
Deletes: runs, recipes, effects, media/cache items.
Preserves: storage provider configurations.
"""
import logging
logger = logging.getLogger(__name__)
user = await get_current_user(request)
if not user:
raise HTTPException(401, "Authentication required")
import database
from ..services.recipe_service import RecipeService
from ..services.run_service import RunService
from ..dependencies import get_redis_client, get_cache_manager
actor_id = user.actor_id
username = user.username
deleted = {
"runs": 0,
"recipes": 0,
"effects": 0,
"media": 0,
}
errors = []
# Delete all runs
try:
run_service = RunService(database, get_redis_client(), get_cache_manager())
runs = await run_service.list_runs(actor_id, offset=0, limit=10000)
for run in runs:
try:
await run_service.discard_run(run["run_id"], actor_id, username)
deleted["runs"] += 1
except Exception as e:
errors.append(f"Run {run['run_id']}: {e}")
except Exception as e:
errors.append(f"Failed to list runs: {e}")
# Delete all recipes
try:
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
recipes = await recipe_service.list_recipes(actor_id, offset=0, limit=10000)
for recipe in recipes:
try:
success, error = await recipe_service.delete_recipe(recipe["recipe_id"], actor_id)
if success:
deleted["recipes"] += 1
else:
errors.append(f"Recipe {recipe['recipe_id']}: {error}")
except Exception as e:
errors.append(f"Recipe {recipe['recipe_id']}: {e}")
except Exception as e:
errors.append(f"Failed to list recipes: {e}")
# Delete all effects (uses ownership model)
try:
# Get user's effects from item_types
effect_items = await database.get_user_items(actor_id, item_type="effect", limit=10000)
for item in effect_items:
cid = item.get("cid")
if cid:
try:
# Remove ownership link
await database.delete_item_type(cid, actor_id, "effect")
await database.delete_friendly_name(actor_id, cid)
# Check if orphaned
remaining = await database.get_item_types(cid)
if not remaining:
# Garbage collect
effects_dir = Path(cache_manager.cache_dir) / "_effects" / cid
if effects_dir.exists():
import shutil
shutil.rmtree(effects_dir)
import ipfs_client
ipfs_client.unpin(cid)
deleted["effects"] += 1
except Exception as e:
errors.append(f"Effect {cid[:16]}...: {e}")
except Exception as e:
errors.append(f"Failed to delete effects: {e}")
# Delete all media/cache items for user (uses ownership model)
try:
from ..services.cache_service import CacheService
cache_service = CacheService(database, cache_manager)
# Get user's media items (video, image, audio)
for media_type in ["video", "image", "audio", "unknown"]:
items = await database.get_user_items(actor_id, item_type=media_type, limit=10000)
for item in items:
cid = item.get("cid")
if cid:
try:
success, error = await cache_service.delete_content(cid, actor_id)
if success:
deleted["media"] += 1
elif error:
errors.append(f"Media {cid[:16]}...: {error}")
except Exception as e:
errors.append(f"Media {cid[:16]}...: {e}")
except Exception as e:
errors.append(f"Failed to delete media: {e}")
logger.info(f"Cleared data for {actor_id}: {deleted}")
if errors:
logger.warning(f"Errors during clear: {errors[:10]}") # Log first 10 errors
return {
"message": "User data cleared",
"deleted": deleted,
"errors": errors[:10] if errors else [], # Return first 10 errors
"storage_preserved": True,
}
@router.get("/")
async def home(request: Request):
"""
Home page - show README and stats.
"""
user = await get_current_user(request)
# Load README
readme_html = ""
try:
readme_path = Path(__file__).parent.parent.parent / "README.md"
if readme_path.exists():
readme_html = markdown.markdown(readme_path.read_text(), extensions=['tables', 'fenced_code'])
except Exception:
pass
# Get stats for current user
stats = {}
if user:
stats = await get_user_stats(user.actor_id)
templates = get_templates(request)
return render(templates, "home.html", request,
user=user,
readme_html=readme_html,
stats=stats,
nav_counts=stats, # Reuse stats for nav counts
active_tab="home",
)
@router.get("/login")
async def login_redirect(request: Request):
"""
Redirect to L2 for login.
"""
from ..config import settings
if settings.l2_server:
# Redirect to L2 login with return URL
return_url = str(request.url_for("auth_callback"))
login_url = f"{settings.l2_server}/login?return_to={return_url}"
return RedirectResponse(url=login_url, status_code=302)
# No L2 configured - show error
return HTMLResponse(
"<html><body><h1>Login not configured</h1>"
"<p>No L2 server configured for authentication.</p></body></html>",
status_code=503
)
# Client tarball path
CLIENT_TARBALL = Path(__file__).parent.parent.parent / "artdag-client.tar.gz"
@router.get("/download/client")
async def download_client():
"""Download the Art DAG CLI client."""
if not CLIENT_TARBALL.exists():
raise HTTPException(404, "Client package not found. Run build-client.sh to create it.")
return FileResponse(
CLIENT_TARBALL,
media_type="application/gzip",
filename="artdag-client.tar.gz"
)