from __future__ import annotations import logging from datetime import UTC, datetime from typing import Literal from fastapi import APIRouter, Depends, Path, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload from app.api.schemas import ( MarkAllReadEnvelope, MarkSelectedReadEnvelope, MarkSelectedReadRequest, PublicationsListEnvelope, RetryPublicationPdfEnvelope, RetryPublicationPdfRequest, TogglePublicationFavoriteEnvelope, TogglePublicationFavoriteRequest, ) from app.db.models import User from app.db.session import get_db_session from app.logging_utils import structured_log from app.services.domains.publication_identifiers import application as identifier_service from app.services.domains.publications import application as publication_service from app.services.domains.scholars import application as scholar_service from app.settings import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/publications", tags=["api-publications"]) async def _require_selected_profile( db_session: AsyncSession, *, user_id: int, selected_scholar_id: int | None, ) -> None: if selected_scholar_id is None: return selected_profile = await scholar_service.get_user_scholar_by_id( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, ) if selected_profile is None: raise ApiException( status_code=404, code="scholar_not_found", message="Scholar filter not found.", ) def _serialize_publication_item(item) -> dict[str, object]: return { "publication_id": item.publication_id, "scholar_profile_id": item.scholar_profile_id, "scholar_label": item.scholar_label, "title": item.title, "year": item.year, "citation_count": item.citation_count, "venue_text": item.venue_text, "pub_url": item.pub_url, "display_identifier": _serialize_display_identifier(item.display_identifier), "pdf_url": item.pdf_url, "pdf_status": item.pdf_status, "pdf_attempt_count": item.pdf_attempt_count, "pdf_failure_reason": item.pdf_failure_reason, "pdf_failure_detail": item.pdf_failure_detail, "is_read": item.is_read, "is_favorite": item.is_favorite, "first_seen_at": item.first_seen_at, "is_new_in_latest_run": item.is_new_in_latest_run, } def _serialize_display_identifier(value) -> dict[str, object] | None: if value is None: return None return { "kind": value.kind, "value": value.value, "label": value.label, "url": value.url, "confidence_score": float(value.confidence_score), } async def _publication_counts( db_session: AsyncSession, *, user_id: int, selected_scholar_id: int | None, favorite_only: bool, search: str | None, snapshot_before: datetime | None, ) -> tuple[int, int, int, int]: unread_count = await publication_service.count_unread_for_user( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, snapshot_before=snapshot_before, ) favorites_count = await publication_service.count_favorite_for_user( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, snapshot_before=snapshot_before, ) latest_count = await publication_service.count_latest_for_user( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, snapshot_before=snapshot_before, ) total_count = await publication_service.count_for_user( db_session, user_id=user_id, mode=publication_service.MODE_ALL, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, search=search, snapshot_before=snapshot_before, ) return unread_count, favorites_count, latest_count, total_count async def _list_publications_for_request( db_session: AsyncSession, *, current_user: User, mode: Literal["all", "unread", "latest", "new"] | None, favorite_only: bool, scholar_profile_id: int | None, search: str | None, sort_by: str, sort_dir: str, limit: int, offset: int, snapshot_before: datetime | None, ) -> tuple[str, int | None, list]: resolved_mode = publication_service.resolve_publication_view_mode(mode) selected_scholar_id = scholar_profile_id await _require_selected_profile( db_session, user_id=current_user.id, selected_scholar_id=selected_scholar_id, ) publications = await publication_service.list_for_user( db_session, user_id=current_user.id, mode=resolved_mode, scholar_profile_id=selected_scholar_id, favorite_only=favorite_only, search=search, sort_by=sort_by, sort_dir=sort_dir, limit=limit, offset=offset, snapshot_before=snapshot_before, ) await publication_service.schedule_missing_pdf_enrichment_for_user( db_session, user_id=current_user.id, request_email=current_user.email, items=publications, max_items=settings.unpaywall_max_items_per_request, ) hydrated = await publication_service.hydrate_pdf_enrichment_state( db_session, items=publications, ) return resolved_mode, selected_scholar_id, hydrated def _resolve_publications_snapshot( *, snapshot: str | None, ) -> tuple[datetime, str]: if snapshot is None: now_utc = datetime.now(UTC) return now_utc, now_utc.isoformat() try: parsed = datetime.fromisoformat(snapshot) except ValueError as exc: raise ApiException( status_code=400, code="invalid_snapshot", message="Invalid publications snapshot cursor.", ) from exc if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=UTC) normalized = parsed.astimezone(UTC) return normalized, normalized.isoformat() def _resolve_publications_paging( *, page: int, page_size: int, limit: int | None, offset: int | None, ) -> tuple[int, int, int]: resolved_limit = int(limit) if limit is not None else int(page_size) resolved_offset = int(offset) if offset is not None else max((int(page) - 1) * resolved_limit, 0) resolved_page = max((resolved_offset // max(resolved_limit, 1)) + 1, 1) return resolved_page, max(resolved_limit, 1), max(resolved_offset, 0) def _publications_list_data( *, mode: str, favorite_only: bool, selected_scholar_id: int | None, unread_count: int, favorites_count: int, latest_count: int, total_count: int, publications: list, page: int, page_size: int, offset: int, snapshot: str, ) -> dict[str, object]: return { "mode": mode, "favorite_only": favorite_only, "selected_scholar_profile_id": selected_scholar_id, "unread_count": unread_count, "favorites_count": favorites_count, "latest_count": latest_count, "new_count": latest_count, "total_count": total_count, "page": int(page), "page_size": int(page_size), "snapshot": snapshot, "has_prev": int(offset) > 0, "has_next": int(offset) + int(page_size) < int(total_count), "publications": [_serialize_publication_item(item) for item in publications], } def _retry_pdf_message(*, queued: bool, resolved_pdf: bool, pdf_status: str) -> str: if resolved_pdf: return "Open-access PDF link already resolved." if queued: return "PDF lookup queued." if pdf_status in {"queued", "running"}: return "PDF lookup is already queued." return "No open-access PDF link found." def _favorite_message(*, is_favorite: bool) -> str: if is_favorite: return "Publication marked as favorite." return "Publication removed from favorites." async def _retry_publication_state( db_session: AsyncSession, *, current_user: User, scholar_profile_id: int, publication_id: int, ): publication = await publication_service.retry_pdf_for_user( db_session, user_id=current_user.id, scholar_profile_id=scholar_profile_id, publication_id=publication_id, ) if publication is None: raise ApiException( status_code=404, code="publication_not_found", message="Publication not found.", ) queued = False if not publication.pdf_url: queued = await publication_service.schedule_retry_pdf_enrichment_for_row( db_session, user_id=current_user.id, request_email=current_user.email, item=publication, ) hydrated = await publication_service.hydrate_pdf_enrichment_state( db_session, items=[publication], ) current = hydrated[0] if hydrated else publication return current, queued async def _favorite_publication_state( db_session: AsyncSession, *, current_user: User, scholar_profile_id: int, publication_id: int, is_favorite: bool, ): updated_count = await publication_service.set_publication_favorite_for_user( db_session, user_id=current_user.id, scholar_profile_id=scholar_profile_id, publication_id=publication_id, is_favorite=is_favorite, ) if updated_count <= 0: raise ApiException( status_code=404, code="publication_not_found", message="Publication not found.", ) publication = await publication_service.get_publication_item_for_user( db_session, user_id=current_user.id, scholar_profile_id=scholar_profile_id, publication_id=publication_id, ) if publication is None: raise ApiException( status_code=404, code="publication_not_found", message="Publication not found.", ) hydrated = await publication_service.hydrate_pdf_enrichment_state( db_session, items=[publication], ) current = hydrated[0] if hydrated else publication identifiers = await identifier_service.overlay_publication_items_with_display_identifiers( db_session, items=[current], ) return identifiers[0] if identifiers else current @router.get( "", response_model=PublicationsListEnvelope, ) async def list_publications( request: Request, mode: Literal["all", "unread", "latest", "new"] | None = Query(default=None), favorite_only: bool = Query(default=False), scholar_profile_id: int | None = Query(default=None, ge=1), search: str | None = Query(default=None, min_length=1, max_length=200), sort_by: Literal["first_seen", "title", "year", "citations", "scholar", "pdf_status"] = Query(default="first_seen"), sort_dir: Literal["asc", "desc"] = Query(default="desc"), page: int = Query(default=1, ge=1), page_size: int = Query(default=100, ge=1, le=500), limit: int | None = Query(default=None, ge=1, le=1000), offset: int | None = Query(default=None, ge=0), snapshot: str | None = Query(default=None, min_length=1, max_length=64), db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): resolved_page, resolved_limit, resolved_offset = _resolve_publications_paging( page=page, page_size=page_size, limit=limit, offset=offset, ) snapshot_before, snapshot_cursor = _resolve_publications_snapshot(snapshot=snapshot) normalized_search = (search or "").strip() or None resolved_mode, selected_scholar_id, publications = await _list_publications_for_request( db_session, current_user=current_user, mode=mode, favorite_only=favorite_only, scholar_profile_id=scholar_profile_id, search=normalized_search, sort_by=sort_by, sort_dir=sort_dir, limit=resolved_limit, offset=resolved_offset, snapshot_before=snapshot_before, ) unread_count, favorites_count, latest_count, total_count = await _publication_counts( db_session, user_id=current_user.id, selected_scholar_id=selected_scholar_id, favorite_only=favorite_only, search=normalized_search, snapshot_before=snapshot_before, ) data = _publications_list_data( mode=resolved_mode, favorite_only=favorite_only, selected_scholar_id=selected_scholar_id, unread_count=unread_count, favorites_count=favorites_count, latest_count=latest_count, total_count=total_count, publications=publications, page=resolved_page, page_size=resolved_limit, offset=resolved_offset, snapshot=snapshot_cursor, ) return success_payload(request, data=data) @router.post( "/mark-all-read", response_model=MarkAllReadEnvelope, ) async def mark_all_publications_read( request: Request, db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): updated_count = await publication_service.mark_all_unread_as_read_for_user( db_session, user_id=current_user.id, ) structured_log( logger, "info", "api.publications.mark_all_read", user_id=current_user.id, updated_count=updated_count ) return success_payload( request, data={ "message": "Marked all unread publications as read.", "updated_count": updated_count, }, ) @router.post( "/mark-read", response_model=MarkSelectedReadEnvelope, ) async def mark_selected_publications_read( payload: MarkSelectedReadRequest, request: Request, db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): selection_pairs = sorted({(int(item.scholar_profile_id), int(item.publication_id)) for item in payload.selections}) updated_count = await publication_service.mark_selected_as_read_for_user( db_session, user_id=current_user.id, selections=selection_pairs, ) structured_log( logger, "info", "api.publications.mark_selected_read", user_id=current_user.id, requested_count=len(selection_pairs), updated_count=updated_count, ) return success_payload( request, data={ "message": "Marked selected publications as read.", "requested_count": len(selection_pairs), "updated_count": updated_count, }, ) @router.post( "/{publication_id}/retry-pdf", response_model=RetryPublicationPdfEnvelope, ) async def retry_publication_pdf( payload: RetryPublicationPdfRequest, request: Request, publication_id: int = Path(ge=1), db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): current, queued = await _retry_publication_state( db_session, current_user=current_user, scholar_profile_id=payload.scholar_profile_id, publication_id=publication_id, ) resolved_pdf = bool(current.pdf_url) message = _retry_pdf_message( queued=queued, resolved_pdf=resolved_pdf, pdf_status=current.pdf_status, ) structured_log( logger, "info", "api.publications.retry_pdf", user_id=current_user.id, scholar_profile_id=payload.scholar_profile_id, publication_id=publication_id, queued=queued, resolved_pdf=resolved_pdf, pdf_status=current.pdf_status, ) return success_payload( request, data={ "message": message, "queued": queued, "resolved_pdf": resolved_pdf, "publication": _serialize_publication_item(current), }, ) @router.post( "/{publication_id}/favorite", response_model=TogglePublicationFavoriteEnvelope, ) async def toggle_publication_favorite( payload: TogglePublicationFavoriteRequest, request: Request, publication_id: int = Path(ge=1), db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): current = await _favorite_publication_state( db_session, current_user=current_user, scholar_profile_id=payload.scholar_profile_id, publication_id=publication_id, is_favorite=payload.is_favorite, ) structured_log( logger, "info", "api.publications.favorite", user_id=current_user.id, scholar_profile_id=payload.scholar_profile_id, publication_id=publication_id, is_favorite=bool(payload.is_favorite), ) return success_payload( request, data={ "message": _favorite_message(is_favorite=bool(payload.is_favorite)), "publication": _serialize_publication_item(current), }, )