Follows: followed tags/pools, feeds, unseen badges and blacklist cloud
Backend (new apps.follows): - FollowedTag/FollowedPool/FollowedPost models; per-user follows with unseen tracking, plus FollowCloud for the cached blacklist cloud. - Two periodic commands sharing one fetch path: sync_followed_tags and sync_followed_pools fetch each followed tag/pool's newest posts (one e621 search per unique follow), store unseen feed rows, refresh covers and pool metadata; both fall back to anonymous e621 access. - API: /api/follows/tags|pools (follow, unfollow, mark seen), a merged feed with per-follow filtering, and /api/follows/cloud/ which rebuilds the blacklisted-tag cloud in a daemon thread when its 10 min cache is stale (polling returns building/ready). - e621 client now supports anonymous reads; trimmed posts carry preview URLs for covers and feed tiles. Frontend: - /followed page: follow forms, cover cards with unseen badges and Mark seen, merged feed with filter/unseen toggle, and a blacklist cloud panel that polls while building. Followed nav entry added.
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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."""
|
||||
|
||||
serializer_class = FollowedTagSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
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."""
|
||||
|
||||
serializer_class = FollowedPoolSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
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):
|
||||
cloud, _ = FollowCloud.objects.get_or_create(user=request.user)
|
||||
now = timezone.now()
|
||||
stale = 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,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user