release prep #18
63
app/api/media.py
Normal 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)
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "website",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 580 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 990 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/scholar_logo.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
frontend/public/scholarr_favicon.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
|
|
@ -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,22 +25,36 @@ 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
|
||||||
magick "${SRC_LOGO}" -alpha extract "${TMP_ALPHA}"
|
# Prefer dedicated favicon artwork when provided.
|
||||||
magick -size 1024x1024 xc:white "${TMP_ALPHA}" \
|
magick "${SRC_FAVICON}" \
|
||||||
-compose CopyOpacity \
|
-background none \
|
||||||
-composite \
|
-gravity center \
|
||||||
"${TMP_GLYPH}"
|
-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 -size 1024x1024 xc:white "${TMP_ALPHA}" \
|
||||||
|
-compose CopyOpacity \
|
||||||
|
-composite \
|
||||||
|
"${TMP_GLYPH}"
|
||||||
|
|
||||||
magick -size 1024x1024 "xc:${BRAND_COLOR}" "${TMP_CANVAS}"
|
magick -size 1024x1024 "xc:${BRAND_COLOR}" "${TMP_CANVAS}"
|
||||||
magick "${TMP_GLYPH}" -resize 760x760 "${TMP_GLYPH}"
|
magick "${TMP_GLYPH}" -resize 760x760 "${TMP_GLYPH}"
|
||||||
magick "${TMP_CANVAS}" "${TMP_GLYPH}" \
|
magick "${TMP_CANVAS}" "${TMP_GLYPH}" \
|
||||||
-gravity center \
|
-gravity center \
|
||||||
-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})"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 104 KiB |
|
|
@ -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
|
||||||
|
|
|
||||||