Duplicates, delete & storage, users page with J-ID avatars
Backend: - Perceptual hashes (aHash/dHash/pHash/wHash via imagehash, no imgdd) stored on items, computed on upload/download and by the new compute_visual_hashes command - Duplicates API: exact duplicates (multi-location items), visual matches for one item, union-find similarity groups with pagination - Delete API with ownership/staff checks, per-item and per-copy deletion, watched-folder path validation; storage overview and temp cleanup; file list accepts j_ids batches - Staged uploads are flagged visual_match with their library matches (threshold via VISUAL_MATCH_THRESHOLD) - Staff users API: list with upload counts, set role and avatar by J-ID; User.avatar FK with signed avatar URLs - Download threads close their DB connection and stale tasks are reaped, keeping behaviour Gunicorn-friendly Frontend: - /duplicates: exact duplicate groups with per-copy delete, visual similarity controls, search similar to a J-ID, paginated groups with selection, bulk delete and dismiss - /delete: storage cards, delete by J-ID with preview grid, temp cleanup - /users: staff directory with role selects and avatar J-ID inputs - Nav + command palette entries; top-bar avatar; upload cards and the metadata modal show library visual matches
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import UserViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("users", UserViewSet, basename="user")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-17 17:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_user_role'),
|
||||
('library', '0006_mediaitem_ahash_mediaitem_dhash_mediaitem_phash_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='avatar',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='library.mediaitem'),
|
||||
),
|
||||
]
|
||||
@@ -15,6 +15,13 @@ class User(AbstractUser):
|
||||
]
|
||||
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default=ROLE_USER)
|
||||
avatar = models.ForeignKey(
|
||||
"library.MediaItem",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
)
|
||||
e621_username = models.CharField(max_length=100, blank=True, default="")
|
||||
e621_api_key = models.CharField(max_length=100, blank=True, default="")
|
||||
e621_base_url = models.CharField(max_length=200, default="https://e621.net")
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core import signing
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.library.services import MEDIA_FILE_SALT, VIDEO_EXTENSIONS
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
def signed_media_url(request, item):
|
||||
"""A signed media URL an <img> tag can load for an authenticated user."""
|
||||
if item is None:
|
||||
return None
|
||||
user = getattr(request, "user", None)
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
location = item.locations.first()
|
||||
is_video = (
|
||||
location is not None
|
||||
and Path(location.rel_path).suffix.lower() in VIDEO_EXTENSIONS
|
||||
)
|
||||
action = "thumbnail" if is_video else "raw"
|
||||
signature = signing.dumps(
|
||||
{"item": item.id, "user": user.id, "action": action},
|
||||
salt=MEDIA_FILE_SALT,
|
||||
)
|
||||
return f"/api/files/J-{item.id}/{action}/?sig={signature}"
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
e621_configured = serializers.BooleanField(read_only=True)
|
||||
can_upload = serializers.BooleanField(read_only=True)
|
||||
avatar_j_id = serializers.SerializerMethodField()
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -19,11 +46,54 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"date_joined",
|
||||
"role",
|
||||
"can_upload",
|
||||
"avatar_j_id",
|
||||
"avatar_url",
|
||||
"e621_username",
|
||||
"e621_configured",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_avatar_j_id(self, obj):
|
||||
return f"J-{obj.avatar_id}" if obj.avatar_id else None
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
return signed_media_url(self.context.get("request"), obj.avatar)
|
||||
|
||||
|
||||
class UserListSerializer(serializers.ModelSerializer):
|
||||
avatar_j_id = serializers.SerializerMethodField()
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
uploads_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"role",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"date_joined",
|
||||
"avatar_j_id",
|
||||
"avatar_url",
|
||||
"uploads_count",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_avatar_j_id(self, obj):
|
||||
return f"J-{obj.avatar_id}" if obj.avatar_id else None
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
return signed_media_url(self.context.get("request"), obj.avatar)
|
||||
|
||||
|
||||
class UserUpdateSerializer(serializers.Serializer):
|
||||
avatar_j_id = serializers.CharField(
|
||||
required=False, allow_blank=True, allow_null=True
|
||||
)
|
||||
role = serializers.ChoiceField(choices=User.ROLE_CHOICES, required=False)
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, validators=[validate_password])
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
from rest_framework import status
|
||||
from rest_framework import mixins, permissions, status, viewsets
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.db.models import Count, Q
|
||||
|
||||
from apps.library.models import MediaItem
|
||||
|
||||
from .models import User
|
||||
from .serializers import (
|
||||
E621CredentialsSerializer,
|
||||
RegisterSerializer,
|
||||
UserListSerializer,
|
||||
UserSerializer,
|
||||
UserUpdateSerializer,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +26,10 @@ class RegisterView(APIView):
|
||||
user = serializer.save()
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
return Response(
|
||||
{"token": token.key, "user": UserSerializer(user).data},
|
||||
{
|
||||
"token": token.key,
|
||||
"user": UserSerializer(user, context={"request": request}).data,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@@ -29,7 +38,7 @@ class MeView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response(UserSerializer(request.user).data)
|
||||
return Response(UserSerializer(request.user, context={"request": request}).data)
|
||||
|
||||
|
||||
class LogoutView(APIView):
|
||||
@@ -71,3 +80,81 @@ class E621CredentialsView(APIView):
|
||||
update_fields=["e621_username", "e621_api_key", "e621_base_url"]
|
||||
)
|
||||
return Response(self._payload(user))
|
||||
|
||||
|
||||
class IsStaffUser(permissions.BasePermission):
|
||||
message = "Staff only."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
return bool(
|
||||
user
|
||||
and user.is_authenticated
|
||||
and (
|
||||
user.is_superuser
|
||||
or user.is_staff
|
||||
or user.role == user.ROLE_STAFF
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Staff user directory: roles and J-ID avatars."""
|
||||
|
||||
permission_classes = [IsStaffUser]
|
||||
http_method_names = ["get", "patch", "head", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = User.objects.annotate(uploads_count=Count("uploads")).order_by("id")
|
||||
search = self.request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search) | Q(email__icontains=search)
|
||||
)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method in {"PATCH", "PUT"}:
|
||||
return UserUpdateSerializer
|
||||
return UserListSerializer
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
user = self.get_object()
|
||||
serializer = UserUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
update_fields = []
|
||||
if "role" in data:
|
||||
user.role = data["role"]
|
||||
update_fields.append("role")
|
||||
if "avatar_j_id" in data:
|
||||
value = str(data.get("avatar_j_id") or "").strip()
|
||||
if not value:
|
||||
user.avatar = None
|
||||
else:
|
||||
numeric = value[2:] if value.upper().startswith("J-") else value
|
||||
item = (
|
||||
MediaItem.objects.filter(pk=int(numeric)).first()
|
||||
if numeric.isdigit()
|
||||
else None
|
||||
)
|
||||
if item is None:
|
||||
return Response(
|
||||
{"detail": f"No library item {value}."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
user.avatar = item
|
||||
update_fields.append("avatar")
|
||||
if update_fields:
|
||||
user.save(update_fields=update_fields)
|
||||
|
||||
return Response(
|
||||
UserListSerializer(user, context=self.get_serializer_context()).data
|
||||
)
|
||||
|
||||
partial_update = update
|
||||
|
||||
Reference in New Issue
Block a user