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, } )