Files
andros c446ae514d Exclude bridge virtual feeds from feed listing, discovery and stats
Bridges help users connect to external content (RSS, ActivityPub) but
are not real Org Social accounts. They no longer appear in /feeds/,
cannot be registered via POST, are skipped by both feed discovery
tasks, and do not count in /stats/. A data migration removes bridge
URLs already registered as feeds.
2026-07-20 09:33:28 +02:00

118 lines
4.3 KiB
Python

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.core.cache import cache
from django.db.models import Count, Q
from django.db.models.functions import TruncMonth
from django.utils import timezone
import logging
from app.bridge.models import bridge_urls_q
from app.feeds.models import Feed, Follow, Post, Profile
logger = logging.getLogger(__name__)
class StatsView(APIView):
"""Aggregated statistics grouped by year and month, plus global counters"""
def get(self, request):
cache_key = "stats"
cached_response = cache.get(cache_key)
if cached_response is not None:
return Response(cached_response, status=status.HTTP_200_OK)
response_data = {
"type": "Success",
"errors": [],
"data": {
"years": self._get_monthly_stats(),
"global": self._get_global_stats(),
},
"meta": {
"generated_at": timezone.now().isoformat(),
},
"_links": {
"self": {"href": "/stats/", "method": "GET"},
"feeds": {"href": "/feeds/", "method": "GET"},
},
}
# Cache permanently (will be cleared by scan_feeds task)
cache.set(cache_key, response_data, None)
return Response(response_data, status=status.HTTP_200_OK)
def _get_monthly_stats(self):
"""Aggregate post counters grouped by year and month.
A post belongs to the month of its ID (RFC 3339 timestamp), which
is stored parsed in created_at. The four type counters are disjoint
(posts + replies + reactions + boosts = total_posts), while
group_messages and polls are transversal.
"""
is_reply = ~Q(reply_to="")
# Same criterion as the replies endpoint: a reaction is a reply with
# a mood and empty or whitespace-only content
is_reaction = is_reply & ~Q(mood="") & Q(content__regex=r"^\s*$")
# Bridged posts belong to external authors, not to real accounts
monthly = (
Post.objects.exclude(bridge_urls_q("profile__feed"))
.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
active_accounts=Count("profile", distinct=True),
total_posts=Count("id"),
posts=Count("id", filter=Q(reply_to="") & Q(include="")),
replies=Count("id", filter=is_reply & ~is_reaction),
boosts=Count("id", filter=Q(reply_to="") & ~Q(include="")),
reactions=Count("id", filter=is_reaction),
group_messages=Count("id", filter=~Q(group="")),
polls=Count("id", filter=Q(poll_end__isnull=False)),
)
.order_by("month")
)
years = {}
for row in monthly:
month = row["month"]
if month is None:
continue
year_key = f"{month.year:04d}"
month_key = f"{month.month:02d}"
years.setdefault(year_key, {})[month_key] = {
"active_accounts": row["active_accounts"],
"total_posts": row["total_posts"],
"posts": row["posts"],
"replies": row["replies"],
"boosts": row["boosts"],
"reactions": row["reactions"],
"group_messages": row["group_messages"],
"polls": row["polls"],
}
return years
def _get_global_stats(self):
"""Global counters, independent of the monthly breakdown.
Bridge virtual feeds (and their profiles, posts and follows) are
a connection helper, not real accounts, so they never count.
"""
real_posts = Post.objects.exclude(bridge_urls_q("profile__feed"))
return {
"registered_feeds": Feed.objects.exclude(bridge_urls_q("url")).count(),
"total_accounts": Profile.objects.exclude(bridge_urls_q("feed")).count(),
"total_posts": real_posts.count(),
"total_follows": Follow.objects.exclude(
bridge_urls_q("followed__feed")
).count(),
"active_groups": real_posts.exclude(group="")
.values("group")
.distinct()
.count(),
}