from django.contrib import auth from django.db import IntegrityError from django.urls import path, include from rest_framework import routers, serializers, viewsets from rest_framework.authentication import TokenAuthentication from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import IsAuthenticated, IsAdminUser from rest_framework.authtoken.models import Token from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.response import Response from authentication.models import ToolshedUser, AccountPreference from authentication.signature_auth import SignatureAuthenticationLocal from files.models import File from files.serializers import FileSerializer from hostadmin.models import Domain router = routers.SimpleRouter() # Schema for the account-level preferences a client may store on the server (see # AccountPreference). Device-level preferences are never sent here - they stay in the # browser's local storage since they describe the device, not the account. PREFERENCE_DEFINITIONS = [ { 'key': 'ui.compact_mode', 'type': 'boolean', 'default': False, 'label': 'Compact mode', 'description': 'Show denser item rows and reduce spacing in lists.', }, { 'key': 'ui.default_search_scope', 'type': 'enum', 'options': ['inventory', 'friends', 'all'], 'default': 'inventory', 'label': 'Default search scope', 'description': 'Choose where the global search starts.', }, { 'key': 'notifications.desktop_enabled', 'type': 'boolean', 'default': True, 'label': 'Desktop notifications', 'description': 'Enable in-browser notifications for important updates.', }, { 'key': 'files.max_upload_mb', 'type': 'integer', 'default': 25, 'label': 'Default upload size limit (MB)', 'description': 'Used as a prefill hint in upload dialogs.', }, { 'key': 'ui.experimental_flags', 'type': 'json', 'default': {}, 'label': 'Experimental flags', 'description': 'Optional JSON toggles for feature previews.', }, ] class UserAuthToken(ObtainAuthToken): def post(self, request, *args, **kwargs): try: fullname = request.data.get('username') username = fullname.split('@')[0] domain = fullname.split('@')[1] password = request.data.get('password') user = auth.authenticate(username=username, password=password, domain=domain) token, created = Token.objects.get_or_create(user=user) return Response({ 'token': token.key, 'key': user.private_key }) except IndexError: return Response({ 'error': 'Invalid Credentials' }, status=400) except IntegrityError: return Response({ 'error': 'Invalid Credentials' }, status=400) class UserSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = ToolshedUser fields = ['username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined'] class UserViewSet(viewsets.ModelViewSet): queryset = ToolshedUser.objects.all() serializer_class = UserSerializer authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated, IsAdminUser] @api_view(['GET', 'PATCH']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) def getUserInfo(request): user = request.user if request.method == 'PATCH': old_file = user.profile_picture if 'profile_picture' in request.data: profile_picture = request.data.get('profile_picture') if profile_picture is None: user.profile_picture = None elif type(profile_picture) == dict: serializer = FileSerializer(data=profile_picture) if not serializer.is_valid(): return Response(serializer.errors, status=400) user.profile_picture = serializer.save() else: return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400) elif 'profile_picture_id' in request.data: profile_picture_id = request.data.get('profile_picture_id') if profile_picture_id is None: user.profile_picture = None else: try: user.profile_picture = File.objects.get(id=profile_picture_id) except File.DoesNotExist: return Response({'profile_picture_id': 'File does not exist.'}, status=400) user.save() if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0: old_file.delete() return Response({ 'username': user.username, 'domain': user.domain, 'email': user.email, 'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None, }) @api_view(['POST']) @permission_classes([]) @authentication_classes([]) def registerUser(request): try: username = request.data.get('username') domain = request.data.get('domain') password = request.data.get('password') email = request.data.get('email') errors = {} if not username: errors['username'] = 'Username is required' if not domain: errors['domain'] = 'Domain is required' if not password: errors['password'] = 'Password is required' if not email: errors['email'] = 'Email is required' if ToolshedUser.objects.filter(email=email).exists(): errors['email'] = 'Email already exists' if ToolshedUser.objects.filter(username=username, domain=domain).exists(): errors['username'] = 'Username already exists' if errors: return Response({'errors': errors}, status=400) Domain.objects.get(name=domain, open_registration=True) user = ToolshedUser.objects.create_user(username, email, password, domain=domain) return Response({'username': user.username, 'domain': user.domain}) except Domain.DoesNotExist: return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400) @api_view(['GET']) @permission_classes([]) @authentication_classes([]) def preference_definitions(request): """Return the schema (types, defaults, labels) for the account preferences clients may set.""" return Response(PREFERENCE_DEFINITIONS) @api_view(['GET', 'PUT']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) def account_preferences(request): """Get or bulk-upsert the authenticated user's account-level preferences. GET returns the current preferences as a {key: value} dict. PUT accepts a {key: value} dict of one or more preferences to set/overwrite; unspecified keys are left untouched. """ if request.method == 'PUT': if not isinstance(request.data, dict): return Response({'detail': 'Expected an object of key/value pairs.'}, status=400) for key, value in request.data.items(): AccountPreference.objects.update_or_create(user=request.user, key=key, defaults={'value': value}) preferences = {pref.key: pref.value for pref in request.user.preferences.all()} return Response(preferences) @api_view(['DELETE']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) def account_preference_detail(request, key): """Reset a single account-level preference back to its default by deleting it.""" AccountPreference.objects.filter(user=request.user, key=key).delete() return Response(status=204) router.register(r'users', UserViewSet) urlpatterns = [ path('', include(router.urls)), path('user/', getUserInfo), path('register/', registerUser), path('token/', UserAuthToken.as_view()), path('preferences/', preference_definitions), path('self/preferences/', account_preferences), path('self/preferences//', account_preference_detail), ]