Files
J621/backend/apps/follows/sync.py
T
JakeBreath 99f617d296 Footer storage/backend display and a staff role that actually grants staff
Footer:
- Left is now 'Backend Storage:' with a capacity bar (blue, peach at 80%,
  red at 95% per DESIGN.md) and a used/total/free tooltip; the watched
  folder path is no longer printed. /api/status/ returns a compact storage
  summary instead of the path (the full storage page still shows paths to
  authenticated users).
- Centre shows the backend API origin (empty = same origin). Staff get a
  link to /setup to point the browser elsewhere; everyone else sees it as
  plain text. The Account 'Backend connection' card is gone — this is
  installation plumbing, not a per-user setting.
- Design spec updated to match.

Staff role:
- The custom role did nothing on several endpoints that only accepted
  Django's is_staff/is_superuser. One canonical check now exists:
  User.is_app_staff (superuser, Django staff, or the staff role), used by
  the stats/users APIs, item object permissions, can_delete, upload/
  similarity/download/match querysets, and the management commands
  (which also pick staff-role accounts for e621 sync/match and file
  ownership).

Verified with a role-only staff account (is_staff/is_superuser false):
stats/users 200, all 32 downloads + 2 scans visible, others' items
editable; the same account as role=user gets 403 for all of those.
2026-09-17 23:24:24 -05:00

175 lines
5.4 KiB
Python

"""Fetch and store followed tag/pool feeds from e621.
Used by the two periodic management commands (`sync_followed_tags`,
`sync_followed_pools`) and by the follow API for the first fill of a new
subscription. One e621 search per unique tag/pool is shared across every
user following it.
"""
import logging
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.utils import timezone
from apps.library import e621
from apps.library.services import trim_e621_post
from .models import FollowedPool, FollowedPost, FollowedTag
logger = logging.getLogger(__name__)
FEED_LIMIT = 24
PRUNE_KEEP = 200
def preferred_fetch_user(username=None):
"""A configured account for sync calls; None means run anonymously.
e621 serves post searches and pool reads anonymously, so a missing
account is not fatal — credentials mainly raise rate limits.
"""
User = get_user_model()
if username:
return User.objects.filter(username=username).first()
return (
User.objects.filter(
Q(is_superuser=True) | Q(is_staff=True) | Q(role=User.ROLE_STAFF)
)
.exclude(e621_username="")
.exclude(e621_api_key="")
.order_by("id")
.first()
)
def search_posts(user, term, limit=FEED_LIMIT):
"""Newest posts for a search term (``tag`` or ``pool:<id>``)."""
payload = e621.get(
user,
"/posts.json",
params={"tags": term, "limit": limit},
require_auth=False,
)
posts = payload.get("posts") if isinstance(payload, dict) else None
return posts or []
def cover_payload(post):
"""Lightweight card cover: enough to render a thumbnail and rating."""
if not isinstance(post, dict):
return None
preview = post.get("preview") or {}
score = post.get("score") or {}
return {
"post_id": post.get("id"),
"rating": post.get("rating"),
"preview_url": preview.get("url"),
"score": score.get("total"),
"fav_count": post.get("fav_count"),
}
def feed_payload(post):
"""Compact stored post for the feed (tags included for the tag cloud)."""
trimmed = trim_e621_post(post)
if trimmed is None:
return None
return {
"id": trimmed.get("id"),
"created_at": trimmed.get("created_at"),
"rating": trimmed.get("rating"),
"tags": trimmed.get("tags") or {},
"score": trimmed.get("score") or {},
"fav_count": trimmed.get("fav_count"),
"file": trimmed.get("file") or {},
"preview": trimmed.get("preview") or {},
"uploader_name": trimmed.get("uploader_name"),
}
def _store_posts(follow, posts, tag=None, pool=None):
"""Insert unseen rows for new posts. Returns how many were added."""
existing = set(follow.posts.values_list("post_id", flat=True))
rows = []
for post in posts:
post_id = post.get("id") if isinstance(post, dict) else None
if not isinstance(post_id, int) or post_id in existing:
continue
data = feed_payload(post)
if data is None:
continue
rows.append(
FollowedPost(
user=follow.user,
post_id=post_id,
data=data,
tag=tag,
pool=pool,
)
)
if rows:
FollowedPost.objects.bulk_create(rows, ignore_conflicts=True)
_prune(follow)
return len(rows)
def _prune(follow):
"""Keep only the newest PRUNE_KEEP posts per follow."""
keep = list(
follow.posts.order_by("-post_id").values_list("id", flat=True)[
:PRUNE_KEEP
]
)
follow.posts.exclude(id__in=keep).delete()
def sync_tag_follow(follow: FollowedTag, fetch_user=None, limit=FEED_LIMIT, posts=None):
"""Fetch new posts for one followed tag; updates cover and sync time."""
if posts is None:
posts = search_posts(fetch_user, follow.tag, limit)
added = _store_posts(follow, posts, tag=follow)
updates = ["last_synced_at"]
cover = cover_payload(posts[0]) if posts else None
if cover is not None:
follow.cover_data = cover
updates.append("cover_data")
follow.last_synced_at = timezone.now()
follow.save(update_fields=updates)
return added
def sync_pool_follow(
follow: FollowedPool,
fetch_user=None,
limit=FEED_LIMIT,
posts=None,
refresh_meta=True,
):
"""Fetch new posts for one followed pool; updates cover and metadata."""
if posts is None:
posts = search_posts(fetch_user, f"pool:{follow.pool_id}", limit)
added = _store_posts(follow, posts, pool=follow)
updates = ["last_synced_at"]
cover = cover_payload(posts[0]) if posts else None
if cover is not None:
follow.cover_data = cover
updates.append("cover_data")
if refresh_meta or not follow.name:
try:
pool = e621.get(
fetch_user,
f"/pools/{follow.pool_id}.json",
require_auth=False,
)
except e621.E621Error as exc:
logger.warning("Could not refresh pool %s: %s", follow.pool_id, exc)
else:
if isinstance(pool, dict) and pool.get("id"):
follow.name = str(pool.get("name") or "")[:200]
follow.post_count = int(pool.get("post_count") or 0)
updates += ["name", "post_count"]
follow.last_synced_at = timezone.now()
follow.save(update_fields=updates)
return added