diff --git a/PrINTech-Back/back/apps/api/migrations/0001_initial.py b/PrINTech-Back/back/apps/api/migrations/0001_initial.py index 7145403..876b00d 100644 --- a/PrINTech-Back/back/apps/api/migrations/0001_initial.py +++ b/PrINTech-Back/back/apps/api/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 6.0.2 on 2026-02-17 23:53 +# Generated by Django 6.0.2 on 2026-02-23 19:59 import django.contrib.auth.models import django.contrib.auth.validators import django.db.models.deletion import django.utils.timezone +import uuid from django.conf import settings from django.db import migrations, models @@ -31,13 +32,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('type', models.CharField(choices=[('SLA/DLP/MSLA', 'Sla'), ('SLS/MJF', 'Sls'), ('FDM/FFF', 'Fdm'), ('MJP', 'Mjp'), ('Binder_Jetting', 'Binder Jetting'), ('DMLS/SLM', 'Dmls')], max_length=25)), - ('status', models.CharField(choices=[('UP', 'Up'), ('DOWN', 'Down'), ('USED', 'Used')], max_length=25)), + ('status', models.CharField(choices=[('UP', 'Up'), ('DOWN', 'Down'), ('USED', 'Used')], default='DOWN', max_length=25)), ], ), migrations.CreateModel( name='User', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), @@ -47,8 +47,10 @@ class Migration(migrations.Migration): ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('email', models.EmailField(max_length=254, unique=True)), - ('credit', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('credit', models.IntegerField(default=0)), + ('is_bot', models.BooleanField(default=False)), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], @@ -64,9 +66,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='File', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('path', models.FileField(upload_to='uploads/%Y/%m/%d')), - ('number_of_printing', models.IntegerField(default=1)), + ('number_of_printing', models.PositiveIntegerField(default=1)), ('para_slicer', models.JSONField(blank=True, null=True)), ('filament_id', models.ManyToManyField(to='api.filament')), ('user_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), @@ -75,21 +77,21 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Operation', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('datetime', models.DateTimeField(auto_now_add=True)), - ('operation_type', models.CharField(choices=[('WITHDRAW', 'Withdraw'), ('ADDING', 'Adding')], max_length=25)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('operation_type', models.CharField(choices=[('CASH', 'Cash'), ('CARD', 'Card')], max_length=25)), ('comment', models.TextField(blank=True, null=True)), - ('amount', models.PositiveIntegerField(default=0)), - ('signer_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operation_signer', to=settings.AUTH_USER_MODEL)), - ('user_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operation_receiver', to=settings.AUTH_USER_MODEL)), + ('amount', models.IntegerField(default=0)), + ('agent_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operation_agent', to=settings.AUTH_USER_MODEL)), + ('beneficiary_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operation_beneficiary', to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( name='Request', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('datetime', models.DateTimeField(auto_now_add=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('COMPLETED', 'Completed'), ('FAILED', 'Failed')], max_length=25)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('COMPLETED', 'Completed'), ('FAILED', 'Failed')], default='PENDING', max_length=25)), ('file_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.file')), ('printer_id', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.printer')), ], diff --git a/PrINTech-Back/back/apps/api/models.py b/PrINTech-Back/back/apps/api/models.py index 944db9e..679ac12 100644 --- a/PrINTech-Back/back/apps/api/models.py +++ b/PrINTech-Back/back/apps/api/models.py @@ -8,6 +8,7 @@ class User(AbstractUser): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) email = models.EmailField(blank=False, unique=True, null=False) credit = models.IntegerField(default=0) + is_bot = models.BooleanField(default=False) class Filament(models.Model): @@ -80,6 +81,7 @@ class Status(models.TextChoices): status = models.CharField(choices=Status.choices, max_length=25, null=False, blank=False, default=Status.PENDING) + class Operation(models.Model): class Type(models.TextChoices): diff --git a/PrINTech-Back/back/apps/api/serializers.py b/PrINTech-Back/back/apps/api/serializers.py index 51786a6..88e7fc3 100644 --- a/PrINTech-Back/back/apps/api/serializers.py +++ b/PrINTech-Back/back/apps/api/serializers.py @@ -1,9 +1,12 @@ from django.contrib.auth.password_validation import validate_password from rest_framework import serializers -from .models import User +from rest_framework.fields import UUIDField + +from .models import User, Request, File, Filament class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(required=True, write_only=True) new_password = serializers.CharField(required=True, write_only=True) confirm_password = serializers.CharField(required=True, write_only=True) @@ -49,3 +52,50 @@ class Meta: fields = ["id", "username", "password", "email"] extra_kwargs = {"password": {"write_only": True}} + + +class CreateFileSerializer(serializers.ModelSerializer): + filaments = serializers.PrimaryKeyRelatedField(many=True, queryset=Filament.objects.all()) + + class Meta: + model = File + fields = [ + "user_id", + "path", + "number_of_printing", + "filaments", + "para_slicer", + ] + extra_kwargs = {"user_id": {"read_only": True}} + + def validate(self, data): + user = self.context["request"].user + path = data["path"] + + if File.objects.filter(path=path, user_id=user).exists(): + raise serializers.ValidationError("File already exists") + + return data + + + + +class CreateRequestSerializer(serializers.ModelSerializer): + + class Meta: + model = Request + fields = ["file_id", "created_at"] + extra_kwargs = {"created_at": {"read_only": True}} + + def validate(self, data): + user = self.context["request"].user + file = data["file_id"] + if file.user_id != user.id: + raise serializers.ValidationError("File doesn't belong to this user") + return data + +class ChangeRequestSerializer(serializers.ModelSerializer): + class Meta: + model = Request + fields = ["printer_id", "status"] + extra_kwargs = {"status": {"read_only": True}} diff --git a/PrINTech-Back/back/apps/api/views.py b/PrINTech-Back/back/apps/api/views.py index d7fa2fc..951fd5d 100644 --- a/PrINTech-Back/back/apps/api/views.py +++ b/PrINTech-Back/back/apps/api/views.py @@ -1,11 +1,17 @@ +from django.db.models.signals import post_delete +from django.dispatch import receiver +from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework_simplejwt.tokens import RefreshToken, AccessToken -from .models import User -from rest_framework import permissions, viewsets, generics, status -from django.shortcuts import render -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication +from .models import User, Request, File, Printer +from rest_framework import viewsets, generics, status +from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser from rest_framework.throttling import UserRateThrottle -from .serializers import UserSerializer, ChangePasswordSerializer +from .serializers import UserSerializer, ChangePasswordSerializer, CreateRequestSerializer, ChangeRequestSerializer, \ + CreateFileSerializer +from django.db import transaction class CreateUserView(generics.CreateAPIView): queryset = User.objects.all() @@ -15,7 +21,8 @@ class CreateUserView(generics.CreateAPIView): def create(self, request, *args, **kwargs): if request.user.is_authenticated: - return Response({"Message": "Vous ne devez pas être authentifié."},status=status.HTTP_403_FORBIDDEN) + return Response({"Message": "You need to be disconnected"},status=status.HTTP_403_FORBIDDEN) + serializer = self.get_serializer( data=request.data, context={"request": request} ) @@ -53,6 +60,113 @@ def get_object(self): def update(self, request, *args, **kwargs): super().update(request, *args, **kwargs) return Response( - {"message": "Mot de passe changé avec succès."}, status=status.HTTP_200_OK + {"message": "Your password has been successfully modified."}, status=status.HTTP_200_OK ) + +class RequestViewSet(viewsets.ModelViewSet): + queryset = Request.objects.all() + authentication_classes = [TokenAuthentication, JWTAuthentication] + throttle_classes = [UserRateThrottle] + + def get_permissions(self): + if self.action == 'destroy': + return [IsAdminUser()] + return [IsAuthenticated()] + + def get_queryset(self): + user = self.request.user + if user.is_staff: + return Request.objects.all() + return Request.objects.filter(file_id__user_id=user) + + def get_serializer_class(self): + if self.action in ['update', 'partial_update', 'completed']: + return ChangeRequestSerializer + return CreateRequestSerializer + + + def _assign_next_pending_request(self, printer): + with transaction.atomic(): + next_request = Request.objects.filter( + status=Request.Status.PENDING + ).order_by('created_at').select_for_update(skip_locked=True).first() + + if next_request: + next_request.printer_id = printer + next_request.status = Request.Status.PROCESSING + next_request.save() + + printer.status = Printer.Status.USED + printer.save() + return True + return False + + + def perform_create(self, serializer): + new_request = serializer.save() + with transaction.atomic(): + free_printer = Printer.objects.filter(status=Printer.Status.UP).first() + if free_printer: + self._assign_next_pending_request(free_printer) + + + + @action(detail=True, methods=['post'], permission_classes=[IsAdminUser]) + def completed(self, request, pk=None): + current_request = self.get_object() + printer = current_request.printer_id + + if not request.user.is_bot : + return Response({"error": "you are not a bot"}, status=status.HTTP_400_BAD_REQUEST) + + with transaction.atomic(): + current_request.status = Request.Status.COMPLETED + current_request.save() + + if printer: + printer.status = Printer.Status.UP + printer.save() + + if printer: + assigned = self._assign_next_pending_request(printer) + if assigned: + return Response({'status': 'completed and new request assigned'}) + + return Response({'status': 'completed, printer is now idle'}) + +class CreateFileView(generics.CreateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = CreateFileSerializer + throttle_classes = [UserRateThrottle] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class FileViewSet(viewsets.ModelViewSet): + serializer_class = CreateFileSerializer + permission_classes = [IsAuthenticated] + throttle_classes = [UserRateThrottle] + + def get_queryset(self): + return File.objects.filter(user_id=self.request.user) + + @receiver(post_delete, sender=File) + def submission_delete(sender, instance, **kwargs): + instance.path.delete(False) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + has_active_print = instance.requests.filter( + status__in=[Request.Status.PROCESSING, Request.Status.PENDING] + ).exists() + + if has_active_print: + return Response( + {"error": "Impossible to delete this file, a request is being processed"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().destroy(request, *args, **kwargs) + diff --git a/PrINTech-Back/back/settings/base.py b/PrINTech-Back/back/settings/base.py index f58712a..66d88bd 100644 --- a/PrINTech-Back/back/settings/base.py +++ b/PrINTech-Back/back/settings/base.py @@ -108,6 +108,7 @@ 'rest_framework', 'drf_spectacular', 'rest_framework_simplejwt', + 'rest_framework.authtoken', ] LOCAL_APPS = [ @@ -124,6 +125,11 @@ 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', ) } diff --git a/PrINTech-Back/back/urls.py b/PrINTech-Back/back/urls.py index 62e7194..00ad222 100644 --- a/PrINTech-Back/back/urls.py +++ b/PrINTech-Back/back/urls.py @@ -4,21 +4,23 @@ from rest_framework import routers, permissions from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView -from .apps.api.views import CreateUserView, UserMeView, ChangePasswordView +from .apps.api.views import CreateUserView, UserMeView, ChangePasswordView, \ + RequestViewSet, CreateFileView, FileViewSet from .views import home admin.autodiscover() router = routers.DefaultRouter() +router.register(r'requests', RequestViewSet, basename='request') +router.register(r'files', FileViewSet, basename='file') urlpatterns = [ - # Examples: path('', home, name='home'), path('api/schema/', SpectacularAPIView.as_view(), name='schema'), - # path('app/', include('apps.app.urls')), - path("api/v1/", include(router.urls)), + path("api/v1/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/v1/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("api/v1/user/", CreateUserView.as_view(), name="create_user"), path("api/v1/user/me/", UserMeView.as_view(), name="user_info"), path( @@ -26,9 +28,13 @@ ChangePasswordView.as_view(), name="change-password", ), + + path("api/v1/file/", CreateFileView.as_view(), name="create_file"), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), path('admin/', admin.site.urls), + path("api/v1/", include(router.urls)), ] # debug toolbar for dev diff --git a/diagrams/auth-diagram.svg b/diagrams/auth-diagram.svg new file mode 100644 index 0000000..e1e6bc9 --- /dev/null +++ b/diagrams/auth-diagram.svg @@ -0,0 +1,66 @@ +"Sequencial DiagramAuthentification"UserAngular/Front-endDjango API (back.apps.api)PostgreSQLSign upFill the formPOST /api/v1/user/Check the mailalt[mail already been used]Conflict400 Bad RequestDisplay "This mail already been used"[new user]Save User (hash password)Success201 CreatedDisplay "Successful registration"Log in (with SimpleJWT)Enter identifiersPOST /api/v1/token/Verify Useralt[invalid identifiers]Not found / Wrong password401 UnauthorizedDisplay "Email or password wrong"[valid identifiers]200 OK {access, refresh}Tokens are stored in localStorage/Cookies/storeRedirect to main pageAPI call with expired access tokenGET /api/v1/user/me/ (expired access token)401 Unauthorizedalt[valid refresh token]POST /api/v1/token/refresh/ {refresh_token}200 OK {new_access_token}Retry the previous request[expired refresh token]Forced disconnection (redirect to login page) \ No newline at end of file diff --git a/diagrams/db-diagram.svg b/diagrams/db-diagram.svg new file mode 100644 index 0000000..10ec156 --- /dev/null +++ b/diagrams/db-diagram.svg @@ -0,0 +1,162 @@ +" UML DiagramDatabase"User EnvironmentMaterial AssetsRequestStatusPENDINGPROCESSINGCOMPLETEDFAILEDOperationTypeCASHCARDOTHERUserid : UUID «PK»username : varchar(150) «UNIQUE»password : varchar(128)email : varchar(254) «UNIQUE»credit : integer = 0is_staff = falseis_superuser = false...Operationid : UUID «PK»beneficiary_id : UUID «FK»agent_id : UUID «FK»operation_type : OperationTypeamount : integer = 0comment : textcreated_at : DateTimeFileid : UUID «PK»user_id : UUID «FK»path : varchar(100)number_of_printing : integer = 1 {>= 0}para_slicer : jsonbRequestid : UUID «PK»file_id : UUID «FK»printer_id : Bigint «FK»status : RequestStatus = PENDINGcreated_at : DateTimePrinterid : Bigint «PK»type : PrinterTypestatus : PrinterStatus = DOWNFilamentid : Bigint «PK»colour : varchar(25)type : FilamentTypequantity : integer = 0 {>= 0}FilamentTypePLAREINFORCED_PLAPETGABSTPE/TPUPrinterTypeSLASLSFDMMJPDMLSBINDER_JETTINGPrinterStatusUPDOWNUSEDInteractsprocessesbeneficiary or agentusesusesusesuses \ No newline at end of file