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:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user