"""Shaders router — CRUD, versioning, drafts, fork, search.""" from uuid import UUID from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.database import get_db from app.models import User, Shader, ShaderVersion from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic, ShaderVersionPublic from app.middleware.auth import get_current_user, get_optional_user from app.services.glsl_validator import validate_glsl router = APIRouter() # ── Public list / search ────────────────────────────────── @router.get("", response_model=list[ShaderPublic]) async def list_shaders( q: str | None = Query(None, description="Search query"), tags: list[str] | None = Query(None, description="Filter by tags"), shader_type: str | None = Query(None, description="Filter by type: 2d, 3d, audio-reactive"), sort: str = Query("trending", description="Sort: trending, new, top"), is_system: bool | None = Query(None, description="Filter to system/platform shaders"), limit: int = Query(20, ge=1, le=50), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), ): query = select(Shader).where( Shader.is_public == True, Shader.status == "published", ) if q: query = query.where(Shader.title.ilike(f"%{q}%")) if tags: from sqlalchemy import type_coerce, Text from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY query = query.where(Shader.tags.overlap(type_coerce(tags, PG_ARRAY(Text)))) if shader_type: query = query.where(Shader.shader_type == shader_type) if is_system is not None: query = query.where(Shader.is_system == is_system) if sort == "new": query = query.order_by(Shader.created_at.desc()) elif sort == "top": query = query.order_by(Shader.score.desc()) else: query = query.order_by(Shader.score.desc(), Shader.created_at.desc()) query = query.limit(limit).offset(offset) result = await db.execute(query) return result.scalars().all() # ── My shaders (workspace) ─────────────────────────────── @router.get("/mine", response_model=list[ShaderPublic]) async def my_shaders( status_filter: str | None = Query(None, alias="status", description="draft, published, archived"), limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """List the authenticated user's shaders — drafts, published, archived.""" query = select(Shader).where(Shader.author_id == user.id) if status_filter: query = query.where(Shader.status == status_filter) query = query.order_by(Shader.updated_at.desc()).limit(limit).offset(offset) result = await db.execute(query) return result.scalars().all() # ── Single shader ───────────────────────────────────────── @router.get("/{shader_id}", response_model=ShaderPublic) async def get_shader( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User | None = Depends(get_optional_user), ): result = await db.execute(select(Shader).where(Shader.id == shader_id)) shader = result.scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") # Drafts are only visible to their author if shader.status == "draft" and (not user or user.id != shader.author_id): raise HTTPException(status_code=404, detail="Shader not found") if not shader.is_public and (not user or user.id != shader.author_id): raise HTTPException(status_code=404, detail="Shader not found") shader.view_count += 1 return shader # ── Version history ─────────────────────────────────────── @router.get("/{shader_id}/versions", response_model=list[ShaderVersionPublic]) async def list_versions( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User | None = Depends(get_optional_user), ): """Get the version history of a shader.""" shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") if shader.status == "draft" and (not user or user.id != shader.author_id): raise HTTPException(status_code=404, detail="Shader not found") result = await db.execute( select(ShaderVersion) .where(ShaderVersion.shader_id == shader_id) .order_by(ShaderVersion.version_number.desc()) ) return result.scalars().all() @router.get("/{shader_id}/versions/{version_number}", response_model=ShaderVersionPublic) async def get_version( shader_id: UUID, version_number: int, db: AsyncSession = Depends(get_db), ): result = await db.execute( select(ShaderVersion).where( ShaderVersion.shader_id == shader_id, ShaderVersion.version_number == version_number, ) ) version = result.scalar_one_or_none() if not version: raise HTTPException(status_code=404, detail="Version not found") return version # ── Create shader (draft or published) ─────────────────── @router.post("", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED) async def create_shader( body: ShaderCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): # Rate limit published shaders for free tier (drafts are unlimited) if body.status == "published" and user.subscription_tier == "free": month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) count_result = await db.execute( select(func.count()).select_from(Shader).where( Shader.author_id == user.id, Shader.status == "published", Shader.created_at >= month_start, ) ) monthly_count = count_result.scalar() if monthly_count >= 5: raise HTTPException(status_code=429, detail="Free tier: 5 published shaders/month. Upgrade to Pro for unlimited.") # Validate GLSL validation = validate_glsl(body.glsl_code, body.shader_type) if not validation.valid: raise HTTPException(status_code=422, detail={ "message": "GLSL validation failed", "errors": validation.errors, "warnings": validation.warnings, }) shader = Shader( author_id=user.id, title=body.title, description=body.description, glsl_code=body.glsl_code, tags=body.tags, shader_type=body.shader_type, is_public=body.is_public if body.status == "published" else False, status=body.status, style_metadata=body.style_metadata, render_status="ready" if body.status == "draft" else "pending", current_version=1, ) db.add(shader) await db.flush() # Create version 1 snapshot v1 = ShaderVersion( shader_id=shader.id, version_number=1, glsl_code=body.glsl_code, title=body.title, description=body.description, tags=body.tags, style_metadata=body.style_metadata, change_note="Initial version", ) db.add(v1) # Enqueue render for published shaders if body.status == "published": from app.worker import celery_app try: celery_app.send_task("render_shader", args=[str(shader.id)]) except Exception: shader.render_status = "ready" # Link to desire if fulfilling if body.fulfills_desire_id: from app.models import Desire desire = (await db.execute( select(Desire).where(Desire.id == body.fulfills_desire_id, Desire.status == "open") )).scalar_one_or_none() if desire: desire.status = "fulfilled" desire.fulfilled_by_shader = shader.id desire.fulfilled_at = datetime.now(timezone.utc) return shader # ── Update shader (creates new version) ────────────────── @router.put("/{shader_id}", response_model=ShaderPublic) async def update_shader( shader_id: UUID, body: ShaderUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): result = await db.execute(select(Shader).where(Shader.id == shader_id)) shader = result.scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") if shader.author_id != user.id and user.role != "admin": raise HTTPException(status_code=403, detail="Not the shader owner") updates = body.model_dump(exclude_unset=True) change_note = updates.pop("change_note", None) code_changed = "glsl_code" in updates # Re-validate GLSL if code changed if code_changed: validation = validate_glsl(updates["glsl_code"], shader.shader_type) if not validation.valid: raise HTTPException(status_code=422, detail={ "message": "GLSL validation failed", "errors": validation.errors, "warnings": validation.warnings, }) # Apply updates for field, value in updates.items(): setattr(shader, field, value) # Create a new version snapshot if code or metadata changed if code_changed or "title" in updates or "description" in updates or "tags" in updates: shader.current_version += 1 new_version = ShaderVersion( shader_id=shader.id, version_number=shader.current_version, glsl_code=shader.glsl_code, title=shader.title, description=shader.description, tags=shader.tags, style_metadata=shader.style_metadata, change_note=change_note, ) db.add(new_version) # Re-render if code changed and shader is published if code_changed and shader.status == "published": shader.render_status = "pending" from app.worker import celery_app try: celery_app.send_task("render_shader", args=[str(shader.id)]) except Exception: shader.render_status = "ready" # If publishing a draft, ensure it's public and queue render if "status" in updates and updates["status"] == "published" and shader.render_status != "ready": shader.is_public = True shader.render_status = "pending" from app.worker import celery_app try: celery_app.send_task("render_shader", args=[str(shader.id)]) except Exception: shader.render_status = "ready" shader.updated_at = datetime.now(timezone.utc) return shader # ── Delete ──────────────────────────────────────────────── @router.delete("/{shader_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_shader( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): result = await db.execute(select(Shader).where(Shader.id == shader_id)) shader = result.scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") if shader.author_id != user.id and user.role != "admin": raise HTTPException(status_code=403, detail="Not the shader owner") await db.delete(shader) # ── Fork ────────────────────────────────────────────────── @router.post("/{shader_id}/fork", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED) async def fork_shader( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): result = await db.execute(select(Shader).where(Shader.id == shader_id)) original = result.scalar_one_or_none() if not original: raise HTTPException(status_code=404, detail="Shader not found") if not original.is_public and original.status != "published": raise HTTPException(status_code=404, detail="Shader not found") forked = Shader( author_id=user.id, title=f"Fork of {original.title}", description=f"Forked from {original.title}", glsl_code=original.glsl_code, tags=original.tags, shader_type=original.shader_type, forked_from=original.id, style_metadata=original.style_metadata, status="draft", # Forks start as drafts is_public=False, render_status="ready", current_version=1, ) db.add(forked) await db.flush() v1 = ShaderVersion( shader_id=forked.id, version_number=1, glsl_code=original.glsl_code, title=forked.title, description=forked.description, tags=original.tags, style_metadata=original.style_metadata, change_note=f"Forked from {original.title}", ) db.add(v1) return forked # ── Restore a version ──────────────────────────────────── @router.post("/{shader_id}/versions/{version_number}/restore", response_model=ShaderPublic) async def restore_version( shader_id: UUID, version_number: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Restore a shader to a previous version (creates a new version snapshot).""" shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") if shader.author_id != user.id and user.role != "admin": raise HTTPException(status_code=403, detail="Not the shader owner") version = (await db.execute( select(ShaderVersion).where( ShaderVersion.shader_id == shader_id, ShaderVersion.version_number == version_number, ) )).scalar_one_or_none() if not version: raise HTTPException(status_code=404, detail="Version not found") # Apply version data to shader shader.glsl_code = version.glsl_code shader.title = version.title shader.description = version.description shader.tags = version.tags shader.style_metadata = version.style_metadata shader.current_version += 1 shader.updated_at = datetime.now(timezone.utc) # Create a new version snapshot for the restore restore_v = ShaderVersion( shader_id=shader.id, version_number=shader.current_version, glsl_code=version.glsl_code, title=version.title, description=version.description, tags=version.tags, style_metadata=version.style_metadata, change_note=f"Restored from version {version_number}", ) db.add(restore_v) # Re-render if published if shader.status == "published": shader.render_status = "pending" from app.worker import celery_app try: celery_app.send_task("render_shader", args=[str(shader.id)]) except Exception: shader.render_status = "ready" return shader