release prep

This commit is contained in:
Justin Visser 2026-02-21 18:36:27 +01:00
parent ca565e2530
commit 5499904cef
22 changed files with 111 additions and 119 deletions

63
app/api/media.py Normal file
View file

@ -0,0 +1,63 @@
from __future__ import annotations
import mimetypes
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_api_current_user
from app.api.errors import ApiException
from app.db.models import User
from app.db.session import get_db_session
from app.services.domains.scholars import application as scholar_service
from app.settings import settings
router = APIRouter(tags=["media"])
@router.get("/scholar-images/{scholar_profile_id}/upload")
async def get_uploaded_scholar_image(
scholar_profile_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user),
):
profile = await scholar_service.get_user_scholar_by_id(
db_session,
user_id=current_user.id,
scholar_profile_id=scholar_profile_id,
)
if profile is None:
raise ApiException(
status_code=404,
code="scholar_not_found",
message="Scholar not found.",
)
if not profile.profile_image_upload_path:
raise ApiException(
status_code=404,
code="scholar_image_not_found",
message="Scholar image not found.",
)
try:
image_path = scholar_service.resolve_upload_file_path(
upload_dir=settings.scholar_image_upload_dir,
relative_path=profile.profile_image_upload_path,
)
except scholar_service.ScholarServiceError as exc:
raise ApiException(
status_code=404,
code="scholar_image_not_found",
message="Scholar image not found.",
) from exc
if not image_path.exists() or not image_path.is_file():
raise ApiException(
status_code=404,
code="scholar_image_not_found",
message="Scholar image not found.",
)
media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream"
return FileResponse(path=image_path, media_type=media_type)

View file

@ -2,10 +2,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import mimetypes
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_api_current_user from app.api.deps import get_api_current_user
@ -35,14 +33,14 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scholars", tags=["api-scholars"]) router = APIRouter(prefix="/scholars", tags=["api-scholars"])
def _uploaded_image_api_path(scholar_profile_id: int) -> str: def _uploaded_image_media_path(scholar_profile_id: int) -> str:
return f"/api/v1/scholars/{scholar_profile_id}/image/upload" return f"/scholar-images/{scholar_profile_id}/upload"
def _serialize_scholar(profile) -> dict[str, object]: def _serialize_scholar(profile) -> dict[str, object]:
uploaded_image_url = None uploaded_image_url = None
if profile.profile_image_upload_path: if profile.profile_image_upload_path:
uploaded_image_url = _uploaded_image_api_path(int(profile.id)) uploaded_image_url = _uploaded_image_media_path(int(profile.id))
profile_image_url, profile_image_source = scholar_service.resolve_profile_image( profile_image_url, profile_image_source = scholar_service.resolve_profile_image(
profile, profile,
@ -557,51 +555,3 @@ async def clear_scholar_image_customization(
data=_serialize_scholar(updated), data=_serialize_scholar(updated),
) )
@router.get(
"/{scholar_profile_id}/image/upload",
)
async def get_uploaded_scholar_image(
scholar_profile_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user),
):
profile = await scholar_service.get_user_scholar_by_id(
db_session,
user_id=current_user.id,
scholar_profile_id=scholar_profile_id,
)
if profile is None:
raise ApiException(
status_code=404,
code="scholar_not_found",
message="Scholar not found.",
)
if not profile.profile_image_upload_path:
raise ApiException(
status_code=404,
code="scholar_image_not_found",
message="Scholar image not found.",
)
try:
image_path = scholar_service.resolve_upload_file_path(
upload_dir=settings.scholar_image_upload_dir,
relative_path=profile.profile_image_upload_path,
)
except scholar_service.ScholarServiceError as exc:
raise ApiException(
status_code=404,
code="scholar_image_not_found",
message="Scholar image not found.",
) from exc
if not image_path.exists() or not image_path.is_file():
raise ApiException(
status_code=404,
code="scholar_image_not_found",
message="Scholar image not found.",
)
media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream"
return FileResponse(path=image_path, media_type=media_type)

View file

@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.api.errors import register_api_exception_handlers from app.api.errors import register_api_exception_handlers
from app.api.media import router as media_router
from app.api.router import router as api_router from app.api.router import router as api_router
from app.api.runtime_deps import get_ingestion_service, get_scholar_source from app.api.runtime_deps import get_ingestion_service, get_scholar_source
from app.db.session import check_database from app.db.session import check_database
@ -106,6 +107,7 @@ app.add_middleware(
strict_transport_security_preload=settings.security_strict_transport_security_preload, strict_transport_security_preload=settings.security_strict_transport_security_preload,
) )
app.include_router(api_router) app.include_router(api_router)
app.include_router(media_router)
@app.get("/healthz") @app.get("/healthz")

View file

@ -8,7 +8,6 @@ from app.services.domains.publications.counts import (
) )
from app.services.domains.publications.listing import ( from app.services.domains.publications.listing import (
list_for_user, list_for_user,
list_new_for_latest_run_for_user,
retry_pdf_for_user, retry_pdf_for_user,
list_unread_for_user, list_unread_for_user,
) )
@ -22,7 +21,6 @@ from app.services.domains.publications.modes import (
MODE_LATEST, MODE_LATEST,
MODE_NEW, MODE_NEW,
MODE_UNREAD, MODE_UNREAD,
resolve_mode,
resolve_publication_view_mode, resolve_publication_view_mode,
) )
from app.services.domains.publications.queries import ( from app.services.domains.publications.queries import (
@ -52,13 +50,11 @@ __all__ = [
"PublicationListItem", "PublicationListItem",
"UnreadPublicationItem", "UnreadPublicationItem",
"resolve_publication_view_mode", "resolve_publication_view_mode",
"resolve_mode",
"get_latest_completed_run_id_for_user", "get_latest_completed_run_id_for_user",
"publications_query", "publications_query",
"get_publication_item_for_user", "get_publication_item_for_user",
"list_for_user", "list_for_user",
"list_unread_for_user", "list_unread_for_user",
"list_new_for_latest_run_for_user",
"retry_pdf_for_user", "retry_pdf_for_user",
"hydrate_pdf_enrichment_state", "hydrate_pdf_enrichment_state",
"schedule_retry_pdf_enrichment_for_row", "schedule_retry_pdf_enrichment_for_row",

View file

@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.services.domains.publications.modes import ( from app.services.domains.publications.modes import (
MODE_ALL, MODE_ALL,
MODE_LATEST,
MODE_UNREAD, MODE_UNREAD,
resolve_publication_view_mode, resolve_publication_view_mode,
) )
@ -81,34 +80,3 @@ async def list_unread_for_user(
) )
) )
return [unread_item_from_row(row) for row in result.all()] 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 [_to_unread_item(row) for row in rows]
def _to_unread_item(row: PublicationListItem) -> UnreadPublicationItem:
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,
doi=row.doi,
pdf_url=row.pdf_url,
)

View file

@ -12,7 +12,3 @@ def resolve_publication_view_mode(value: str | None) -> str:
if value in {MODE_LATEST, MODE_NEW}: if value in {MODE_LATEST, MODE_NEW}:
return MODE_LATEST return MODE_LATEST
return MODE_ALL return MODE_ALL
def resolve_mode(value: str | None) -> str:
return resolve_publication_view_mode(value)

View file

@ -344,11 +344,6 @@ def _author_search_cooldown_remaining_seconds(
return max(0, remaining_seconds) return max(0, remaining_seconds)
def _reset_author_search_runtime_state_for_tests() -> None:
# Runtime state now lives in the database; tests should reset via DB fixtures.
return None
async def list_scholars_for_user( async def list_scholars_for_user(
db_session: AsyncSession, db_session: AsyncSession,
*, *,

View file

@ -9,6 +9,8 @@ All API responses under `/api/v1` use one of these envelopes:
`meta.request_id` must be present for success and error responses. `meta.request_id` must be present for success and error responses.
Binary media assets are served outside `/api/v1` (for example, `GET /scholar-images/{scholar_profile_id}/upload`).
## Publications Semantics ## Publications Semantics
- `GET /api/v1/publications` supports `mode=all|unread|latest`. - `GET /api/v1/publications` supports `mode=all|unread|latest`.

6
frontend/docs/website/package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "website",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 990 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View file

@ -3,8 +3,10 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" FRONTEND_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SRC_LOGO="${FRONTEND_DIR}/src/logo.png"
PUBLIC_DIR="${FRONTEND_DIR}/public" PUBLIC_DIR="${FRONTEND_DIR}/public"
SRC_LOGO="${PUBLIC_DIR}/scholar_logo.png"
SRC_FAVICON="${PUBLIC_DIR}/scholar_favicon.png"
SRC_FAVICON_ALT="${PUBLIC_DIR}/scholarr_favicon.png"
TMP_DIR="$(mktemp -d)" TMP_DIR="$(mktemp -d)"
TMP_ALPHA="${TMP_DIR}/logo-alpha-mask.png" TMP_ALPHA="${TMP_DIR}/logo-alpha-mask.png"
TMP_GLYPH="${TMP_DIR}/logo-glyph-white.png" TMP_GLYPH="${TMP_DIR}/logo-glyph-white.png"
@ -23,9 +25,22 @@ if [[ ! -f "${SRC_LOGO}" ]]; then
exit 1 exit 1
fi fi
if [[ ! -f "${SRC_FAVICON}" && -f "${SRC_FAVICON_ALT}" ]]; then
SRC_FAVICON="${SRC_FAVICON_ALT}"
fi
mkdir -p "${PUBLIC_DIR}" mkdir -p "${PUBLIC_DIR}"
# Build a high-contrast icon: solid brand background + white logo. if [[ -f "${SRC_FAVICON}" ]]; then
# Prefer dedicated favicon artwork when provided.
magick "${SRC_FAVICON}" \
-background none \
-gravity center \
-resize 1024x1024^ \
-extent 1024x1024 \
"${TMP_ICON}"
else
# Build a high-contrast icon from the logo: solid brand background + white logo.
magick "${SRC_LOGO}" -alpha extract "${TMP_ALPHA}" magick "${SRC_LOGO}" -alpha extract "${TMP_ALPHA}"
magick -size 1024x1024 xc:white "${TMP_ALPHA}" \ magick -size 1024x1024 xc:white "${TMP_ALPHA}" \
-compose CopyOpacity \ -compose CopyOpacity \
@ -39,6 +54,7 @@ magick "${TMP_CANVAS}" "${TMP_GLYPH}" \
-compose over \ -compose over \
-composite \ -composite \
"${TMP_ICON}" "${TMP_ICON}"
fi
magick "${TMP_ICON}" -resize 16x16 "${PUBLIC_DIR}/favicon-16x16.png" magick "${TMP_ICON}" -resize 16x16 "${PUBLIC_DIR}/favicon-16x16.png"
magick "${TMP_ICON}" -resize 32x32 "${PUBLIC_DIR}/favicon-32x32.png" magick "${TMP_ICON}" -resize 32x32 "${PUBLIC_DIR}/favicon-32x32.png"
@ -53,4 +69,4 @@ magick \
rm -rf "${TMP_DIR}" rm -rf "${TMP_DIR}"
echo "Generated favicon + app icon assets in ${PUBLIC_DIR}" echo "Generated favicon + app icon assets in ${PUBLIC_DIR} (logo: ${SRC_LOGO}, favicon source: ${SRC_FAVICON})"

View file

@ -60,7 +60,7 @@ function onToggleMenu(): void {
to="/dashboard" to="/dashboard"
class="inline-flex items-center gap-2.5 rounded-sm font-display text-xl tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset" class="inline-flex items-center gap-2.5 rounded-sm font-display text-xl tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
> >
<AppBrandMark size="lg" /> <AppBrandMark size="xl" />
<span>scholarr</span> <span>scholarr</span>
</RouterLink> </RouterLink>
</div> </div>

View file

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import brandLogo from "@/logo.png";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
size?: "sm" | "md" | "lg" | "xl"; size?: "sm" | "md" | "lg" | "xl";
@ -26,7 +24,7 @@ const sizeClass = computed(() => {
}); });
const logoMaskStyle: Record<string, string> = { const logoMaskStyle: Record<string, string> = {
"--brand-logo-mask": `url(${brandLogo})`, "--brand-logo-mask": "url(/scholar_logo.png)",
}; };
</script> </script>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

View file

@ -777,9 +777,9 @@ async def test_api_scholars_search_and_profile_image_management(
assert upload_response.status_code == 200 assert upload_response.status_code == 200
upload_data = upload_response.json()["data"] upload_data = upload_response.json()["data"]
assert upload_data["profile_image_source"] == "upload" assert upload_data["profile_image_source"] == "upload"
assert upload_data["profile_image_url"] == f"/api/v1/scholars/{scholar_profile_id}/image/upload" assert upload_data["profile_image_url"] == f"/scholar-images/{scholar_profile_id}/upload"
uploaded_image_response = client.get(f"/api/v1/scholars/{scholar_profile_id}/image/upload") uploaded_image_response = client.get(f"/scholar-images/{scholar_profile_id}/upload")
assert uploaded_image_response.status_code == 200 assert uploaded_image_response.status_code == 200
assert uploaded_image_response.headers["content-type"].startswith("image/png") assert uploaded_image_response.headers["content-type"].startswith("image/png")
assert uploaded_image_response.content == uploaded_bytes assert uploaded_image_response.content == uploaded_bytes