""" LinkSyncServer - Collection CRUD Endpoints """ import logging import uuid from typing import List, Optional from fastapi import APIRouter, HTTPException, Query, Request, status from pydantic import BaseModel, Field from sqlalchemy import and_, or_ from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, User, get_session from queries.executor import execute_query router = APIRouter(prefix="/api/collections", tags=["Collections"]) logger = logging.getLogger(__name__) class CollectionCreate(BaseModel): name: str = Field(..., description="Collection name") description: Optional[str] = Field(None, max_length=1024) query_type: str = Field(default="static", description="static or dynamic") query_expression: Optional[dict] = Field(None, description="Query expression for dynamic collections") is_public: bool = Field(default=False) link_ids: Optional[List[str]] = Field(default_factory=list, description="Link IDs for static collections") class CollectionUpdate(BaseModel): name: Optional[str] = Field(None, max_length=200) description: Optional[str] = Field(None, max_length=1024) query_type: Optional[str] = None query_expression: Optional[dict] = None is_public: Optional[bool] = None def get_current_user_id(request: Request) -> Optional[str]: auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] try: import jwt from config.settings import settings payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) return payload.get("sub") except jwt.ExpiredSignatureError: logger.warning("Token expired") except jwt.InvalidTokenError as e: logger.warning(f"Invalid token: {e}") except Exception as e: logger.warning(f"Token decode error: {e}") return None def parse_uuid(id_str: str): try: return uuid.UUID(id_str) if isinstance(id_str, str) else id_str except (ValueError, AttributeError): return None def log_audit(db, action, entity_type, entity_id, user_id, old_value=None, new_value=None): try: audit = AuditLog( action=action, entity_type=entity_type, entity_id=entity_id, old_value=old_value, new_value=new_value, user_id=user_id, ) db.add(audit) db.commit() except Exception: db.rollback() @router.get("/", response_model=List[dict]) async def list_collections( limit: int = Query(20, le=100, ge=1), offset: int = Query(0, ge=0), request: Request = None, ): db = get_session() try: username = get_current_user_id(request) if request else None query = db.query(Collection) if username: user = db.query(User).filter(User.username == username).first() if user: query = query.filter( or_(Collection.created_by == user.id, Collection.is_public == True) ) else: query = query.filter(Collection.is_public == True) else: query = query.filter(Collection.is_public == True) collections = query.order_by(Collection.created_at.desc()).offset(offset).limit(limit).all() return [c.to_dict() for c in collections] finally: db.close() @router.get("/public-tree", response_model=List[dict]) async def public_collections_tree(): db = get_session() try: collections = db.query(Collection).filter(Collection.is_public == True).order_by(Collection.name).all() result = [] for col in collections: col_data = col.to_dict() links = [] if col.query_type == "static": cbs = db.query(CollectionBookmark).filter(CollectionBookmark.collection_id == col.id).all() for cb in cbs: bm = db.query(Bookmark).filter(Bookmark.id == cb.link_id).first() if bm: links.append(bm.to_dict()) elif col.query_type == "dynamic" and col.query_expression: from queries.executor import execute_query from queries.parser import QueryParser try: parser = QueryParser() parsed = parser.parse(col.query_expression.get("expression", "")) if parsed: all_bookmarks = db.query(Bookmark).all() matched = execute_query(parsed, [b.to_dict() for b in all_bookmarks]) links = matched except Exception: pass col_data["links"] = links result.append(col_data) return result finally: db.close() @router.get("/{collection_id}", response_model=dict) async def get_collection(collection_id: str): db = get_session() try: collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") result = collection.to_dict() if collection.query_type == "static": links = ( db.query(CollectionBookmark) .filter(CollectionBookmark.collection_id == parse_uuid(collection_id)) .all() ) result["link_ids"] = [lb.link_id for lb in links] return result finally: db.close() @router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED) async def create_collection(data: CollectionCreate, request: Request): db = get_session() try: username = get_current_user_id(request) if not username: raise HTTPException(status_code=401, detail="Authentication required") user = db.query(User).filter(User.username == username).first() if not user: raise HTTPException(status_code=404, detail="User not found") created_by_id = user.id collection = Collection( id=uuid.uuid4(), name=data.name, description=data.description, query_type=data.query_type, query_expression=data.query_expression, is_public=data.is_public, created_by=created_by_id, ) db.add(collection) db.flush() if data.query_type == "static" and data.link_ids: for link_id in data.link_ids: try: lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id except (ValueError, AttributeError): continue cb = CollectionBookmark(collection_id=collection.id, link_id=lid) db.add(cb) db.commit() db.refresh(collection) log_audit(db, "create", "Collection", collection.id, created_by_id, new_value=collection.to_dict()) return collection.to_dict() finally: db.close() @router.put("/{collection_id}", response_model=dict) async def update_collection(collection_id: str, data: CollectionUpdate, request: Request): db = get_session() try: collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") old_value = collection.to_dict() update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(collection, field, value) db.commit() db.refresh(collection) username = get_current_user_id(request) user = db.query(User).filter(User.username == username).first() if username else None log_audit(db, "update", "Collection", collection.id, user.id if user else None, old_value=old_value, new_value=collection.to_dict()) return collection.to_dict() finally: db.close() @router.delete("/{collection_id}", response_model=dict) async def delete_collection(collection_id: str, request: Request): db = get_session() try: collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") old_value = collection.to_dict() if collection.query_type == "static": db.query(CollectionBookmark).filter( CollectionBookmark.collection_id == collection.id ).delete() db.delete(collection) db.commit() username = get_current_user_id(request) user = db.query(User).filter(User.username == username).first() if username else None log_audit(db, "delete", "Collection", collection.id, user.id if user else None, old_value=old_value) return {"message": "Collection deleted successfully", "deleted_id": str(collection.id)} finally: db.close() @router.post("/{collection_id}/refresh", response_model=dict) async def refresh_collection(collection_id: str): db = get_session() try: collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.query_type != "dynamic": raise HTTPException(status_code=400, detail="Only dynamic collections can be refreshed") if collection.query_expression: bookmarks = execute_query(collection.query_expression) else: bookmarks = [] return { "collection_id": collection_id, "matched_count": len(bookmarks), "bookmarks": bookmarks, } finally: db.close() @router.post("/{collection_id}/add-links", response_model=dict) async def add_links_to_collection(collection_id: str, link_ids: List[str]): db = get_session() try: parsed_cid = parse_uuid(collection_id) if parsed_cid is None: raise HTTPException(status_code=404, detail="Collection not found") collection = db.query(Collection).filter(Collection.id == parsed_cid).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.query_type != "static": raise HTTPException(status_code=400, detail="Can only add links to static collections") existing = { cb.link_id for cb in db.query(CollectionBookmark) .filter(CollectionBookmark.collection_id == parsed_cid) .all() } added = 0 for link_id in link_ids: try: lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id except (ValueError, AttributeError): continue if lid not in existing: db.add(CollectionBookmark(collection_id=parsed_cid, link_id=lid)) added += 1 db.commit() return {"message": f"Added {added} links", "added_count": added} finally: db.close() @router.delete("/{collection_id}/remove-links", response_model=dict) async def remove_links_from_collection(collection_id: str, link_ids: List[str]): db = get_session() try: parsed_cid = parse_uuid(collection_id) if parsed_cid is None: raise HTTPException(status_code=404, detail="Collection not found") collection = db.query(Collection).filter(Collection.id == parsed_cid).first() if not collection: raise HTTPException(status_code=404, detail="Collection not found") parsed_link_ids = [] for link_id in link_ids: try: parsed_link_ids.append(uuid.UUID(link_id) if isinstance(link_id, str) else link_id) except (ValueError, AttributeError): continue removed = ( db.query(CollectionBookmark) .filter( CollectionBookmark.collection_id == parsed_cid, CollectionBookmark.link_id.in_(parsed_link_ids), ) .delete(synchronize_session=False) ) db.commit() return {"message": f"Removed {removed} links", "removed_count": removed} finally: db.close()