modularize service layer, add mobile nav drawer, and sync docs

This commit is contained in:
Justin Visser 2026-02-20 01:28:20 +01:00
parent 7f7a8ce2b0
commit 6b6ed91b92
72 changed files with 8122 additions and 7501 deletions

View file

@ -0,0 +1 @@
from app.services.domains.publications.application import *

View file

@ -0,0 +1,50 @@
from __future__ import annotations
from app.services.domains.publications.counts import (
count_for_user,
count_latest_for_user,
count_unread_for_user,
)
from app.services.domains.publications.listing import (
list_for_user,
list_new_for_latest_run_for_user,
list_unread_for_user,
)
from app.services.domains.publications.modes import (
MODE_ALL,
MODE_LATEST,
MODE_NEW,
MODE_UNREAD,
resolve_mode,
resolve_publication_view_mode,
)
from app.services.domains.publications.queries import (
get_latest_completed_run_id_for_user,
publications_query,
)
from app.services.domains.publications.read_state import (
mark_all_unread_as_read_for_user,
mark_selected_as_read_for_user,
)
from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem
__all__ = [
"MODE_ALL",
"MODE_UNREAD",
"MODE_LATEST",
"MODE_NEW",
"PublicationListItem",
"UnreadPublicationItem",
"resolve_publication_view_mode",
"resolve_mode",
"get_latest_completed_run_id_for_user",
"publications_query",
"list_for_user",
"list_unread_for_user",
"list_new_for_latest_run_for_user",
"count_for_user",
"count_unread_for_user",
"count_latest_for_user",
"mark_all_unread_as_read_for_user",
"mark_selected_as_read_for_user",
]

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import ScholarProfile, ScholarPublication
from app.services.domains.publications.modes import (
MODE_ALL,
MODE_LATEST,
MODE_UNREAD,
resolve_publication_view_mode,
)
from app.services.domains.publications.queries import get_latest_completed_run_id_for_user
async def count_for_user(
db_session: AsyncSession,
*,
user_id: int,
mode: str = MODE_ALL,
scholar_profile_id: int | None = None,
) -> int:
resolved_mode = resolve_publication_view_mode(mode)
latest_run_id = await get_latest_completed_run_id_for_user(db_session, user_id=user_id)
stmt = (
select(func.count())
.select_from(ScholarPublication)
.join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id)
.where(ScholarProfile.user_id == user_id)
)
if scholar_profile_id is not None:
stmt = stmt.where(ScholarProfile.id == scholar_profile_id)
if resolved_mode == MODE_UNREAD:
stmt = stmt.where(ScholarPublication.is_read.is_(False))
if resolved_mode == MODE_LATEST:
if latest_run_id is None:
return 0
stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id)
result = await db_session.execute(stmt)
return int(result.scalar_one() or 0)
async def count_unread_for_user(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_id: int | None = None,
) -> int:
return await count_for_user(
db_session,
user_id=user_id,
mode=MODE_UNREAD,
scholar_profile_id=scholar_profile_id,
)
async def count_latest_for_user(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_id: int | None = None,
) -> int:
return await count_for_user(
db_session,
user_id=user_id,
mode=MODE_LATEST,
scholar_profile_id=scholar_profile_id,
)

View file

@ -0,0 +1,89 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.domains.publications.modes import (
MODE_ALL,
MODE_LATEST,
MODE_UNREAD,
resolve_publication_view_mode,
)
from app.services.domains.publications.queries import (
get_latest_completed_run_id_for_user,
publication_list_item_from_row,
publications_query,
unread_item_from_row,
)
from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem
async def list_for_user(
db_session: AsyncSession,
*,
user_id: int,
mode: str = MODE_ALL,
scholar_profile_id: int | None = None,
limit: int = 300,
) -> list[PublicationListItem]:
resolved_mode = resolve_publication_view_mode(mode)
latest_run_id = await get_latest_completed_run_id_for_user(db_session, user_id=user_id)
result = await db_session.execute(
publications_query(
user_id=user_id,
mode=resolved_mode,
latest_run_id=latest_run_id,
scholar_profile_id=scholar_profile_id,
limit=limit,
)
)
return [
publication_list_item_from_row(row, latest_run_id=latest_run_id)
for row in result.all()
]
async def list_unread_for_user(
db_session: AsyncSession,
*,
user_id: int,
limit: int = 100,
) -> list[UnreadPublicationItem]:
result = await db_session.execute(
publications_query(
user_id=user_id,
mode=MODE_UNREAD,
latest_run_id=None,
scholar_profile_id=None,
limit=limit,
)
)
return [unread_item_from_row(row) for row in result.all()]
async def list_new_for_latest_run_for_user(
db_session: AsyncSession,
*,
user_id: int,
limit: int = 100,
) -> list[UnreadPublicationItem]:
rows = await list_for_user(
db_session,
user_id=user_id,
mode=MODE_LATEST,
scholar_profile_id=None,
limit=limit,
)
return [
UnreadPublicationItem(
publication_id=row.publication_id,
scholar_profile_id=row.scholar_profile_id,
scholar_label=row.scholar_label,
title=row.title,
year=row.year,
citation_count=row.citation_count,
venue_text=row.venue_text,
pub_url=row.pub_url,
pdf_url=row.pdf_url,
)
for row in rows
]

View file

@ -0,0 +1,18 @@
from __future__ import annotations
MODE_ALL = "all"
MODE_UNREAD = "unread"
MODE_LATEST = "latest"
MODE_NEW = "new" # compatibility alias for MODE_LATEST
def resolve_publication_view_mode(value: str | None) -> str:
if value == MODE_UNREAD:
return MODE_UNREAD
if value in {MODE_LATEST, MODE_NEW}:
return MODE_LATEST
return MODE_ALL
def resolve_mode(value: str | None) -> str:
return resolve_publication_view_mode(value)

View file

@ -0,0 +1,139 @@
from __future__ import annotations
from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import CrawlRun, Publication, RunStatus, ScholarProfile, ScholarPublication
from app.services.domains.publications.modes import MODE_LATEST, MODE_UNREAD
from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem
def _normalized_citation_count(value: object) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
async def get_latest_completed_run_id_for_user(
db_session: AsyncSession,
*,
user_id: int,
) -> int | None:
result = await db_session.execute(
select(func.max(CrawlRun.id)).where(
CrawlRun.user_id == user_id,
CrawlRun.status != RunStatus.RUNNING,
)
)
latest_run_id = result.scalar_one_or_none()
return int(latest_run_id) if latest_run_id is not None else None
def publications_query(
*,
user_id: int,
mode: str,
latest_run_id: int | None,
scholar_profile_id: int | None,
limit: int,
) -> Select[tuple]:
scholar_label = ScholarProfile.display_name
stmt = (
select(
Publication.id,
ScholarProfile.id,
scholar_label,
ScholarProfile.scholar_id,
Publication.title_raw,
Publication.year,
Publication.citation_count,
Publication.venue_text,
Publication.pub_url,
Publication.pdf_url,
ScholarPublication.is_read,
ScholarPublication.first_seen_run_id,
ScholarPublication.created_at,
)
.join(ScholarPublication, ScholarPublication.publication_id == Publication.id)
.join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id)
.where(ScholarProfile.user_id == user_id)
.order_by(ScholarPublication.created_at.desc(), Publication.id.desc())
.limit(limit)
)
if scholar_profile_id is not None:
stmt = stmt.where(ScholarProfile.id == scholar_profile_id)
if mode == MODE_UNREAD:
stmt = stmt.where(ScholarPublication.is_read.is_(False))
if mode == MODE_LATEST:
if latest_run_id is None:
return stmt.where(False)
stmt = stmt.where(ScholarPublication.first_seen_run_id == latest_run_id)
return stmt
def publication_list_item_from_row(
row: tuple,
*,
latest_run_id: int | None,
) -> PublicationListItem:
(
publication_id,
scholar_profile_id,
display_name,
scholar_id,
title_raw,
year,
citation_count,
venue_text,
pub_url,
pdf_url,
is_read,
first_seen_run_id,
created_at,
) = row
return PublicationListItem(
publication_id=int(publication_id),
scholar_profile_id=int(scholar_profile_id),
scholar_label=(display_name or scholar_id),
title=title_raw,
year=year,
citation_count=_normalized_citation_count(citation_count),
venue_text=venue_text,
pub_url=pub_url,
pdf_url=pdf_url,
is_read=bool(is_read),
first_seen_at=created_at,
is_new_in_latest_run=(
latest_run_id is not None and int(first_seen_run_id or 0) == latest_run_id
),
)
def unread_item_from_row(row: tuple) -> UnreadPublicationItem:
(
publication_id,
scholar_profile_id,
display_name,
scholar_id,
title_raw,
year,
citation_count,
venue_text,
pub_url,
pdf_url,
_is_read,
_first_seen_run_id,
_created_at,
) = row
return UnreadPublicationItem(
publication_id=int(publication_id),
scholar_profile_id=int(scholar_profile_id),
scholar_label=(display_name or scholar_id),
title=title_raw,
year=year,
citation_count=_normalized_citation_count(citation_count),
venue_text=venue_text,
pub_url=pub_url,
pdf_url=pdf_url,
)

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from sqlalchemy import select, tuple_, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import ScholarProfile, ScholarPublication
def _normalized_selection_pairs(selections: list[tuple[int, int]]) -> set[tuple[int, int]]:
pairs: set[tuple[int, int]] = set()
for scholar_profile_id, publication_id in selections:
normalized = (int(scholar_profile_id), int(publication_id))
if normalized[0] <= 0 or normalized[1] <= 0:
continue
pairs.add(normalized)
return pairs
async def mark_all_unread_as_read_for_user(
db_session: AsyncSession,
*,
user_id: int,
) -> int:
scholar_ids = (
select(ScholarProfile.id)
.where(ScholarProfile.user_id == user_id)
.scalar_subquery()
)
stmt = (
update(ScholarPublication)
.where(
ScholarPublication.scholar_profile_id.in_(scholar_ids),
ScholarPublication.is_read.is_(False),
)
.values(is_read=True)
)
result = await db_session.execute(stmt)
await db_session.commit()
return int(result.rowcount or 0)
async def mark_selected_as_read_for_user(
db_session: AsyncSession,
*,
user_id: int,
selections: list[tuple[int, int]],
) -> int:
normalized_pairs = _normalized_selection_pairs(selections)
if not normalized_pairs:
return 0
scholar_ids = (
select(ScholarProfile.id)
.where(ScholarProfile.user_id == user_id)
.scalar_subquery()
)
stmt = (
update(ScholarPublication)
.where(
ScholarPublication.scholar_profile_id.in_(scholar_ids),
tuple_(
ScholarPublication.scholar_profile_id,
ScholarPublication.publication_id,
).in_(list(normalized_pairs)),
ScholarPublication.is_read.is_(False),
)
.values(is_read=True)
)
result = await db_session.execute(stmt)
await db_session.commit()
return int(result.rowcount or 0)

View file

@ -0,0 +1,33 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class PublicationListItem:
publication_id: int
scholar_profile_id: int
scholar_label: str
title: str
year: int | None
citation_count: int
venue_text: str | None
pub_url: str | None
pdf_url: str | None
is_read: bool
first_seen_at: datetime
is_new_in_latest_run: bool
@dataclass(frozen=True)
class UnreadPublicationItem:
publication_id: int
scholar_profile_id: int
scholar_label: str
title: str
year: int | None
citation_count: int
venue_text: str | None
pub_url: str | None
pdf_url: str | None