Files
J621/backend/apps/follows/views.py
T
JakeBreath cad659b75f Add a blacklist editor that saves to e621
The Online sidebar's "Your blacklist" section is editable now: typing a
tag appends it and each entry gets an x to remove it. Changes are written
straight to the e621 account (PATCH /users/{id}.json with
user[blacklisted_tags]); the store keeps the fetched profile for the id,
updates the list optimistically and reloads it from e621. The Followed
page's blacklist cloud is rebuilt afterwards via the new
/api/follows/cloud/?refresh=1 force flag.

Verified against the live API with a reversible add/verify/restore test.
2026-09-17 20:35:23 -05:00

321 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."""
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):
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,
}
)