diff --git a/api/serializers.py b/api/serializers.py index b4de53c..e6f1612 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,8 +1,6 @@ from rest_framework import serializers from tournaments.models.court import Court from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer -from django.contrib.auth import password_validation -from django.utils.translation import gettext_lazy as _ from django.db.utils import IntegrityError from django.conf import settings @@ -14,7 +12,6 @@ from django.core.mail import EmailMessage from django.contrib.sites.shortcuts import get_current_site from api.tokens import account_activation_token -# from tournaments.models.data_access import DataAccess from shared.cryptography import encryption_util from tournaments.models.draw_log import DrawLog @@ -146,7 +143,7 @@ class RoundSerializer(serializers.ModelSerializer): class GroupStageSerializer(serializers.ModelSerializer): class Meta: model = GroupStage - fields = '__all__' # ['id', 'index', 'tournament_id', 'format'] + fields = '__all__' class MatchSerializer(serializers.ModelSerializer): class Meta: @@ -156,7 +153,7 @@ class MatchSerializer(serializers.ModelSerializer): class TeamScoreSerializer(serializers.ModelSerializer): class Meta: model = TeamScore - fields = '__all__' # ['id', 'match_id', 'score', 'walk_out', 'lucky_loser', 'player_registrations'] + fields = '__all__' class TeamRegistrationSerializer(serializers.ModelSerializer): class Meta: @@ -180,31 +177,6 @@ class PurchaseSerializer(serializers.ModelSerializer): validated_data['user'] = user return super().create(validated_data) -class ChangePasswordSerializer(serializers.Serializer): - old_password = serializers.CharField(max_length=128, write_only=True, required=True) - new_password1 = serializers.CharField(max_length=128, write_only=True, required=True) - new_password2 = serializers.CharField(max_length=128, write_only=True, required=True) - - def validate_old_password(self, value): - user = self.context['request'].user - if not user.check_password(value): - raise serializers.ValidationError( - _('Your old password was entered incorrectly. Please enter it again.') - ) - return value - - def validate(self, data): - if data['new_password1'] != data['new_password2']: - raise serializers.ValidationError({'new_password2': _("The two password fields didn't match.")}) - password_validation.validate_password(data['new_password1'], self.context['request'].user) - return data - - def save(self, **kwargs): - password = self.validated_data['new_password1'] - user = self.context['request'].user - user.set_password(password) - user.save() - return user class LiveMatchSerializer(serializers.ModelSerializer): class Meta: @@ -237,7 +209,6 @@ class DeviceTokenSerializer(serializers.ModelSerializer): fields = '__all__' read_only_fields = ['user'] - class DrawLogSerializer(serializers.ModelSerializer): class Meta: model = DrawLog diff --git a/api/urls.py b/api/urls.py index a0fd475..ae8fb58 100644 --- a/api/urls.py +++ b/api/urls.py @@ -4,6 +4,7 @@ from rest_framework.authtoken.views import obtain_auth_token from . import views from sync.views import SynchronizationApi, UserDataAccessApi, DataAccessViewSet +from authentication.views import CustomAuthToken, Logout, ChangePasswordView router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) @@ -36,10 +37,11 @@ urlpatterns = [ path('api-token-auth/', obtain_auth_token, name='api_token_auth'), path("user-by-token/", views.user_by_token, name="user_by_token"), - path("change-password/", views.ChangePasswordView.as_view(), name="change_password"), - path('token-auth/', views.CustomAuthToken.as_view()), - path('api-token-logout/', views.Logout.as_view()), + # authentication + path("change-password/", ChangePasswordView.as_view(), name="change_password"), + path('token-auth/', CustomAuthToken.as_view()), + path('api-token-logout/', Logout.as_view()), # forgotten password path('dj-rest-auth/', include('dj_rest_auth.urls')), diff --git a/api/utils.py b/api/utils.py index 71f145f..825a06f 100644 --- a/api/utils.py +++ b/api/utils.py @@ -1,9 +1,3 @@ -import re - -def is_valid_email(email): - email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' - return re.match(email_regex, email) is not None - def check_version_smaller_than_1_1_12(version_str): # Remove the parentheses part if it exists, example of version: 1.1.12 (2) version_str = version_str.split()[0] diff --git a/api/views.py b/api/views.py index 2fb7628..bfbc0c3 100644 --- a/api/views.py +++ b/api/views.py @@ -1,102 +1,20 @@ -from pandas.io.feather_format import pd -from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer +from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer from rest_framework import viewsets, permissions -from rest_framework.authtoken.models import Token from rest_framework.response import Response from rest_framework.decorators import api_view from rest_framework import status -from rest_framework.generics import UpdateAPIView from rest_framework.exceptions import MethodNotAllowed -from rest_framework.permissions import IsAuthenticated -from rest_framework.views import APIView from django.http import Http404 -from django.contrib.auth import authenticate from django.db.models import Q -from django.core.exceptions import ObjectDoesNotExist -from django.utils import timezone -from django.apps import apps -from django.views.decorators.csrf import csrf_exempt -from django.utils.decorators import method_decorator - -from collections import defaultdict from .permissions import IsClubOwner -from .utils import is_valid_email, check_version_smaller_than_1_1_12 - -from sync.models import Device +from .utils import check_version_smaller_than_1_1_12 from shared.discord import send_discord_log_message -@method_decorator(csrf_exempt, name='dispatch') -class CustomAuthToken(APIView): - permission_classes = [] - - def post(self, request, *args, **kwargs): - username = request.data.get('username') - password = request.data.get('password') - device_id = request.data.get('device_id') - - user = authenticate(username=username, password=password) - - if user is None and is_valid_email(username) == True: - true_username = self.get_username_from_email(username) - user = authenticate(username=true_username, password=password) - - if user: - # user.device_id = device_id - # user.save() - # self.create_or_update_device(user, device_id) - - # token, created = Token.objects.get_or_create(user=user) - # return Response({'token': token.key}) - - if user.device_id is None or user.device_id == device_id or user.username == 'apple-test': - user.device_id = device_id - user.save() - - self.create_or_update_device(user, device_id) - - token, created = Token.objects.get_or_create(user=user) - return Response({'token': token.key}) - else: - return Response({'error': 'Vous ne pouvez pour l\'instant vous connecter sur plusieurs appareils en même temps. Veuillez vous déconnecter du précédent appareil. Autrement, veuillez contacter le support.'}, status=status.HTTP_403_FORBIDDEN) - - else: - return Response({'error': 'L\'utilisateur et le mot de passe de correspondent pas'}, status=status.HTTP_401_UNAUTHORIZED) - - def create_or_update_device(self, user, device_id): - Device.objects.update_or_create( - id=device_id, - defaults={ - 'user': user - } - ) - - def get_username_from_email(self, email): - try: - user = CustomUser.objects.get(email=email) - return user.username - except ObjectDoesNotExist: - return None - -class Logout(APIView): - permission_classes = (IsAuthenticated,) - - def post(self, request, *args, **kwargs): - # request.user.auth_token.delete() - - device_id = request.data.get('device_id') - if request.user.device_id == device_id: - request.user.device_id = None - request.user.save() - - Device.objects.filter(id=device_id).delete() - - return Response(status=status.HTTP_200_OK) - @api_view(['GET']) def user_by_token(request): serializer = UserSerializer(request.user) @@ -174,20 +92,6 @@ class PurchaseViewSet(SoftDeleteViewSet): def delete(self, request, pk): raise MethodNotAllowed('DELETE') -class ChangePasswordView(UpdateAPIView): - serializer_class = ChangePasswordSerializer - - def update(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.save() - # if using drf authtoken, create a new token - if hasattr(user, 'auth_token'): - user.auth_token.delete() - token, created = Token.objects.get_or_create(user=user) - # return new token - return Response({'token': token.key}, status=status.HTTP_200_OK) - class EventViewSet(SoftDeleteViewSet): queryset = Event.objects.all() serializer_class = EventSerializer diff --git a/authentication/__init__.py b/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/admin.py b/authentication/admin.py new file mode 100644 index 0000000..39e13ae --- /dev/null +++ b/authentication/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from .models import Device, LoginLog + +class DeviceAdmin(admin.ModelAdmin): + list_display = ['user', 'device_model', 'last_login', 'id'] + readonly_fields = ('last_login',) + ordering = ['-last_login'] + +class LoginLogAdmin(admin.ModelAdmin): + list_display = ['user', 'device', 'date'] + ordering = ['-date'] + +admin.site.register(Device, DeviceAdmin) +admin.site.register(LoginLog, LoginLogAdmin) diff --git a/authentication/apps.py b/authentication/apps.py new file mode 100644 index 0000000..8bab8df --- /dev/null +++ b/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'authentication' diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..7b2b881 --- /dev/null +++ b/authentication/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1 on 2025-03-20 14:49 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('last_login', models.DateTimeField(auto_now=True)), + ('model_name', models.CharField(blank=True, max_length=100, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LoginLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('date', models.DateTimeField(auto_now=True)), + ('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='login_logs', to='authentication.device')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_logs', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/authentication/migrations/0002_rename_model_name_device_device_model.py b/authentication/migrations/0002_rename_model_name_device_device_model.py new file mode 100644 index 0000000..4fc4419 --- /dev/null +++ b/authentication/migrations/0002_rename_model_name_device_device_model.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2025-03-20 15:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='device', + old_name='model_name', + new_name='device_model', + ), + ] diff --git a/authentication/migrations/__init__.py b/authentication/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/models/__init__.py b/authentication/models/__init__.py new file mode 100644 index 0000000..10a01da --- /dev/null +++ b/authentication/models/__init__.py @@ -0,0 +1,2 @@ +from .device import Device +from .login_log import LoginLog diff --git a/sync/models/device.py b/authentication/models/device.py similarity index 72% rename from sync/models/device.py rename to authentication/models/device.py index fa0bf0d..d1f27a6 100644 --- a/sync/models/device.py +++ b/authentication/models/device.py @@ -6,6 +6,7 @@ class Device(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='devices') last_login = models.DateTimeField(auto_now=True) + device_model = models.CharField(max_length=100, blank=True, null=True) def __str__(self): - return f"{self.id} > {self.user.username}" + return f"{self.user.username} : {self.device_model}" diff --git a/authentication/models/login_log.py b/authentication/models/login_log.py new file mode 100644 index 0000000..ad8bf72 --- /dev/null +++ b/authentication/models/login_log.py @@ -0,0 +1,13 @@ +from django.db import models +import uuid +from django.conf import settings +from . import Device + +class LoginLog(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='login_logs') + device = models.ForeignKey(Device, on_delete=models.SET_NULL, related_name='login_logs', null=True) + date = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.id} > {self.user.username}" diff --git a/authentication/serializers.py b/authentication/serializers.py new file mode 100644 index 0000000..9ef71c5 --- /dev/null +++ b/authentication/serializers.py @@ -0,0 +1,29 @@ +from django.contrib.auth import password_validation + +from rest_framework import serializers + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(max_length=128, write_only=True, required=True) + new_password1 = serializers.CharField(max_length=128, write_only=True, required=True) + new_password2 = serializers.CharField(max_length=128, write_only=True, required=True) + + def validate_old_password(self, value): + user = self.context['request'].user + if not user.check_password(value): + raise serializers.ValidationError( + _('Your old password was entered incorrectly. Please enter it again.') + ) + return value + + def validate(self, data): + if data['new_password1'] != data['new_password2']: + raise serializers.ValidationError({'new_password2': _("The two password fields didn't match.")}) + password_validation.validate_password(data['new_password1'], self.context['request'].user) + return data + + def save(self, **kwargs): + password = self.validated_data['new_password1'] + user = self.context['request'].user + user.set_password(password) + user.save() + return user diff --git a/authentication/tests.py b/authentication/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/authentication/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/authentication/utils.py b/authentication/utils.py new file mode 100644 index 0000000..bbe5dc2 --- /dev/null +++ b/authentication/utils.py @@ -0,0 +1,5 @@ +import re + +def is_valid_email(email): + email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' + return re.match(email_regex, email) is not None diff --git a/authentication/views.py b/authentication/views.py new file mode 100644 index 0000000..130fa61 --- /dev/null +++ b/authentication/views.py @@ -0,0 +1,109 @@ +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth import authenticate +from django.utils.decorators import method_decorator +from django.core.exceptions import ObjectDoesNotExist +from django.conf import settings + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.authtoken.models import Token +from rest_framework import status +from rest_framework.generics import UpdateAPIView + +from .utils import is_valid_email +from .models import Device, LoginLog + +from .serializers import ChangePasswordSerializer + +CustomUser=settings.AUTH_USER_MODEL + +@method_decorator(csrf_exempt, name='dispatch') +class CustomAuthToken(APIView): + permission_classes = [] + + def post(self, request, *args, **kwargs): + username = request.data.get('username') + password = request.data.get('password') + device_id = request.data.get('device_id') + + user = authenticate(username=username, password=password) + + if user is None and is_valid_email(username) == True: + true_username = self.get_username_from_email(username) + user = authenticate(username=true_username, password=password) + + if user: + # user.device_id = device_id + # user.save() + # self.create_or_update_device(user, device_id) + + # token, created = Token.objects.get_or_create(user=user) + # return Response({'token': token.key}) + + if user.device_id is None or user.device_id == device_id or user.username == 'apple-test': + user.device_id = device_id + user.save() + + device_model = request.data.get('device_model') + + device = self.create_or_update_device(user, device_id, device_model) + self.create_login_log(user, device) + + token, created = Token.objects.get_or_create(user=user) + return Response({'token': token.key}) + else: + return Response({'error': 'Vous ne pouvez pour l\'instant vous connecter sur plusieurs appareils en même temps. Veuillez vous déconnecter du précédent appareil. Autrement, veuillez contacter le support.'}, status=status.HTTP_403_FORBIDDEN) + + else: + return Response({'error': 'L\'utilisateur et le mot de passe de correspondent pas'}, status=status.HTTP_401_UNAUTHORIZED) + + def create_or_update_device(self, user, device_id, device_model): + obj, created = Device.objects.update_or_create( + id=device_id, + device_model=device_model, + defaults={ + 'user': user + } + ) + return obj + + def create_login_log(self, user, device): + LoginLog.objects.create(user=user, device=device) + + def get_username_from_email(self, email): + try: + user = CustomUser.objects.get(email=email) + return user.username + except ObjectDoesNotExist: + return None + +class Logout(APIView): + permission_classes = (IsAuthenticated,) + + def post(self, request, *args, **kwargs): + # request.user.auth_token.delete() + + device_id = request.data.get('device_id') + if request.user.device_id == device_id: + request.user.device_id = None + request.user.save() + + Device.objects.filter(id=device_id).delete() + + return Response(status=status.HTTP_200_OK) + +class ChangePasswordView(UpdateAPIView): + serializer_class = ChangePasswordSerializer + + def update(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + # if using drf authtoken, create a new token + if hasattr(user, 'auth_token'): + user.auth_token.delete() + token, created = Token.objects.get_or_create(user=user) + # return new token + return Response({'token': token.key}, status=status.HTTP_200_OK) diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index 40ccc09..56de9cc 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -33,6 +33,7 @@ ALLOWED_HOSTS = ['*'] INSTALLED_APPS = [ 'daphne', + 'authentication', 'sync', 'tournaments', # 'crm', diff --git a/sync/admin.py b/sync/admin.py index 6b6f4c8..4b00649 100644 --- a/sync/admin.py +++ b/sync/admin.py @@ -1,8 +1,8 @@ from django.contrib import admin -from .models import BaseModel, ModelLog, DataAccess, Device - from django.utils import timezone +from .models import BaseModel, ModelLog, DataAccess + class SyncedObjectAdmin(admin.ModelAdmin): def save_model(self, request, obj, form, change): if isinstance(obj, BaseModel): @@ -25,10 +25,6 @@ class ModelLogAdmin(admin.ModelAdmin): ordering = ['-date'] search_fields = ['model_id'] -class DeviceAdmin(admin.ModelAdmin): - list_display = ['user', 'last_login', 'id'] - readonly_fields = ('last_login',) - class DataAccessAdmin(SyncedObjectAdmin): list_display = ['related_user', 'get_shared_users', 'model_name', 'model_id'] list_filter = ['related_user', 'shared_with'] @@ -41,4 +37,3 @@ class DataAccessAdmin(SyncedObjectAdmin): # Register your models here. admin.site.register(ModelLog, ModelLogAdmin) admin.site.register(DataAccess, DataAccessAdmin) -admin.site.register(Device, DeviceAdmin) diff --git a/sync/migrations/0004_delete_device.py b/sync/migrations/0004_delete_device.py new file mode 100644 index 0000000..1934534 --- /dev/null +++ b/sync/migrations/0004_delete_device.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1 on 2025-03-20 14:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0003_device'), + ] + + operations = [ + migrations.DeleteModel( + name='Device', + ), + ] diff --git a/sync/models/__init__.py b/sync/models/__init__.py index c09b42f..4b08618 100644 --- a/sync/models/__init__.py +++ b/sync/models/__init__.py @@ -1,4 +1,3 @@ from .base import BaseModel, SideStoreModel from .model_log import ModelLog, ModelOperation from .data_access import DataAccess -from .device import Device diff --git a/sync/signals.py b/sync/signals.py index 8cc2c4d..221c2ce 100644 --- a/sync/signals.py +++ b/sync/signals.py @@ -2,7 +2,9 @@ from django.db.models.signals import pre_save, post_save, pre_delete, post_delet from django.db import models, transaction from django.dispatch import receiver -from .models import DataAccess, ModelLog, ModelOperation, BaseModel, SideStoreModel, Device +from .models import DataAccess, ModelLog, ModelOperation, BaseModel, SideStoreModel +from authentication.models import Device + from django.contrib.auth import get_user_model from django.utils import timezone