Follow lists were paginated at the API default of 48, but the SPA treats them as complete sets: the tag/pool toggles read their state from page one (so the 49th follow looked unfollowed and its spinner waited for a page that could never contain it) and the Followed page rendered only 48 cards while showing that as the count. Both follow endpoints are now unpaginated — they are per-user sets and still restricted to the caller's rows — and the three consumers take plain arrays. Post visibility: the old J621-Django online view fetched limit=320 (e621's maximum) while ours hard-coded 48, and fetchPostsByIds capped id batches at 100. The Online browser now has a 'Posts per page' setting (48/100/200/320) in its sidebar, mirrored in Account -> Browsing preferences, stored per user as e621_per_page and also used for pool loading; the id-batch cap is raised to 320. Tests: follow list shape/isolation (4) and preference validation/merge (3) added; the full backend suite is 46 green. Live-checked the array response shape and the preference bounds (200 accepted, 500 rejected).
328 lines
11 KiB
Python
328 lines
11 KiB
Python
import re
|
|
|
|
from django.db.models import Count, Q
|
|
from django.utils import timezone
|
|
from rest_framework import mixins, status, viewsets
|
|
from rest_framework.decorators import action
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from apps.library import e621
|
|
|
|
from . import cloud as cloud_service
|
|
from . import sync
|
|
from .models import FollowCloud, FollowedPool, FollowedPost, FollowedTag
|
|
from .serializers import FollowedPoolSerializer, FollowedTagSerializer
|
|
|
|
POOL_URL_RE = re.compile(r"/pools/(\d+)")
|
|
|
|
|
|
def _parse_page(request, default_per_page=48):
|
|
try:
|
|
per_page = int(request.query_params.get("per_page", default_per_page))
|
|
except ValueError:
|
|
per_page = default_per_page
|
|
try:
|
|
page = int(request.query_params.get("page", 1))
|
|
except ValueError:
|
|
page = 1
|
|
return max(1, page), min(200, max(1, per_page))
|
|
|
|
|
|
class FollowedTagViewSet(
|
|
mixins.ListModelMixin,
|
|
mixins.CreateModelMixin,
|
|
mixins.DestroyModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
"""Tags the current user follows.
|
|
|
|
Deliberately unpaginated: the SPA treats this as the complete set (follow
|
|
toggles read their state from it and the Followed page lists every card),
|
|
so a 48-item page silently broke following past 48 entries.
|
|
"""
|
|
|
|
serializer_class = FollowedTagSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
pagination_class = None
|
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
|
|
|
def get_queryset(self):
|
|
return (
|
|
FollowedTag.objects.filter(user=self.request.user)
|
|
.annotate(
|
|
unseen_count=Count("posts", filter=Q(posts__seen=False), distinct=True)
|
|
)
|
|
.order_by("tag")
|
|
)
|
|
|
|
def create(self, request):
|
|
tag = str(request.data.get("tag") or "").strip().lower()[:200]
|
|
if not tag:
|
|
return Response(
|
|
{"detail": "A tag is required."}, status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
if FollowedTag.objects.filter(user=request.user, tag=tag).exists():
|
|
return Response(
|
|
{"detail": f"Already following '{tag}'."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
results = e621.get(
|
|
request.user,
|
|
"/tags.json",
|
|
params={"search[name_matches]": tag, "limit": 5},
|
|
require_auth=False,
|
|
)
|
|
except e621.E621Error as exc:
|
|
return Response(
|
|
{"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
|
|
)
|
|
names = {
|
|
str(entry.get("name", "")).lower()
|
|
for entry in results
|
|
if isinstance(entry, dict)
|
|
}
|
|
if tag not in names:
|
|
return Response(
|
|
{"detail": f"e621 has no tag named '{tag}'."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
follow = FollowedTag.objects.create(user=request.user, tag=tag)
|
|
FollowCloud.objects.filter(user=request.user).update(computed_at=None)
|
|
try:
|
|
sync.sync_tag_follow(follow, request.user)
|
|
except e621.E621Error:
|
|
# The periodic command will catch up; the follow still stands.
|
|
pass
|
|
follow = self.get_queryset().get(pk=follow.pk)
|
|
return Response(
|
|
self.get_serializer(follow).data, status=status.HTTP_201_CREATED
|
|
)
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.delete()
|
|
FollowCloud.objects.filter(user=self.request.user).update(computed_at=None)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def seen(self, request, pk=None):
|
|
follow = self.get_object()
|
|
follow.posts.update(seen=True)
|
|
return Response({"success": True})
|
|
|
|
|
|
class FollowedPoolViewSet(
|
|
mixins.ListModelMixin,
|
|
mixins.CreateModelMixin,
|
|
mixins.DestroyModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
"""Pools the current user follows (unpaginated, like the tag list)."""
|
|
|
|
serializer_class = FollowedPoolSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
pagination_class = None
|
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
|
|
|
def get_queryset(self):
|
|
return (
|
|
FollowedPool.objects.filter(user=self.request.user)
|
|
.annotate(
|
|
unseen_count=Count("posts", filter=Q(posts__seen=False), distinct=True)
|
|
)
|
|
.order_by("pool_id")
|
|
)
|
|
|
|
def create(self, request):
|
|
raw = str(
|
|
request.data.get("pool") or request.data.get("pool_id") or ""
|
|
).strip()
|
|
match = POOL_URL_RE.search(raw)
|
|
if match:
|
|
pool_id = int(match.group(1))
|
|
elif raw.isdigit():
|
|
pool_id = int(raw)
|
|
else:
|
|
return Response(
|
|
{"detail": "Enter an e621 pool ID or pool URL."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if FollowedPool.objects.filter(user=request.user, pool_id=pool_id).exists():
|
|
return Response(
|
|
{"detail": f"Already following pool #{pool_id}."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
pool = e621.get(
|
|
request.user, f"/pools/{pool_id}.json", require_auth=False
|
|
)
|
|
except e621.E621NotFound:
|
|
return Response(
|
|
{"detail": f"e621 has no pool #{pool_id}."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except e621.E621Error as exc:
|
|
return Response(
|
|
{"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
|
|
)
|
|
follow = FollowedPool.objects.create(
|
|
user=request.user,
|
|
pool_id=pool_id,
|
|
name=str(pool.get("name") or "")[:200],
|
|
post_count=int(pool.get("post_count") or 0),
|
|
)
|
|
FollowCloud.objects.filter(user=request.user).update(computed_at=None)
|
|
try:
|
|
sync.sync_pool_follow(follow, request.user, refresh_meta=False)
|
|
except e621.E621Error:
|
|
pass
|
|
follow = self.get_queryset().get(pk=follow.pk)
|
|
return Response(
|
|
self.get_serializer(follow).data, status=status.HTTP_201_CREATED
|
|
)
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.delete()
|
|
FollowCloud.objects.filter(user=self.request.user).update(computed_at=None)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def seen(self, request, pk=None):
|
|
follow = self.get_object()
|
|
follow.posts.update(seen=True)
|
|
return Response({"success": True})
|
|
|
|
|
|
class FollowedFeedView(APIView):
|
|
"""Merged newest-first feed across followed tags and pools."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
page, per_page = _parse_page(request)
|
|
queryset = FollowedPost.objects.filter(user=request.user).order_by(
|
|
"-post_id"
|
|
)
|
|
tag_id = request.query_params.get("tag", "")
|
|
pool_id = request.query_params.get("pool", "")
|
|
if tag_id.isdigit():
|
|
queryset = queryset.filter(tag_id=int(tag_id))
|
|
if pool_id.isdigit():
|
|
queryset = queryset.filter(pool_id=int(pool_id))
|
|
if request.query_params.get("unseen") == "1":
|
|
queryset = queryset.filter(seen=False)
|
|
|
|
tag_names = dict(
|
|
FollowedTag.objects.filter(user=request.user).values_list("id", "tag")
|
|
)
|
|
pool_names = dict(
|
|
FollowedPool.objects.filter(user=request.user).values_list(
|
|
"id", "name"
|
|
)
|
|
)
|
|
entries = {}
|
|
for post_id, seen, data, tag, pool in queryset.values_list(
|
|
"post_id", "seen", "data", "tag_id", "pool_id"
|
|
)[:2000]:
|
|
entry = entries.get(post_id)
|
|
if entry is None:
|
|
entry = entries[post_id] = {
|
|
"post_id": post_id,
|
|
"seen": seen,
|
|
"data": data,
|
|
"sources": [],
|
|
}
|
|
else:
|
|
entry["seen"] = entry["seen"] and seen
|
|
if tag and not any(
|
|
source["kind"] == "tag" and source["id"] == tag
|
|
for source in entry["sources"]
|
|
):
|
|
entry["sources"].append(
|
|
{
|
|
"kind": "tag",
|
|
"id": tag,
|
|
"label": tag_names.get(tag, f"tag:{tag}"),
|
|
}
|
|
)
|
|
if pool and not any(
|
|
source["kind"] == "pool" and source["id"] == pool
|
|
for source in entry["sources"]
|
|
):
|
|
entry["sources"].append(
|
|
{
|
|
"kind": "pool",
|
|
"id": pool,
|
|
"label": pool_names.get(pool) or f"Pool #{pool}",
|
|
}
|
|
)
|
|
|
|
items = list(entries.values())
|
|
count = len(items)
|
|
start = (page - 1) * per_page
|
|
return Response(
|
|
{
|
|
"count": count,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"has_next": start + per_page < count,
|
|
"results": items[start : start + per_page],
|
|
}
|
|
)
|
|
|
|
|
|
class FollowedPostSeenView(APIView):
|
|
"""Mark one post seen across every follow it appears in."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request, post_id):
|
|
FollowedPost.objects.filter(user=request.user, post_id=post_id).update(
|
|
seen=True
|
|
)
|
|
return Response({"success": True})
|
|
|
|
|
|
class FollowCloudView(APIView):
|
|
"""The blacklisted-tag cloud, rebuilt in a thread when stale."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
force = request.query_params.get("refresh") == "1"
|
|
cloud, _ = FollowCloud.objects.get_or_create(user=request.user)
|
|
now = timezone.now()
|
|
stale = (
|
|
force
|
|
or cloud.computed_at is None
|
|
or now - cloud.computed_at > (cloud_service.CLOUD_TTL)
|
|
)
|
|
if stale:
|
|
reclaim = Q(building=False) | Q(
|
|
building_since__lt=now - cloud_service.BUILD_STALE
|
|
)
|
|
started = (
|
|
FollowCloud.objects.filter(pk=cloud.pk)
|
|
.filter(reclaim)
|
|
.update(building=True, building_since=now)
|
|
)
|
|
if started:
|
|
cloud_service.start_cloud_build(request.user.id)
|
|
cloud.refresh_from_db()
|
|
building = cloud.building and not (
|
|
cloud.building_since
|
|
and now - cloud.building_since > cloud_service.BUILD_STALE
|
|
)
|
|
data = cloud.data or {}
|
|
return Response(
|
|
{
|
|
"status": "building" if building else "ready",
|
|
"stale": stale,
|
|
"tags": data.get("tags", []),
|
|
"posts": data.get("posts", 0),
|
|
"source": data.get("source"),
|
|
"blacklist_count": data.get("blacklist_count", 0),
|
|
"computed_at": cloud.computed_at,
|
|
}
|
|
)
|