Adds a preferences JSON field on the user plus GET/POST /api/auth/preferences/ (merge semantics, validated keys), surfaced in /auth/me/ and typed on the frontend. The Account page gains a Browsing preferences card: landing page, default rating filter, default sort, items per page and thumbnail size. Signed-in users also sync these while browsing (the Library sidebar's rating/sort/per-page controls and the new thumbnail slider), debounced; on load the account's values seed the local UI state, so settings follow the user across browsers. Guests keep the existing localStorage behaviour. The thumbnail size drives the media grids (Library, Online, pool detail) between 140 and 320px columns. Verified the API against the dev server: merge keeps untouched keys, invalid values 400, values round-trip through /auth/me/.
210 lines
6.4 KiB
Python
210 lines
6.4 KiB
Python
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,
|
|
PreferencesSerializer,
|
|
RegisterSerializer,
|
|
UserListSerializer,
|
|
UserSerializer,
|
|
UserUpdateSerializer,
|
|
)
|
|
|
|
|
|
class RegisterView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
def post(self, request):
|
|
serializer = RegisterSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
user = serializer.save()
|
|
token, _ = Token.objects.get_or_create(user=user)
|
|
return Response(
|
|
{
|
|
"token": token.key,
|
|
"user": UserSerializer(user, context={"request": request}).data,
|
|
},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
|
|
class MeView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
return Response(UserSerializer(request.user, context={"request": request}).data)
|
|
|
|
|
|
class LogoutView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request):
|
|
if request.auth:
|
|
request.auth.delete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
class E621CredentialsView(APIView):
|
|
"""Store the e621 credentials used by the user's browser to call e621 directly."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def _payload(self, user):
|
|
return {
|
|
"username": user.e621_username,
|
|
"api_key": user.e621_api_key,
|
|
"base_url": user.e621_base_url,
|
|
"configured": user.e621_configured,
|
|
}
|
|
|
|
def get(self, request):
|
|
return Response(self._payload(request.user))
|
|
|
|
def put(self, request):
|
|
serializer = E621CredentialsSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
data = serializer.validated_data
|
|
user = request.user
|
|
user.e621_username = data["username"].strip()
|
|
user.e621_api_key = data["api_key"].strip()
|
|
user.e621_base_url = (
|
|
(data.get("base_url") or "https://e621.net").strip().rstrip("/")
|
|
)
|
|
user.save(
|
|
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
|
|
)
|
|
)
|
|
|
|
|
|
def resolve_avatar_item(value):
|
|
"""Turn a "J-42" / "42" string into a MediaItem.
|
|
|
|
Returns ``(item, error)``: an empty value clears the avatar (``None``,
|
|
``None``), an unknown item returns an error message.
|
|
"""
|
|
value = str(value or "").strip()
|
|
if not value:
|
|
return None, None
|
|
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 None, f"No library item {value}."
|
|
return item, None
|
|
|
|
|
|
class AvatarView(APIView):
|
|
"""Self-service profile picture chosen from the library."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request):
|
|
serializer = UserUpdateSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
item, error = resolve_avatar_item(
|
|
serializer.validated_data.get("avatar_j_id")
|
|
)
|
|
if error:
|
|
return Response({"detail": error}, status=status.HTTP_400_BAD_REQUEST)
|
|
user = request.user
|
|
user.avatar = item
|
|
user.save(update_fields=["avatar"])
|
|
return Response(UserSerializer(user, context={"request": request}).data)
|
|
|
|
|
|
class PreferencesView(APIView):
|
|
"""Per-user browse preferences, merged so callers can send single keys."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
return Response(request.user.preferences or {})
|
|
|
|
def post(self, request):
|
|
serializer = PreferencesSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
preferences = dict(request.user.preferences or {})
|
|
preferences.update(serializer.validated_data)
|
|
request.user.preferences = preferences
|
|
request.user.save(update_fields=["preferences"])
|
|
return Response(preferences)
|
|
|
|
|
|
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:
|
|
item, error = resolve_avatar_item(data.get("avatar_j_id"))
|
|
if error:
|
|
return Response(
|
|
{"detail": error},
|
|
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
|