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:
@@ -18,6 +18,7 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, sessionmaker
|
||||
from sqlalchemy.sql import func
|
||||
@@ -58,7 +59,7 @@ class User(Base, TimestampMixin):
|
||||
"""User model for authentication."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username = Column(String(100), unique=True, nullable=False, index=True)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
@@ -72,7 +73,7 @@ class User(Base, TimestampMixin):
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"id": str(self.id),
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
"role": self.role,
|
||||
@@ -86,8 +87,8 @@ class ApiKey(Base, TimestampMixin):
|
||||
"""API Key for authentication."""
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||
key_hash = Column(String(255), nullable=False, unique=True)
|
||||
name = Column(String(100))
|
||||
expires_at = Column(DateTime)
|
||||
@@ -100,14 +101,14 @@ class Tag(Base, TimestampMixin):
|
||||
"""Tag model for bookmarks."""
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||
color = Column(String(7))
|
||||
description = Column(Text)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"id": str(self.id),
|
||||
"name": self.name,
|
||||
"color": self.color,
|
||||
"description": self.description,
|
||||
@@ -120,7 +121,7 @@ class Bookmark(Base, TimestampMixin):
|
||||
"""Bookmark/Link model with Firefox-compatible fields."""
|
||||
__tablename__ = "links"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
url = Column(String(2048), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
@@ -130,8 +131,8 @@ class Bookmark(Base, TimestampMixin):
|
||||
path = Column(String(512), nullable=True)
|
||||
visit_count = Column(Integer, default=0)
|
||||
is_bookmarked = Column(Boolean, default=False)
|
||||
source_set_id = Column(String(36), ForeignKey("links.id"))
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
source_set_id = Column(UUID(as_uuid=True), ForeignKey("links.id"))
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="bookmarks")
|
||||
source_set = relationship("Bookmark", remote_side=[id])
|
||||
@@ -139,7 +140,7 @@ class Bookmark(Base, TimestampMixin):
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"id": str(self.id),
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
@@ -149,8 +150,8 @@ class Bookmark(Base, TimestampMixin):
|
||||
"path": self.path,
|
||||
"visit_count": self.visit_count,
|
||||
"is_bookmarked": self.is_bookmarked,
|
||||
"source_set_id": self.source_set_id,
|
||||
"user_id": self.user_id,
|
||||
"source_set_id": str(self.source_set_id) if self.source_set_id else None,
|
||||
"user_id": str(self.user_id) if self.user_id else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -160,26 +161,26 @@ class Collection(Base, TimestampMixin):
|
||||
"""Collection model for bookmark sets."""
|
||||
__tablename__ = "collections"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text)
|
||||
query_type = Column(String(20), nullable=False)
|
||||
query_expression = Column(JSON)
|
||||
is_public = Column(Boolean, default=False)
|
||||
created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="collections")
|
||||
collection_bookmarks = relationship("CollectionBookmark", back_populates="collection")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"id": str(self.id),
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"query_type": self.query_type,
|
||||
"query_expression": self.query_expression,
|
||||
"is_public": self.is_public,
|
||||
"created_by": self.created_by,
|
||||
"created_by": str(self.created_by),
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -187,10 +188,10 @@ class Collection(Base, TimestampMixin):
|
||||
|
||||
class CollectionBookmark(Base, TimestampMixin):
|
||||
"""Junction table for static collections."""
|
||||
__tablename__ = "collection_bookmarks"
|
||||
__tablename__ = "collection_links"
|
||||
|
||||
collection_id = Column(String(36), ForeignKey("collections.id"), primary_key=True)
|
||||
bookmark_id = Column(String(36), ForeignKey("links.id"), primary_key=True)
|
||||
collection_id = Column(UUID(as_uuid=True), ForeignKey("collections.id"), primary_key=True)
|
||||
bookmark_id = Column(UUID(as_uuid=True), ForeignKey("links.id"), primary_key=True)
|
||||
|
||||
collection = relationship("Collection", back_populates="collection_bookmarks")
|
||||
bookmark = relationship("Bookmark", back_populates="collection_bookmarks")
|
||||
@@ -200,11 +201,11 @@ class AuditLog(Base, TimestampMixin):
|
||||
"""Audit log for tracking changes."""
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
action = Column(String(100), nullable=False)
|
||||
entity_type = Column(String(50), nullable=False)
|
||||
entity_id = Column(String(36))
|
||||
entity_id = Column(UUID(as_uuid=True))
|
||||
old_value = Column(JSON)
|
||||
new_value = Column(JSON)
|
||||
ip_address = Column(String(45))
|
||||
|
||||
Reference in New Issue
Block a user