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.
195 lines
6.1 KiB
Python
195 lines
6.1 KiB
Python
from rest_framework import mixins, 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.core.permissions import IsAppStaff
|
|
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))
|
|
|
|
|
|
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 = [IsAppStaff]
|
|
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
|