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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user