feat: Add pending linkages management API and user interface for tag approvals

This commit introduces a new API for managing pending tag linkages, allowing admins to review and approve or deny user-suggested tags. The frontend has been updated with a new User Tagged Photos page for displaying pending linkages, including options for filtering and submitting decisions. Additionally, the Layout component has been modified to include navigation to the new page. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-27 14:40:43 -05:00
parent 999e79f859
commit d5d6dc82b1
10 changed files with 1627 additions and 3 deletions
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
from typing import Generator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from src.web.app import app
from src.web.db import models
from src.web.db.models import Photo, PhotoTagLinkage, Tag, User
from src.web.db.session import get_auth_db, get_db
from src.web.constants.roles import DEFAULT_ADMIN_ROLE
from src.web.api.auth import get_current_user
# Create isolated in-memory databases for main and auth stores.
main_engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
auth_engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
MainSessionLocal = sessionmaker(
bind=main_engine, autoflush=False, autocommit=False, future=True
)
AuthSessionLocal = sessionmaker(
bind=auth_engine, autoflush=False, autocommit=False, future=True
)
models.Base.metadata.create_all(bind=main_engine)
with auth_engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT
)
"""
)
)
connection.execute(
text(
"""
CREATE TABLE pending_linkages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
photo_id INTEGER NOT NULL,
tag_id INTEGER,
tag_name VARCHAR(255),
user_id INTEGER NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
def override_get_db() -> Generator[Session, None, None]:
db = MainSessionLocal()
try:
yield db
finally:
db.close()
def override_get_auth_db() -> Generator[Session, None, None]:
db = AuthSessionLocal()
try:
yield db
finally:
db.close()
def override_get_current_user() -> dict[str, str]:
return {"username": "admin"}
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_auth_db] = override_get_auth_db
app.dependency_overrides[get_current_user] = override_get_current_user
client = TestClient(app)
def _ensure_admin_user() -> None:
with MainSessionLocal() as session:
existing = session.query(User).filter(User.username == "admin").first()
if existing:
existing.is_admin = True
existing.role = DEFAULT_ADMIN_ROLE
session.commit()
return
admin_user = User(
username="admin",
password_hash="test",
email="admin@example.com",
full_name="Admin",
is_active=True,
is_admin=True,
role=DEFAULT_ADMIN_ROLE,
)
session.add(admin_user)
session.commit()
@pytest.fixture(autouse=True)
def clean_databases() -> Generator[None, None, None]:
with MainSessionLocal() as session:
session.query(PhotoTagLinkage).delete()
session.query(Tag).delete()
session.query(Photo).delete()
session.query(User).filter(User.username != "admin").delete()
session.commit()
with AuthSessionLocal() as session:
session.execute(text("DELETE FROM pending_linkages"))
session.execute(text("DELETE FROM users"))
session.commit()
_ensure_admin_user()
yield
def _insert_auth_user(user_id: int = 1) -> None:
with auth_engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO users (id, name, email)
VALUES (:id, :name, :email)
"""
),
{"id": user_id, "name": "Tester", "email": "tester@example.com"},
)
def _insert_pending_linkage(
photo_id: int,
*,
tag_id: int | None = None,
tag_name: str | None = None,
status: str = "pending",
user_id: int = 1,
) -> int:
with auth_engine.begin() as connection:
result = connection.execute(
text(
"""
INSERT INTO pending_linkages (
photo_id, tag_id, tag_name, user_id, status, notes
)
VALUES (:photo_id, :tag_id, :tag_name, :user_id, :status, 'note')
"""
),
{
"photo_id": photo_id,
"tag_id": tag_id,
"tag_name": tag_name,
"user_id": user_id,
"status": status,
},
)
return int(result.lastrowid)
def _create_photo(path: str, filename: str, file_hash: str) -> int:
with MainSessionLocal() as session:
photo = Photo(path=path, filename=filename, file_hash=file_hash)
session.add(photo)
session.commit()
session.refresh(photo)
return photo.id
def test_list_pending_linkages_returns_existing_rows():
_ensure_admin_user()
photo_id = _create_photo("/tmp/photo1.jpg", "photo1.jpg", "hash1")
_insert_auth_user()
linkage_id = _insert_pending_linkage(photo_id, tag_name="Beach Day")
response = client.get("/api/v1/pending-linkages")
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 1
item = payload["items"][0]
assert item["photo_id"] == photo_id
assert item["proposed_tag_name"] == "Beach Day"
assert item["status"] == "pending"
def test_review_pending_linkages_creates_tag_and_linkage():
_ensure_admin_user()
photo_id = _create_photo("/tmp/photo2.jpg", "photo2.jpg", "hash2")
_insert_auth_user()
linkage_id = _insert_pending_linkage(photo_id, tag_name="Sunset Crew")
response = client.post(
"/api/v1/pending-linkages/review",
json={"decisions": [{"id": linkage_id, "decision": "approve"}]},
)
assert response.status_code == 200
payload = response.json()
assert payload["approved"] == 1
assert payload["denied"] == 0
assert payload["tags_created"] == 1
assert payload["linkages_created"] == 1
with MainSessionLocal() as session:
tags = session.query(Tag).all()
assert len(tags) == 1
assert tags[0].tag_name == "Sunset Crew"
linkage = session.query(PhotoTagLinkage).first()
assert linkage is not None
assert linkage.photo_id == photo_id
assert linkage.tag_id == tags[0].id
with AuthSessionLocal() as session:
statuses = session.execute(
text("SELECT status FROM pending_linkages WHERE id = :id"),
{"id": linkage_id},
).fetchone()
assert statuses is not None
assert statuses[0] == "approved"
def test_cleanup_pending_linkages_deletes_approved_and_denied():
_ensure_admin_user()
photo_id = _create_photo("/tmp/photo3.jpg", "photo3.jpg", "hash3")
_insert_auth_user()
approved_id = _insert_pending_linkage(photo_id, tag_name="Approved Tag", status="approved")
denied_id = _insert_pending_linkage(photo_id, tag_name="Denied Tag", status="denied")
pending_id = _insert_pending_linkage(photo_id, tag_name="Pending Tag", status="pending")
response = client.post("/api/v1/pending-linkages/cleanup")
assert response.status_code == 200
payload = response.json()
assert payload["deleted_records"] == 2
with AuthSessionLocal() as session:
remaining = session.execute(
text("SELECT id, status FROM pending_linkages ORDER BY id")
).fetchall()
assert len(remaining) == 1
assert remaining[0][0] == pending_id
assert remaining[0][1] == "pending"