feat: add web UI with login, CRUD, admin, and API key management

- Add login page with JWT authentication
- Add dashboard with stats and quick actions
- Add links management page (full CRUD with search)
- Add collections management page
- Add API key management page with copy-to-clipboard
- Add admin user management page (admin only)
- Fix UUID type mismatches across all endpoints
- Add updated_at column to api_keys and audit_log in schema.sql
- Fix DB_PASSWORD default in docker-compose.yml
- Add PyJWT to requirements.txt
- Fix API docs URL (/docs instead of /api/docs)
- Improve JS error handling (show actual messages)
- Rewrite conftest.py with proper DB lifecycle management
- Add 42 new integration tests (84 total, all passing)
  - test_admin.py: 15 tests for admin endpoints
  - test_auth_extended.py: 9 tests for API key CRUD
  - test_tags.py: 12 tests for tag endpoints
  - test_sync.py: 6 tests for sync endpoints
This commit is contained in:
DavidSaylor
2026-05-21 07:21:49 -05:00
parent 09d30427f4
commit 77b076c7d7
31 changed files with 2740 additions and 213 deletions

View File

@@ -5,7 +5,7 @@ LinkSyncServer - Admin Endpoints
import uuid
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, EmailStr, Field
from api.endpoints.auth import hash_password, require_admin
@@ -34,11 +34,18 @@ class SettingsUpdate(BaseModel):
cors_origins: Optional[str] = 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
@router.get("/users", response_model=List[dict])
async def list_users(
limit: int = Query(20, le=100, ge=1),
offset: int = Query(0, ge=0),
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
@@ -51,7 +58,7 @@ async def list_users(
@router.post("/users", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_user(
data: UserCreate,
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
@@ -62,7 +69,7 @@ async def create_user(
raise HTTPException(status_code=400, detail="Username or email already exists")
user = User(
id=str(uuid.uuid4()),
id=uuid.uuid4(),
username=data.username,
email=data.email,
password_hash=hash_password(data.password),
@@ -80,11 +87,14 @@ async def create_user(
@router.get("/users/{user_id}", response_model=dict)
async def get_user(
user_id: str,
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
parsed_id = parse_uuid(user_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="User not found")
user = db.query(User).filter(User.id == parsed_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user.to_dict()
@@ -96,11 +106,14 @@ async def get_user(
async def update_user(
user_id: str,
data: UserUpdate,
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
parsed_id = parse_uuid(user_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="User not found")
user = db.query(User).filter(User.id == parsed_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -121,11 +134,14 @@ async def update_user(
@router.delete("/users/{user_id}", response_model=dict)
async def delete_user(
user_id: str,
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
user = db.query(User).filter(User.id == user_id).first()
parsed_id = parse_uuid(user_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="User not found")
user = db.query(User).filter(User.id == parsed_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.username == current_admin.get("username"):
@@ -139,7 +155,7 @@ async def delete_user(
@router.get("/stats", response_model=dict)
async def get_system_stats(current_admin: dict = require_admin):
async def get_system_stats(current_admin: dict = Depends(require_admin)):
db = get_session()
try:
return {
@@ -159,7 +175,7 @@ async def get_audit_log(
offset: int = Query(0, ge=0),
entity_type: Optional[str] = Query(None),
action: Optional[str] = Query(None),
current_admin: dict = require_admin,
current_admin: dict = Depends(require_admin),
):
db = get_session()
try:
@@ -171,14 +187,14 @@ async def get_audit_log(
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
return [
{
"id": log.id,
"user_id": log.user_id,
"id": str(log.id),
"user_id": str(log.user_id) if log.user_id else None,
"action": log.action,
"entity_type": log.entity_type,
"entity_id": log.entity_id,
"entity_id": str(log.entity_id) if log.entity_id else None,
"old_value": log.old_value,
"new_value": log.new_value,
"ip_address": log.ip_address,
"ip_address": str(log.ip_address) if log.ip_address else None,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs

View File

@@ -5,8 +5,9 @@ LinkSyncServer - Authentication Endpoints
import hashlib
import os
import secrets
import uuid
from datetime import datetime, timedelta
from typing import Optional
from typing import List, Optional
import bcrypt
import jwt
@@ -178,6 +179,32 @@ async def logout():
return {"message": "Logged out successfully"}
@router.get("/api-keys", response_model=List[dict])
async def list_api_keys(
current_user: dict = Depends(get_current_user),
):
db = get_session()
try:
user = db.query(User).filter(User.username == current_user["username"]).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return [
{
"id": str(key.id),
"key_id": str(key.id),
"name": key.name,
"is_active": key.is_active,
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
"created_at": key.created_at.isoformat() if key.created_at else None,
}
for key in api_keys
]
finally:
db.close()
@router.post("/api-key", response_model=ApiKeyResponse)
async def create_api_key(
name: str = "default",
@@ -204,7 +231,7 @@ async def create_api_key(
return {
"api_key": raw_key,
"key_id": api_key.id,
"key_id": str(api_key.id),
"name": api_key.name,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
}
@@ -223,14 +250,19 @@ async def get_api_key_info(
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
parsed_key_id = uuid.UUID(key_id)
except (ValueError, AttributeError):
raise HTTPException(status_code=404, detail="API key not found")
api_key = db.query(ApiKey).filter(
ApiKey.id == key_id, ApiKey.user_id == user.id
ApiKey.id == parsed_key_id, ApiKey.user_id == user.id
).first()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
return {
"key_id": api_key.id,
"key_id": str(api_key.id),
"name": api_key.name,
"is_active": api_key.is_active,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
@@ -251,8 +283,13 @@ async def delete_api_key(
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
parsed_key_id = uuid.UUID(key_id)
except (ValueError, AttributeError):
raise HTTPException(status_code=404, detail="API key not found")
api_key = db.query(ApiKey).filter(
ApiKey.id == key_id, ApiKey.user_id == user.id
ApiKey.id == parsed_key_id, ApiKey.user_id == user.id
).first()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")

View File

@@ -10,7 +10,7 @@ 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, get_session
from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, User, get_session
from queries.executor import execute_query
router = APIRouter(prefix="/api/collections", tags=["Collections"])
@@ -49,6 +49,13 @@ def get_current_user_id(request: Request) -> Optional[str]:
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(
@@ -73,12 +80,16 @@ async def list_collections(
):
db = get_session()
try:
user_id = get_current_user_id(request) if request else None
username = get_current_user_id(request) if request else None
query = db.query(Collection)
if user_id:
query = query.filter(
or_(Collection.created_by == user_id, Collection.is_public == True)
)
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()
@@ -91,14 +102,14 @@ async def list_collections(
async def get_collection(collection_id: str):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
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 == collection_id)
.filter(CollectionBookmark.collection_id == parse_uuid(collection_id))
.all()
)
result["link_ids"] = [lb.bookmark_id for lb in links]
@@ -111,30 +122,40 @@ async def get_collection(collection_id: str):
async def create_collection(data: CollectionCreate, request: Request):
db = get_session()
try:
user_id = get_current_user_id(request)
if not user_id:
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=str(uuid.uuid4()),
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=user_id,
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:
cb = CollectionBookmark(collection_id=collection.id, bookmark_id=link_id)
try:
lid = uuid.UUID(link_id) if isinstance(link_id, str) else link_id
except (ValueError, AttributeError):
continue
cb = CollectionBookmark(collection_id=collection.id, bookmark_id=lid)
db.add(cb)
db.commit()
db.refresh(collection)
log_audit(db, "create", "Collection", collection.id, user_id, new_value=collection.to_dict())
log_audit(db, "create", "Collection", collection.id, created_by_id, new_value=collection.to_dict())
return collection.to_dict()
finally:
db.close()
@@ -144,7 +165,7 @@ async def create_collection(data: CollectionCreate, request: Request):
async def update_collection(collection_id: str, data: CollectionUpdate, request: Request):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
collection = db.query(Collection).filter(Collection.id == parse_uuid(collection_id)).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
@@ -155,8 +176,9 @@ async def update_collection(collection_id: str, data: CollectionUpdate, request:
db.commit()
db.refresh(collection)
user_id = get_current_user_id(request)
log_audit(db, "update", "Collection", collection_id, user_id, old_value=old_value, new_value=collection.to_dict())
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()
@@ -166,20 +188,21 @@ async def update_collection(collection_id: str, data: CollectionUpdate, request:
async def delete_collection(collection_id: str, request: Request):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
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
CollectionBookmark.collection_id == collection.id
).delete()
db.delete(collection)
db.commit()
user_id = get_current_user_id(request)
log_audit(db, "delete", "Collection", collection_id, user_id, old_value=old_value)
return {"message": "Collection deleted successfully", "deleted_id": collection_id}
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()
@@ -188,7 +211,7 @@ async def delete_collection(collection_id: str, request: Request):
async def refresh_collection(collection_id: str):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
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":
@@ -212,7 +235,10 @@ async def refresh_collection(collection_id: str):
async def add_links_to_collection(collection_id: str, link_ids: List[str]):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
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":
@@ -221,13 +247,17 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]):
existing = {
cb.bookmark_id
for cb in db.query(CollectionBookmark)
.filter(CollectionBookmark.collection_id == collection_id)
.filter(CollectionBookmark.collection_id == parsed_cid)
.all()
}
added = 0
for link_id in link_ids:
if link_id not in existing:
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=link_id))
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, bookmark_id=lid))
added += 1
db.commit()
@@ -240,15 +270,25 @@ async def add_links_to_collection(collection_id: str, link_ids: List[str]):
async def remove_links_from_collection(collection_id: str, link_ids: List[str]):
db = get_session()
try:
collection = db.query(Collection).filter(Collection.id == collection_id).first()
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 == collection_id,
CollectionBookmark.bookmark_id.in_(link_ids),
CollectionBookmark.collection_id == parsed_cid,
CollectionBookmark.bookmark_id.in_(parsed_link_ids),
)
.delete(synchronize_session=False)
)

View File

@@ -55,6 +55,13 @@ def get_current_user_id(request: Request) -> Optional[str]:
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: str, entity_type: str, entity_id: str, user_id: Optional[str], old_value=None, new_value=None):
try:
audit = AuditLog(
@@ -105,7 +112,10 @@ async def list_bookmarks(
async def get_bookmark(bookmark_id: str):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark.to_dict()
@@ -117,9 +127,14 @@ async def get_bookmark(bookmark_id: str):
async def create_bookmark(data: BookmarkCreate, request: Request):
db = get_session()
try:
user_id = get_current_user_id(request)
user_id = None
username = get_current_user_id(request)
if username:
user = db.query(User).filter(User.username == username).first()
if user:
user_id = user.id
bookmark = Bookmark(
id=str(uuid.uuid4()),
id=uuid.uuid4(),
url=data.url,
title=data.title,
description=data.description,
@@ -144,7 +159,10 @@ async def create_bookmark(data: BookmarkCreate, request: Request):
async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Request):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
@@ -155,8 +173,9 @@ async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Reque
db.commit()
db.refresh(bookmark)
user_id = get_current_user_id(request)
log_audit(db, "update", "Bookmark", bookmark_id, user_id, old_value=old_value, new_value=bookmark.to_dict())
username = get_current_user_id(request)
user = db.query(User).filter(User.username == username).first() if username else None
log_audit(db, "update", "Bookmark", bookmark.id, user.id if user else None, old_value=old_value, new_value=bookmark.to_dict())
return bookmark.to_dict()
finally:
db.close()
@@ -166,16 +185,20 @@ async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Reque
async def delete_bookmark(bookmark_id: str, request: Request):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
old_value = bookmark.to_dict()
db.delete(bookmark)
db.commit()
user_id = get_current_user_id(request)
log_audit(db, "delete", "Bookmark", bookmark_id, user_id, old_value=old_value)
return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id}
username = get_current_user_id(request)
user = db.query(User).filter(User.username == username).first() if username else None
log_audit(db, "delete", "Bookmark", bookmark.id, user.id if user else None, old_value=old_value)
return {"message": "Bookmark deleted successfully", "deleted_id": str(bookmark.id)}
finally:
db.close()
@@ -188,7 +211,10 @@ class TagList(BaseModel):
async def add_tags(bookmark_id: str, data: TagList, request: Request):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
@@ -211,7 +237,10 @@ async def add_tags(bookmark_id: str, data: TagList, request: Request):
async def remove_tags(bookmark_id: str, data: TagList, request: Request):
db = get_session()
try:
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
parsed_id = parse_uuid(bookmark_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Bookmark not found")
bookmark = db.query(Bookmark).filter(Bookmark.id == parsed_id).first()
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")

View File

@@ -36,11 +36,18 @@ class SyncResponse(BaseModel):
synced_count: int
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 apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]) -> SyncResponse:
db = get_session()
try:
actions = []
server_bookmarks = {b.id: b for b in db.query(Bookmark).all()}
server_bookmarks = {str(b.id): b for b in db.query(Bookmark).all()}
for bm in browser_bookmarks:
existing = server_bookmarks.get(bm.id)
@@ -58,8 +65,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
existing.is_bookmarked = bm.is_bookmarked
actions.append({"type": "update", "link_id": bm.id})
else:
try:
bm_uuid = uuid.UUID(bm.id)
except (ValueError, AttributeError):
bm_uuid = uuid.uuid4()
new_bm = Bookmark(
id=bm.id,
id=bm_uuid,
url=bm.url,
title=bm.title,
description=bm.description,
@@ -86,8 +97,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
existing.is_bookmarked = bm.is_bookmarked
actions.append({"type": "update", "link_id": bm.id})
else:
try:
bm_uuid = uuid.UUID(bm.id)
except (ValueError, AttributeError):
bm_uuid = uuid.uuid4()
new_bm = Bookmark(
id=bm.id,
id=bm_uuid,
url=bm.url,
title=bm.title,
description=bm.description,
@@ -103,8 +118,12 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
elif sync_config.mode == "server-authoritative":
if not existing:
try:
bm_uuid = uuid.UUID(bm.id)
except (ValueError, AttributeError):
bm_uuid = uuid.uuid4()
new_bm = Bookmark(
id=bm.id,
id=bm_uuid,
url=bm.url,
title=bm.title,
description=bm.description,
@@ -122,8 +141,10 @@ def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData
browser_ids = {bm.id for bm in browser_bookmarks}
for server_id in server_bookmarks:
if server_id not in browser_ids:
db.query(Bookmark).filter(Bookmark.id == server_id).delete()
actions.append({"type": "delete", "link_id": server_id})
parsed_id = parse_uuid(server_id)
if parsed_id:
db.query(Bookmark).filter(Bookmark.id == parsed_id).delete()
actions.append({"type": "delete", "link_id": server_id})
db.commit()
return SyncResponse(actions=actions, synced_count=len(actions))
@@ -155,7 +176,10 @@ async def get_collection(collection_id: str):
db = get_session()
try:
from models.base import Collection
collection = db.query(Collection).filter(Collection.id == collection_id).first()
parsed_id = parse_uuid(collection_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Collection not found")
collection = db.query(Collection).filter(Collection.id == parsed_id).first()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
return collection.to_dict()
@@ -168,7 +192,10 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
db = get_session()
try:
from models.base import Collection, CollectionBookmark
collection = db.query(Collection).filter(Collection.id == collection_id).first()
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":
@@ -176,16 +203,19 @@ async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
added = 0
for bid in bookmark_ids:
parsed_bid = parse_uuid(bid)
if parsed_bid is None:
continue
existing = (
db.query(CollectionBookmark)
.filter(
CollectionBookmark.collection_id == collection_id,
CollectionBookmark.bookmark_id == bid,
CollectionBookmark.collection_id == parsed_cid,
CollectionBookmark.bookmark_id == parsed_bid,
)
.first()
)
if not existing:
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=bid))
db.add(CollectionBookmark(collection_id=parsed_cid, bookmark_id=parsed_bid))
added += 1
db.commit()
@@ -199,12 +229,15 @@ async def delete_collection(collection_id: str):
db = get_session()
try:
from models.base import Collection, CollectionBookmark
collection = db.query(Collection).filter(Collection.id == collection_id).first()
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")
db.query(CollectionBookmark).filter(
CollectionBookmark.collection_id == collection_id
CollectionBookmark.collection_id == parsed_cid
).delete()
db.delete(collection)
db.commit()

View File

@@ -28,6 +28,13 @@ class TagUpdate(BaseModel):
description: Optional[str] = Field(None, max_length=500)
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
@router.get("/", response_model=List[dict])
async def list_tags(
page: int = Query(1, ge=1),
@@ -58,7 +65,10 @@ async def tag_count():
async def get_tag(tag_id: str):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
parsed_id = parse_uuid(tag_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == parsed_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
return tag.to_dict()
@@ -86,7 +96,10 @@ async def get_tag_links(
):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
parsed_id = parse_uuid(tag_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == parsed_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
@@ -112,7 +125,7 @@ async def create_tag(data: TagCreate):
raise HTTPException(status_code=400, detail="Tag already exists")
tag = Tag(
id=str(uuid.uuid4()),
id=uuid.uuid4(),
name=data.name,
color=data.color,
description=data.description,
@@ -129,7 +142,10 @@ async def create_tag(data: TagCreate):
async def update_tag(tag_id: str, data: TagUpdate):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
parsed_id = parse_uuid(tag_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == parsed_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
@@ -148,7 +164,10 @@ async def update_tag(tag_id: str, data: TagUpdate):
async def delete_tag(tag_id: str):
db = get_session()
try:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
parsed_id = parse_uuid(tag_id)
if parsed_id is None:
raise HTTPException(status_code=404, detail="Tag not found")
tag = db.query(Tag).filter(Tag.id == parsed_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")