Ermöglicht Apps außerhalb des Browsers (z.B. den TUI-Client), sich per Authorization: Bearer <token> zu authentifizieren statt per Session-Cookie. Die neue ApiTokenAuthMiddleware setzt request.user genau wie ein Login, wodurch alle bestehenden books/podcasts/radio-Endpunkte ohne Änderungen token-fähig werden. GET /api/sync/ liefert zusätzlich den kompletten Nutzerzustand (Bücher-Metadaten, Lesefortschritt, Notizen, Podcasts, Sender) in einem Request; Schreiben läuft weiter über die bestehenden Endpunkte, um deren Merge-Semantik (furthest-wins Progress, Notes-Upsert) wiederzuverwenden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
82 lines
3.3 KiB
Python
82 lines
3.3 KiB
Python
from django.db.models import Max
|
|
from django.http import JsonResponse
|
|
from django.utils import timezone
|
|
from django.views.decorators.http import require_http_methods
|
|
|
|
from books.models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
|
|
from podcasts.models import PodcastFeed, EpisodeProgress, PodcastQueue
|
|
from radio.models import SavedStation
|
|
|
|
|
|
@require_http_methods(['GET'])
|
|
def sync_snapshot(request):
|
|
"""Aggregate read-only snapshot of a user's data for local/native clients
|
|
(see accounts/middleware.py for the Bearer-token auth that makes this
|
|
reachable without a browser session).
|
|
|
|
Deliberately excludes book file bytes and podcast audio — those stay
|
|
large/expensive and are fetched lazily via the existing per-resource
|
|
endpoints (e.g. GET /books/<id>/data/), which this same token also
|
|
unlocks. Writes (progress, highlights, bookmarks, ...) go through those
|
|
existing endpoints too, to reuse their merge semantics rather than
|
|
duplicating it here."""
|
|
if not request.user.is_authenticated:
|
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
|
|
|
user = request.user
|
|
|
|
books = list(EBook.objects.filter(user=user).values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read'))
|
|
for b in books:
|
|
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
|
|
|
book_progress = list(
|
|
EBookProgress.objects.filter(user=user).values('book_id', 'scroll_fraction', 'position_anchor', 'updated_at')
|
|
)
|
|
for p in book_progress:
|
|
p['updated_at'] = p['updated_at'].isoformat()
|
|
|
|
book_highlights = list(EBookHighlights.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at'))
|
|
for h in book_highlights:
|
|
h['updated_at'] = h['updated_at'].isoformat()
|
|
|
|
book_bookmarks = list(EBookBookmarks.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at'))
|
|
for bm in book_bookmarks:
|
|
bm['updated_at'] = bm['updated_at'].isoformat()
|
|
|
|
podcast_feeds = list(
|
|
PodcastFeed.objects.filter(user=user)
|
|
.annotate(latest_episode_at=Max('episodes__pub_date'))
|
|
.values('id', 'title', 'artwork_url', 'rss_url', 'last_refreshed_at', 'author', 'added_at', 'auto_queue', 'latest_episode_at')
|
|
)
|
|
for f in podcast_feeds:
|
|
for key in ('last_refreshed_at', 'added_at', 'latest_episode_at'):
|
|
if f[key]:
|
|
f[key] = f[key].isoformat()
|
|
|
|
episode_progress = list(
|
|
EpisodeProgress.objects.filter(user=user)
|
|
.values('episode_id', 'position_seconds', 'played', 'dismissed', 'updated_at')
|
|
)
|
|
for ep in episode_progress:
|
|
ep['updated_at'] = ep['updated_at'].isoformat()
|
|
|
|
podcast_queue = list(
|
|
PodcastQueue.objects.filter(user=user).order_by('position').values('episode_id', 'position')
|
|
)
|
|
|
|
saved_stations = list(
|
|
SavedStation.objects.filter(user=user)
|
|
.values('id', 'name', 'url', 'bitrate', 'country', 'tags', 'favicon_url', 'is_favorite', 'notes')
|
|
)
|
|
|
|
return JsonResponse({
|
|
'server_time': timezone.now().isoformat(),
|
|
'books': books,
|
|
'book_progress': book_progress,
|
|
'book_highlights': book_highlights,
|
|
'book_bookmarks': book_bookmarks,
|
|
'podcast_feeds': podcast_feeds,
|
|
'episode_progress': episode_progress,
|
|
'podcast_queue': podcast_queue,
|
|
'saved_stations': saved_stations,
|
|
})
|