From b560fa69c26b8e971c89ca88cd9518cd2352e0a8 Mon Sep 17 00:00:00 2001 From: Gaston Mastrapasqua Date: Mon, 30 Dec 2024 17:40:02 +0100 Subject: [PATCH 1/4] feat: get all the datasets of an analysis --- common/constants/__init__.py | 0 common/constants/constants.py | 8 ++ .../helpers/get_mime_type_from_extension.py | 7 + file_management/contract/dto/dataset_to.py | 14 +- .../io/create_presigned_url_upload_file_in.py | 1 + .../repository/file_management_repository.py | 12 +- ...te_presigned_url_upload_file_controller.py | 3 +- .../get_analysis_datasets_controller.py | 16 +++ .../0002_alter_dataset_options_and_more.py | 39 ++++++ file_management/models/dataset.py | 7 +- .../file_management_repository_impl.py | 14 +- .../service/file_management_service.py | 8 ++ .../impl/file_management_service_impl.py | 25 +++- file_management/urls.py | 8 ++ .../use_cases/create_dataset_uc.py | 36 ------ .../create_presigned_url_upload_file_uc.py | 10 +- .../use_cases/get_analysis_datasets_uc.py | 33 +++++ .../contract/repository/user_repository.py | 10 ++ .../repository/user_repository_impl.py | 5 + .../service/impl/users_service_impl.py | 122 ++++++++++++------ user_management/service/users_service.py | 11 ++ .../usecases/is_user_in_analysis_uc.py | 25 ++++ 22 files changed, 324 insertions(+), 90 deletions(-) create mode 100644 common/constants/__init__.py create mode 100644 common/constants/constants.py create mode 100644 common/helpers/get_mime_type_from_extension.py create mode 100644 file_management/interfaces/controllers/get_analysis_datasets_controller.py create mode 100644 file_management/migrations/0002_alter_dataset_options_and_more.py delete mode 100644 file_management/use_cases/create_dataset_uc.py create mode 100644 file_management/use_cases/get_analysis_datasets_uc.py create mode 100644 user_management/usecases/is_user_in_analysis_uc.py diff --git a/common/constants/__init__.py b/common/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/constants/constants.py b/common/constants/constants.py new file mode 100644 index 0000000..428a264 --- /dev/null +++ b/common/constants/constants.py @@ -0,0 +1,8 @@ +"""Contains constants""" +# Units in bytes +KB = 1024 +MB = 1024 * KB +GB = 1024 * MB +TB = 1024 * GB + +DATASET_MAX_SIZE = 10 * MB diff --git a/common/helpers/get_mime_type_from_extension.py b/common/helpers/get_mime_type_from_extension.py new file mode 100644 index 0000000..1039f6e --- /dev/null +++ b/common/helpers/get_mime_type_from_extension.py @@ -0,0 +1,7 @@ +"""Contains a method for getting a mimetype from a file extension""" +import mimetypes + +def get_mimetype_from_extension(filename: str) -> str: + """Retrieve a mimetype from a given filename""" + mimetype, _ = mimetypes.guess_type(filename) + return mimetype diff --git a/file_management/contract/dto/dataset_to.py b/file_management/contract/dto/dataset_to.py index a1081e2..5dd2ded 100644 --- a/file_management/contract/dto/dataset_to.py +++ b/file_management/contract/dto/dataset_to.py @@ -12,8 +12,11 @@ class DatasetTO(BaseTO): """Contains the fields for a dataset""" id: str | None - uploadedBy: UserTO | None - createdOn: datetime | None + uploadedBy: str | None + sizeBytes: int | None + createdAt: datetime | None + updatedAt: datetime | None + mimeType: str | None url: str | None filename: str | None @@ -24,9 +27,12 @@ def from_model(cls, instance: Dataset) -> 'DatasetTO | None': if not instance: return None return cls( - uploadedBy=instance.uploaded_by, - createdOn=instance.created_on, + uploadedBy=instance.uploaded_by.id, + createdAt=instance.created_at, id=instance.id, + sizeBytes=instance.size_bytes, + mimeType=instance.mime_type, + updatedAt=instance.updated_at, filename=instance.filename, url=instance.url ) diff --git a/file_management/contract/io/create_presigned_url_upload_file_in.py b/file_management/contract/io/create_presigned_url_upload_file_in.py index 4b77cab..3133b30 100644 --- a/file_management/contract/io/create_presigned_url_upload_file_in.py +++ b/file_management/contract/io/create_presigned_url_upload_file_in.py @@ -6,3 +6,4 @@ class CreatePresignedUrlUploadFileIn(serializers.Serializer): """Request input for an analysis creation""" filename = serializers.CharField() analysis_id = serializers.IntegerField() + size_bytes = serializers.IntegerField() diff --git a/file_management/contract/repository/file_management_repository.py b/file_management/contract/repository/file_management_repository.py index d3f862a..f3f1974 100644 --- a/file_management/contract/repository/file_management_repository.py +++ b/file_management/contract/repository/file_management_repository.py @@ -1,6 +1,7 @@ """This module contains the File Management repository""" from abc import abstractmethod +from typing import List from file_management.contract.dto.dataset_to import DatasetTO from file_management.contract.dto.s3_presigned_url_to import S3PresignedUrlTO @@ -11,7 +12,7 @@ class FileManagementRepository: @abstractmethod def create_presigned_url_upload_file( - self, filename: str, user_id: str + self, filename: str, user_id: str, size_bytes: int ) -> tuple[S3PresignedUrlTO, DatasetTO]: """ Create a presigned URL to allow the frontend to upload a file @@ -38,3 +39,12 @@ def get_dataset_by_id(self, dataset_id) -> DatasetTO: """ Retrieve a dataset by id """ + + @abstractmethod + def get_analysis_datasets(self, analysis_id: int) -> List[DatasetTO]: + """Retrieve the datasets of the analysis + + Keyword arguments: + analysis_id -- the id of the analysis + Return: A list of DatasetTO + """ diff --git a/file_management/interfaces/controllers/create_presigned_url_upload_file_controller.py b/file_management/interfaces/controllers/create_presigned_url_upload_file_controller.py index 69383f1..42c11bc 100644 --- a/file_management/interfaces/controllers/create_presigned_url_upload_file_controller.py +++ b/file_management/interfaces/controllers/create_presigned_url_upload_file_controller.py @@ -23,5 +23,6 @@ def create_presigned_url_upload_file_controller(request): raise BadRequestException("All fields are required") filename = data.validated_data["filename"] analysis_id = data.validated_data["analysis_id"] - url = service.create_presigned_url_upload_file(request.user, filename, analysis_id) + size_bytes = data.validated_data["size_bytes"] + url = service.create_presigned_url_upload_file(request.user, filename, analysis_id, size_bytes) return api_response_success(data=url) diff --git a/file_management/interfaces/controllers/get_analysis_datasets_controller.py b/file_management/interfaces/controllers/get_analysis_datasets_controller.py new file mode 100644 index 0000000..c767ef0 --- /dev/null +++ b/file_management/interfaces/controllers/get_analysis_datasets_controller.py @@ -0,0 +1,16 @@ +"""Contains the controller for getting the analysis datasets""" +from rest_framework.decorators import api_view + +from common.exceptions.exceptions import BadRequestException +from common.helpers.api_responses import api_response_success +from file_management.service.impl.file_management_service_impl import FileManagementServiceImpl + +@api_view(["GET"]) +def get_analysis_datasets_controller(request): + """Retrieve the analysis datasets""" + analysis_id = request.query_params.get("analysis_id", None) + if not analysis_id: + raise BadRequestException("The analysis id is required") + service = FileManagementServiceImpl() + datasets = service.get_analysis_datasets(request.user, analysis_id) + return api_response_success(data=datasets) diff --git a/file_management/migrations/0002_alter_dataset_options_and_more.py b/file_management/migrations/0002_alter_dataset_options_and_more.py new file mode 100644 index 0000000..e355427 --- /dev/null +++ b/file_management/migrations/0002_alter_dataset_options_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.4 on 2024-12-30 15:59 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('file_management', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='dataset', + options={'ordering': ['created_at']}, + ), + migrations.RenameField( + model_name='dataset', + old_name='created_on', + new_name='created_at', + ), + migrations.AddField( + model_name='dataset', + name='mime_type', + field=models.CharField(default='text/csv'), + ), + migrations.AddField( + model_name='dataset', + name='size_bytes', + field=models.IntegerField(default=0), + preserve_default=False, + ), + migrations.AddField( + model_name='dataset', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/file_management/models/dataset.py b/file_management/models/dataset.py index 91cea30..b467de3 100644 --- a/file_management/models/dataset.py +++ b/file_management/models/dataset.py @@ -7,10 +7,13 @@ class Dataset(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4) filename = models.CharField(max_length=255) url = models.URLField(null=True) - created_on = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) uploaded_by = models.ForeignKey('user_management.User', on_delete=models.CASCADE) + mime_type = models.CharField(null=False, default="text/csv") + size_bytes = models.IntegerField(default=0) class Meta: """Table's metadata""" db_table = 'dataset' - ordering = ['created_on'] + ordering = ['created_at'] diff --git a/file_management/repository/file_management_repository_impl.py b/file_management/repository/file_management_repository_impl.py index 98a8ce6..dd4de92 100644 --- a/file_management/repository/file_management_repository_impl.py +++ b/file_management/repository/file_management_repository_impl.py @@ -1,6 +1,7 @@ """This module contains the analysis repository""" import logging +from typing import List import urllib from datetime import timedelta from os import getenv @@ -9,6 +10,7 @@ from django.db import transaction from analysis.models.analysis import Analysis +from common.helpers.get_mime_type_from_extension import get_mimetype_from_extension from file_management.contract.dto.dataset_to import DatasetTO from file_management.contract.dto.s3_presigned_url_to import S3PresignedUrlTO from file_management.contract.repository.file_management_repository import ( @@ -23,7 +25,7 @@ class FileManagementRepositoryImpl(FileManagementRepository): """Analysis repository""" - def create_presigned_url_upload_file(self, filename: str, user_id: str): + def create_presigned_url_upload_file(self, filename: str, user_id: str, size_bytes: int): s3_client = boto3.client("s3") expires_in = timedelta(hours=1).seconds user = User.objects.filter(id=user_id).first() @@ -31,7 +33,10 @@ def create_presigned_url_upload_file(self, filename: str, user_id: str): try: with transaction.atomic(): new_dataset = Dataset.objects.create( - filename=filename, uploaded_by=user + filename=filename, + uploaded_by=user, + size_bytes=size_bytes, + mime_type=get_mimetype_from_extension(filename) ) object_name = f"datasets/{str(new_dataset.id)}" response = s3_client.generate_presigned_post( @@ -70,3 +75,8 @@ def attach_file_to_analysis(self, dataset_id: str, analysis_id: int) -> DatasetT def get_dataset_by_id(self, dataset_id: str) -> DatasetTO: dataset = Dataset.objects.filter(id=dataset_id).first() return DatasetTO.from_model(dataset) + + def get_analysis_datasets(self, analysis_id: int) -> List[DatasetTO]: + analysis = Analysis.objects.filter(id=analysis_id).first() + datasets = analysis.datasets.all() + return DatasetTO.from_models(datasets) diff --git a/file_management/service/file_management_service.py b/file_management/service/file_management_service.py index 7229e04..2cf5a64 100644 --- a/file_management/service/file_management_service.py +++ b/file_management/service/file_management_service.py @@ -1,8 +1,10 @@ """Contains the abstract class of file management service""" from abc import abstractmethod +from typing import List from common.service.base_service import BaseService +from file_management.contract.dto.dataset_to import DatasetTO from file_management.contract.dto.s3_presigned_url_to import S3PresignedUrlTO @@ -16,3 +18,9 @@ def create_presigned_url_upload_file(self, user, filename: str) -> S3PresignedUr @abstractmethod def create_presigned_url_download_file(self, user, dataset_id: str) -> S3PresignedUrlTO: """Generate a presigned URL for downloading files""" + + @abstractmethod + def get_analysis_datasets(self, user, analysis_id: int) -> List[DatasetTO]: + """ + Retrieve all the datasets from the given analysis if the user has the required permissions + """ diff --git a/file_management/service/impl/file_management_service_impl.py b/file_management/service/impl/file_management_service_impl.py index 3a44dff..33c5f90 100644 --- a/file_management/service/impl/file_management_service_impl.py +++ b/file_management/service/impl/file_management_service_impl.py @@ -1,9 +1,8 @@ """Contains the implementation of AnalysisService""" -import urllib from analysis.service.impl.analysis_service_impl import AnalysisServiceImpl -from common.exceptions.exceptions import BadRequestException, NotFoundException -from file_management.contract.dto.s3_presigned_url_to import S3PresignedUrlTO +from common.constants.constants import DATASET_MAX_SIZE, MB +from common.exceptions.exceptions import BadRequestException, NotFoundException, ForbiddenException from file_management.repository.file_management_repository_impl import ( FileManagementRepositoryImpl, ) @@ -14,7 +13,9 @@ from file_management.use_cases.create_presigned_url_upload_file_uc import ( CreatePresignedUrlUploadFileUC, ) +from file_management.use_cases.get_analysis_datasets_uc import GetAnalysisDatasetsUC from user_management.repository.role_repository_impl import RoleRepositoryImpl +from user_management.service.impl.users_service_impl import UsersServiceImpl from user_management.usecases.attach_file_to_analysis_uc import AttachFileToAnalysisUC from user_management.usecases.get_user_role_in_analysis_uc import ( GetUserRoleInAnalysisUC, @@ -30,23 +31,28 @@ def __init__(self): ) self.get_user_role_in_analysis_uc = GetUserRoleInAnalysisUC.get_instance() self.attach_file_to_analysis_uc = AttachFileToAnalysisUC.get_instance() + self.get_analysis_datasets_uc = GetAnalysisDatasetsUC.get_instance() self.create_presigned_url_download_file_uc = ( CreatePresignedUrlDownloadFileUC.get_instance() ) self.repository = FileManagementRepositoryImpl() self.role_repository = RoleRepositoryImpl() self.analysis_service = AnalysisServiceImpl() + self.user_service = UsersServiceImpl() def create_presigned_url_upload_file( - self, user, filename: str, analysis_id: int + self, user, filename: str, analysis_id: int, size_bytes: int ) -> str: # TODO: validate if the user is in ['FACILITATOR', 'DATA MANAGER'] + if size_bytes > DATASET_MAX_SIZE: + raise BadRequestException(f"The file must be smaller than {DATASET_MAX_SIZE / MB}MB") + self.analysis_service.get_analysis_by_id( analysis_id ) # Raise 404 if analysis doesn't exist presigned_url, dataset = self.create_presigned_url_upload_file_uc.exec( - self.repository, filename, user.id + self.repository, filename, user.id, size_bytes ) self.attach_file_to_analysis_uc.exec(self.repository, dataset.id, analysis_id) @@ -69,3 +75,12 @@ def create_presigned_url_download_file( if not response: raise BadRequestException() return response + + def get_analysis_datasets(self, user, analysis_id: int): + self.analysis_service.get_analysis_by_id(analysis_id) # Raises 404 if not found + if not self.user_service.is_user_in_analysis(user.id, analysis_id): + raise ForbiddenException("You can't see that analysis") + datasets = self.get_analysis_datasets_uc.exec( + self.repository, analysis_id + ) + return [dataset.to_dict() for dataset in datasets] diff --git a/file_management/urls.py b/file_management/urls.py index 739d240..edc18c9 100644 --- a/file_management/urls.py +++ b/file_management/urls.py @@ -8,6 +8,9 @@ from file_management.interfaces.controllers.create_presigned_url_upload_file_controller import ( create_presigned_url_upload_file_controller, ) +from file_management.interfaces.controllers.get_analysis_datasets_controller import ( + get_analysis_datasets_controller, +) urlpatterns = [ path( @@ -20,4 +23,9 @@ create_presigned_url_download_file_controller, name="get_download_file_url", ), + path( + "get-analysis-datasets", + get_analysis_datasets_controller, + name="get_analysis_datasets", + ), ] diff --git a/file_management/use_cases/create_dataset_uc.py b/file_management/use_cases/create_dataset_uc.py deleted file mode 100644 index f208e3a..0000000 --- a/file_management/use_cases/create_dataset_uc.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Contains the use case for creating datasets""" - -from common.use_case.base_use_case import BaseUseCase -from file_management.contract.dto.dataset_to import DatasetTO -from file_management.contract.repository.file_management_repository import ( - FileManagementRepository, -) - - -class CreateDatasetUC(BaseUseCase): - """Singleton use case for creating datasets""" - - _instance = None - - def __init__(self): - if CreateDatasetUC._instance is not None: - raise Exception("This class is a singleton!") - else: - CreateDatasetUC._instance = self - - @staticmethod - def get_instance(): - """Retrieves the singleton instance""" - if CreateDatasetUC._instance is None: - CreateDatasetUC() - return CreateDatasetUC._instance - - def exec( - self, - repository: FileManagementRepository, - url: str, - user_id: str, - filename: str, - ) -> DatasetTO: - dataset_id = repository.create_dataset(url, user_id, filename) - return dataset_id diff --git a/file_management/use_cases/create_presigned_url_upload_file_uc.py b/file_management/use_cases/create_presigned_url_upload_file_uc.py index d4d6c22..9ef468a 100644 --- a/file_management/use_cases/create_presigned_url_upload_file_uc.py +++ b/file_management/use_cases/create_presigned_url_upload_file_uc.py @@ -27,7 +27,13 @@ def get_instance(): return CreatePresignedUrlUploadFileUC._instance def exec( - self, repository: FileManagementRepository, dataset_id: str, user_id: str + self, + repository: FileManagementRepository, + dataset_id: str, + user_id: str, + size_bytes: int, ) -> tuple[S3PresignedUrlTO, DatasetTO]: - presigned_url, dataset = repository.create_presigned_url_upload_file(dataset_id, user_id) + presigned_url, dataset = repository.create_presigned_url_upload_file( + dataset_id, user_id, size_bytes + ) return presigned_url, dataset diff --git a/file_management/use_cases/get_analysis_datasets_uc.py b/file_management/use_cases/get_analysis_datasets_uc.py new file mode 100644 index 0000000..6517f19 --- /dev/null +++ b/file_management/use_cases/get_analysis_datasets_uc.py @@ -0,0 +1,33 @@ +"""Contains the use case for getting the datasets of the analysis""" + +from common.use_case.base_use_case import BaseUseCase +from file_management.contract.dto.dataset_to import DatasetTO +from file_management.contract.dto.s3_presigned_url_to import S3PresignedUrlTO +from file_management.contract.repository.file_management_repository import ( + FileManagementRepository, +) + + +class GetAnalysisDatasetsUC(BaseUseCase): + """Singleton use case for getting the datasets of the analysis""" + + _instance = None + + def __init__(self): + if GetAnalysisDatasetsUC._instance is not None: + raise Exception("This class is a singleton!") + else: + GetAnalysisDatasetsUC._instance = self + + @staticmethod + def get_instance(): + """Retrieves the singleton instance""" + if GetAnalysisDatasetsUC._instance is None: + GetAnalysisDatasetsUC() + return GetAnalysisDatasetsUC._instance + + def exec( + self, repository: FileManagementRepository, analysis_id: int + ) -> tuple[S3PresignedUrlTO, DatasetTO]: + datasets = repository.get_analysis_datasets(analysis_id) + return datasets diff --git a/user_management/contract/repository/user_repository.py b/user_management/contract/repository/user_repository.py index f1abd16..dfa5050 100644 --- a/user_management/contract/repository/user_repository.py +++ b/user_management/contract/repository/user_repository.py @@ -25,3 +25,13 @@ def get_user_by_filters(self, **kwargs): Get users based on dynamic filters. Accepts any combination of filter arguments. """ + + @abstractmethod + def is_user_in_analysis(self, user_id: str, analysis_id: int) -> bool: + """Verify if an user belongs to an analysis + + Keyword arguments: + user_id -- the id of the user + analysis_id -- the id of the analysis + Return: True if the user belongs and False if not + """ diff --git a/user_management/repository/user_repository_impl.py b/user_management/repository/user_repository_impl.py index 2ebc24a..da09270 100644 --- a/user_management/repository/user_repository_impl.py +++ b/user_management/repository/user_repository_impl.py @@ -4,6 +4,7 @@ from user_management.contract.repository.user_repository import UserRepository from user_management.contract.to.user_to import UserTO from user_management.models import User +from user_management.models.user_analysis_role import UserAnalysisRole class UserRepositoryImpl(UserRepository): @@ -64,3 +65,7 @@ def create(self, data): """ user = User.objects.create(**data) return user + + def is_user_in_analysis(self, user_id: str, analysis_id: int) -> bool: + is_user_in_analysis = UserAnalysisRole.objects.filter(user_id=user_id, analysis_id=analysis_id).exists() + return is_user_in_analysis diff --git a/user_management/service/impl/users_service_impl.py b/user_management/service/impl/users_service_impl.py index d753f2c..2cbb6b7 100644 --- a/user_management/service/impl/users_service_impl.py +++ b/user_management/service/impl/users_service_impl.py @@ -1,3 +1,4 @@ +"""Contains the users service""" from tokenize import TokenError from django.contrib.auth.hashers import check_password @@ -8,33 +9,49 @@ from analysis.repository.analysis_repository_impl import AnalysisRepositoryImpl from analysis.use_cases.get_analysis_uc import GetAnalysisUC -from common.exceptions.exceptions import NotFoundException, UnauthorizedException, BadRequestException +from common.exceptions.exceptions import ( + NotFoundException, + UnauthorizedException, + BadRequestException, +) from common.helpers.api_responses import api_response_success from common.helpers.query_options import QueryOptions from common.use_case.get_all_uc import GetAllUC as GetUsersUC -from interac_not_manager.repository.notification_repository_impl import NotificationRepositoryImpl -from interac_not_manager.service.utils.messages import ORGANIZATION_INVITE_MESSAGE, ANALYSIS_INVITE_MESSAGE -from interac_not_manager.usecases.send_notification_to_user_uc import SendNotificationToUserUC +from interac_not_manager.repository.notification_repository_impl import ( + NotificationRepositoryImpl, +) +from interac_not_manager.service.utils.messages import ( + ORGANIZATION_INVITE_MESSAGE, + ANALYSIS_INVITE_MESSAGE, +) +from interac_not_manager.usecases.send_notification_to_user_uc import ( + SendNotificationToUserUC, +) from user_management.contract.io.invite_user_analysis_in import InviteUserAnalysisIn from user_management.contract.io.invite_user_in import InviteUserIn from user_management.contract.io.sign_in_in import SignInIn from user_management.contract.io.sign_up_in import SignUpIn from user_management.interfaces.serializers.token_serializer import UserTokenSerializer from user_management.interfaces.serializers.user_serializer import UserSerializer -from user_management.repository.organization_repository_impl import OrganizationRepositoryImpl +from user_management.repository.organization_repository_impl import ( + OrganizationRepositoryImpl, +) from user_management.repository.user_repository_impl import UserRepositoryImpl from user_management.repository.workspace_repository_impl import WorkspaceRepositoryImpl from user_management.service.users_service import UsersService from user_management.usecases.add_user_to_workspace_uc import AddUserToWorkspaceUC from user_management.usecases.get_user_uc_by_filters_uc import GetUserByFiltersUC from user_management.usecases.invite_user_to_analysis_uc import InviteUserToAnalysisUC -from user_management.usecases.invite_user_to_organization_uc import InviteUserToOrganizationUC +from user_management.usecases.invite_user_to_organization_uc import ( + InviteUserToOrganizationUC, +) +from user_management.usecases.is_user_in_analysis_uc import IsUserInAnalysisUC from user_management.usecases.sign_in_uc import SignInUC from user_management.usecases.sign_up_uc import SignUpUC class UsersServiceImpl(UsersService): - + """Business logic for user management""" def __init__(self): self.get_users_uc = GetUsersUC.get_instance() self.sign_in_uc = SignInUC.get_instance() @@ -46,39 +63,60 @@ def __init__(self): self.invite_user_to_analysis_uc = InviteUserToAnalysisUC.get_instance() self.add_user_to_workspace_uc = AddUserToWorkspaceUC.get_instance() self.sign_in_uc = SignInUC.get_instance() + self.is_user_in_analysis_uc = IsUserInAnalysisUC.get_instance() + self.repository = UserRepositoryImpl() def invite_user_to_org(self, invite_user_in: InviteUserIn): """Business logic to invite user to an organization""" if not invite_user_in.is_valid(): raise BadRequestException("Invitation not valid", invite_user_in.errors) data = invite_user_in.validated_data - user = self.get_user_by_filters.exec(UserRepositoryImpl(), email=data['email']) + user = self.get_user_by_filters.exec(self.repository, email=data["email"]) if user is None: raise NotFoundException("User not found") - user_org_role = self.invite_user_to_org_uc.exec(OrganizationRepositoryImpl(), user.id, data['id'], - data['role_id']) - self.notify_user_uc.exec(NotificationRepositoryImpl(), {"user_id": user.id, - "message": ORGANIZATION_INVITE_MESSAGE + " " + user_org_role.organization.name}) + user_org_role = self.invite_user_to_org_uc.exec( + OrganizationRepositoryImpl(), user.id, data["id"], data["role_id"] + ) + self.notify_user_uc.exec( + NotificationRepositoryImpl(), + { + "user_id": user.id, + "message": ORGANIZATION_INVITE_MESSAGE + + " " + + user_org_role.organization.name, + }, + ) def invite_user_to_analysis(self, invite_user_in: InviteUserAnalysisIn): """Business logic to invite user to an analysis""" if not invite_user_in.is_valid(): raise BadRequestException("Invitation not valid", invite_user_in.errors) data = invite_user_in.validated_data - user = self.get_user_by_filters.exec(UserRepositoryImpl(), email=data['email']) + user = self.get_user_by_filters.exec(self.repository, email=data["email"]) if user is None: raise NotFoundException("User not found") - analysis = self.get_analysis_uc.exec(AnalysisRepositoryImpl(), None, id=data['id']) + analysis = self.get_analysis_uc.exec( + AnalysisRepositoryImpl(), None, id=data["id"] + ) if analysis is None or len(analysis) != 1: raise NotFoundException("Analysis not found") - self.invite_user_to_analysis_uc.exec(AnalysisRepositoryImpl(), user.id, data['id'], data['role_id']) - self.add_user_to_workspace_uc.exec(WorkspaceRepositoryImpl(), user.id, analysis[0].workspaceId, None) - self.notify_user_uc.exec(NotificationRepositoryImpl(), - {"user_id": user.id, "message": ANALYSIS_INVITE_MESSAGE + " " + analysis[0].title}) + self.invite_user_to_analysis_uc.exec( + AnalysisRepositoryImpl(), user.id, data["id"], data["role_id"] + ) + self.add_user_to_workspace_uc.exec( + WorkspaceRepositoryImpl(), user.id, analysis[0].workspaceId, None + ) + self.notify_user_uc.exec( + NotificationRepositoryImpl(), + { + "user_id": user.id, + "message": ANALYSIS_INVITE_MESSAGE + " " + analysis[0].title, + }, + ) def get_users(self, query_options: QueryOptions): """Business logic to retrieve all users""" - users = self.get_users_uc.exec(UserRepositoryImpl(), query_options) + users = self.get_users_uc.exec(self.repository, query_options) if not users: raise NotFoundException("Users not found") return UserSerializer(users, many=True).data @@ -88,17 +126,22 @@ def sign_up(self, sign_up_in: SignUpIn): if not sign_up_in.is_valid(): raise BadRequestException("All fields are mandatory", sign_up_in.errors) data = sign_up_in.validated_data - if self.get_user_by_filters.exec(UserRepositoryImpl(), email=data['email']) is not None: + if ( + self.get_user_by_filters.exec(self.repository, email=data["email"]) + is not None + ): raise BadRequestException("User already exists", None) - self.sign_up_uc.exec(UserRepositoryImpl(), **data) + self.sign_up_uc.exec(self.repository, **data) def sign_in(self, sign_in_in: SignInIn): """Business logic to sign in""" if not sign_in_in.is_valid(): raise BadRequestException("All fields are mandatory", sign_in_in.errors) data = sign_in_in.validated_data - user_to = self.get_user_by_filters.exec(UserRepositoryImpl(), email=data['email']) - if not user_to or not check_password(data['password'], user_to.password): + user_to = self.get_user_by_filters.exec( + self.repository, email=data["email"] + ) + if not user_to or not check_password(data["password"], user_to.password): raise BadRequestException("Incorrect email or password") try: return UserTokenSerializer(user_to).data @@ -108,14 +151,16 @@ def sign_in(self, sign_in_in: SignInIn): def sign_in_with_access_token(self, token: str): """Sign in a user with an access token""" if not token: - raise UnauthorizedException("The token is required", {'is_authenticated': False}) + raise UnauthorizedException( + "The token is required", {"is_authenticated": False} + ) jwt_auth = JWTAuthentication() try: token_decoded = jwt_auth.get_validated_token(token) user_to = self.get_user_by_filters.exec( - UserRepositoryImpl(), - id=token_decoded.payload['user_id']) + self.repository, id=token_decoded.payload["user_id"] + ) if not user_to: raise BadRequestException("User not found") return UserTokenSerializer(user_to).data @@ -125,32 +170,35 @@ def sign_in_with_access_token(self, token: str): def refresh_token(self, refresh_token): """Business logic to process refresh token""" if not refresh_token: - raise BadRequestException( - "Refresh token is required.", None) + raise BadRequestException("Refresh token is required.", None) try: # Attempt to decode the refresh token and generate a new access token token = RefreshToken(refresh_token) return str(token.access_token) except TokenError as e: # Handle cases where the refresh token is invalid or expired - raise UnauthorizedException( - "Invalid or expired refresh token.", None - ) + raise UnauthorizedException("Invalid or expired refresh token.", None) from e def verify_token(self, auth_header): """Business logic to verify token""" - if not auth_header or not auth_header.startswith('Bearer '): + if not auth_header or not auth_header.startswith("Bearer "): # Return false if the token is missing or improperly formatted - raise UnauthorizedException("Unauthorized", {'is_authenticated': False}) + raise UnauthorizedException("Unauthorized", {"is_authenticated": False}) # Extract the token part from the header - token = auth_header.split(' ')[1] + token = auth_header.split(" ")[1] # Instantiate JWTAuthentication to validate the token jwt_auth = JWTAuthentication() try: jwt_auth.get_validated_token(token) - return api_response_success("Is authenticated", {'isAuthenticated': True}, status.HTTP_200_OK) - except (InvalidToken, TokenError): - raise UnauthorizedException("Session expired", {'is_authenticated': False}) + return api_response_success( + "Is authenticated", {"isAuthenticated": True}, status.HTTP_200_OK + ) + except (InvalidToken, TokenError) as e: + raise UnauthorizedException("Session expired", {"is_authenticated": False}) from e + + def is_user_in_analysis(self, user_id: str, analysis_id: int) -> bool: + """Verify if the user is in the analysis""" + return self.is_user_in_analysis_uc.exec(self.repository, user_id, analysis_id) diff --git a/user_management/service/users_service.py b/user_management/service/users_service.py index 58149d7..bed51ef 100644 --- a/user_management/service/users_service.py +++ b/user_management/service/users_service.py @@ -40,3 +40,14 @@ def invite_user_to_org(self, invite_user_in: InviteUserIn): def invite_user_to_analysis(self, invite_user_in: InviteUserAnalysisIn): """Invite user to an analysis""" pass + + @abstractmethod + def is_user_in_analysis(self, user_id: str, analysis_id: int) -> bool: + """Validate if a user belongs to an analysis + + Keyword arguments: + user_id -- the id of the user + analysis_id -- the id of the analysis + Return: True if the user belongs and False if not + """ + diff --git a/user_management/usecases/is_user_in_analysis_uc.py b/user_management/usecases/is_user_in_analysis_uc.py new file mode 100644 index 0000000..ddccfe2 --- /dev/null +++ b/user_management/usecases/is_user_in_analysis_uc.py @@ -0,0 +1,25 @@ +"""This module contains use case for knowing if a user belongs to an analysis""" +from common.use_case.base_use_case import BaseUseCase +from user_management.contract.repository.user_repository import UserRepository + + +class IsUserInAnalysisUC(BaseUseCase): + """Class for verifying if an user belongs to an analysis""" + _instance = None + + def __init__(self): + if IsUserInAnalysisUC._instance is not None: + raise Exception("This class is a singleton!") + else: + IsUserInAnalysisUC._instance = self + + @staticmethod + def get_instance(): + """Returns an instance of the class""" + if IsUserInAnalysisUC._instance is None: + IsUserInAnalysisUC() + return IsUserInAnalysisUC._instance + + def exec(self, repository: UserRepository, user_id: str, analysis_id: int): + """Execute de use case""" + return repository.is_user_in_analysis(user_id, analysis_id) From 9a2953d405d4e777883887922e4ce597a43dbd7c Mon Sep 17 00:00:00 2001 From: Gaston Mastrapasqua Date: Mon, 30 Dec 2024 18:21:59 +0100 Subject: [PATCH 2/4] refactor: save the dataset with the filename --- file_management/repository/file_management_repository_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/file_management/repository/file_management_repository_impl.py b/file_management/repository/file_management_repository_impl.py index dd4de92..fb3c3f0 100644 --- a/file_management/repository/file_management_repository_impl.py +++ b/file_management/repository/file_management_repository_impl.py @@ -38,7 +38,7 @@ def create_presigned_url_upload_file(self, filename: str, user_id: str, size_byt size_bytes=size_bytes, mime_type=get_mimetype_from_extension(filename) ) - object_name = f"datasets/{str(new_dataset.id)}" + object_name = f"datasets/{filename}" response = s3_client.generate_presigned_post( bucket_name, object_name, From 806108ad2d63343d4f5be7a3e6c780ff33cab36a Mon Sep 17 00:00:00 2001 From: Gaston Mastrapasqua Date: Mon, 30 Dec 2024 18:34:57 +0100 Subject: [PATCH 3/4] refactor: adapt tests to receive size bytes --- file_management/tests/test_controllers.py | 6 +++--- file_management/tests/test_services.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/file_management/tests/test_controllers.py b/file_management/tests/test_controllers.py index 3b0923b..e97bc22 100644 --- a/file_management/tests/test_controllers.py +++ b/file_management/tests/test_controllers.py @@ -24,14 +24,14 @@ def test_valid_data(self, mock_service): mock_service_instance.create_presigned_url_upload_file.return_value = { "url": "https://example.com/upload" } - valid_data = {"filename": "test.csv", "analysisId": 1} + valid_data = {"filename": "test.csv", "analysisId": 1, "sizeBytes": 12345} response = self.client.post(self.url, valid_data) self.assertEqual(response.status_code, 200) self.assertEqual( response.data["payload"], {"url": "https://example.com/upload"} ) mock_service_instance.create_presigned_url_upload_file.assert_called_once_with( - self.user, "test.csv", 1 + self.user, "test.csv", 1, 12345 ) def test_missing_fields(self): @@ -52,7 +52,7 @@ def test_invalid_data(self, mock_service): mock_service.return_value = mock_service_instance mock_service_instance.create_presigned_url_upload_file.side_effect = Exception() - valid_data = {"filename": "test.csv", "analysisId": 1} + valid_data = {"filename": "test.csv", "analysisId": 1, "sizeBytes": 12345} response = self.client.post(self.url, valid_data) diff --git a/file_management/tests/test_services.py b/file_management/tests/test_services.py index c4d4c76..bb9bc1c 100644 --- a/file_management/tests/test_services.py +++ b/file_management/tests/test_services.py @@ -31,6 +31,7 @@ def test_create_presigned_url_success(self): presigned_url_mock.url = "https://mockurl.com/" dataset_mock = MagicMock() dataset_mock.id = 123 + size_bytes = 12345 self.service.create_presigned_url_upload_file_uc.exec.return_value = ( presigned_url_mock, dataset_mock ) @@ -39,7 +40,7 @@ def test_create_presigned_url_success(self): mock_dataset.id = 456 response = self.service.create_presigned_url_upload_file( - self.user, self.filename, self.analysis_id + self.user, self.filename, self.analysis_id, size_bytes ) self.service.analysis_service.get_analysis_by_id.assert_called_once_with( @@ -56,8 +57,9 @@ def test_analysis_not_found(self): self.service.analysis_service.get_analysis_by_id.side_effect = ( NotFoundException() ) + size_bytes = 12345 with self.assertRaises(NotFoundException): self.service.create_presigned_url_upload_file( - self.user, self.filename, self.analysis_id + self.user, self.filename, self.analysis_id, size_bytes ) From c0dfc250dfda5ff960d3e4b459ed5d5dc4c0150a Mon Sep 17 00:00:00 2001 From: Gaston Mastrapasqua Date: Mon, 30 Dec 2024 22:46:17 +0100 Subject: [PATCH 4/4] feat: add tests --- file_management/tests/test_controllers.py | 105 ++++++++++++++++++++ file_management/tests/test_services.py | 111 +++++++++++++++++++++- file_management/tests/test_urls.py | 12 ++- 3 files changed, 225 insertions(+), 3 deletions(-) diff --git a/file_management/tests/test_controllers.py b/file_management/tests/test_controllers.py index e97bc22..bce60dc 100644 --- a/file_management/tests/test_controllers.py +++ b/file_management/tests/test_controllers.py @@ -4,7 +4,14 @@ from django.test import TestCase from django.urls import reverse +from analysis.models.analysis import Analysis +from common.exceptions.exceptions import BadRequestException from common.test_utils import create_logged_in_client +from file_management.models.dataset import Dataset +from user_management.models.organization import Organization +from user_management.models.role import Role +from user_management.models.user_analysis_role import UserAnalysisRole +from user_management.models.workspace import Workspace class TestCreatePresignedUrlFileUploadController(TestCase): @@ -57,3 +64,101 @@ def test_invalid_data(self, mock_service): response = self.client.post(self.url, valid_data) self.assertEqual(response.status_code, 500) + +class TestCreatePresignedUrlDownloadFileController(TestCase): + """Contains the test cases for the enpodint to getting url for downloading a dataset""" + + def setUp(self): + self.client, self.user = create_logged_in_client() + self.url = reverse("get_download_file_url") + + @patch( + "file_management.interfaces.controllers.create_presigned_url_download_file_controller.FileManagementServiceImpl" + ) + def test_empty_dataset(self, mock_service): + """Test that if the dataset is invalid or empty it raises an exception""" + mock_service_instance = MagicMock() + mock_service.return_value = mock_service_instance + mock_service_instance.create_presigned_url_download_file.side_effect = BadRequestException() + response = self.client.post(self.url) + + self.assertEqual(response.status_code, 400) + + @patch( + "file_management.interfaces.controllers.create_presigned_url_download_file_controller.FileManagementServiceImpl" + ) + def test_valid_dataset(self, mock_service): + """Test that if the dataset is present and valid it works""" + mock_service_instance = MagicMock() + mock_service.return_value = mock_service_instance + mock_service_instance.create_presigned_url_download_file.return_value = [ + { + "id": "cd516de1-354c-46b0-873b-11d2f9871287", + "uploadedBy": "910c285d-d9a9-4d2c-af4e-31f4fb93a1c4", + "sizeBytes": 10485760, + "createdAt": "2024-12-30T17:06:40.705037Z", + "updatedAt": "2024-12-30T17:06:40.710246Z", + "mimeType": "text/csv", + "url": "https://testUrl", + "filename": "Prueba3.txt" + }, + { + "id": "de98223f-cb5d-4512-88a5-c143c55a2775", + "uploadedBy": "910c285d-d9a9-4d2c-af4e-31f4fb93a1c4", + "sizeBytes": 10485760, + "createdAt": "2024-12-30T17:20:48.556865Z", + "updatedAt": "2024-12-30T17:20:48.561312Z", + "mimeType": "text/csv", + "url": "https://testUrl", + "filename": "Prueba3.txt" + } + ] + dataset_id = 12345 + response = self.client.post(self.url + f"?dataset_id={dataset_id}") + + self.assertEqual(response.status_code, 200) + mock_service_instance.create_presigned_url_download_file.assert_called_once() + +class TestGetDatasetsFromAnalysis(TestCase): + """Contains the test cases for getting the datasets from an analysis id""" + + def setUp(self): + self.client, self.user = create_logged_in_client() + self.url = reverse("get_analysis_datasets") + self.org = Organization.objects.create(name="TestOrganization2") + self.workspace = Workspace.objects.create( + title="TestWorksp2ace1", + organization=self.org, + facilitator_id=self.user.id, + creator_id=self.user.id, + ) + self.analysis = Analysis.objects.create( + title="TestAnalysis1", + workspace_id=self.workspace.id, + end_date="2024-12-17", + creator_id=self.user.id, + ) + self.analysis.datasets.add( + Dataset.objects.create( + filename="test.csv", + url="http://testurl/test.csv", + uploaded_by=self.user, + size_bytes=12345 + ) + ) + UserAnalysisRole.objects.create( + user=self.user, + analysis=self.analysis, + role=Role.objects.first() + ) + + def test_call_without_analysis_id_fails(self): + """Tests that if the query param analysis_id is not present it raises an exception""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 400) + + def test_call_with_valid_data_works(self): + """Test that if the analysis_id is present and valid works""" + response = self.client.get(self.url + f"?analysis_id={self.analysis.id}") + + self.assertEqual(response.status_code, 200) diff --git a/file_management/tests/test_services.py b/file_management/tests/test_services.py index bb9bc1c..4a33fe1 100644 --- a/file_management/tests/test_services.py +++ b/file_management/tests/test_services.py @@ -1,12 +1,20 @@ """This module contains the tests for the services""" from unittest.mock import MagicMock -from django.test import SimpleTestCase +from django.test import SimpleTestCase, TestCase -from common.exceptions.exceptions import NotFoundException +from analysis.models.analysis import Analysis +from common.constants.constants import DATASET_MAX_SIZE +from common.exceptions.exceptions import BadRequestException, ForbiddenException, NotFoundException +from file_management.models.dataset import Dataset from file_management.service.impl.file_management_service_impl import ( FileManagementServiceImpl, ) +from user_management.models.organization import Organization +from user_management.models.role import Role +from user_management.models.user import User +from user_management.models.user_analysis_role import UserAnalysisRole +from user_management.models.workspace import Workspace class TestGetPresignedUrlFileUpload(SimpleTestCase): @@ -63,3 +71,102 @@ def test_analysis_not_found(self): self.service.create_presigned_url_upload_file( self.user, self.filename, self.analysis_id, size_bytes ) + + def test_file_too_big(self): + """Test that if the file size is too big raises a bad request exception""" + size_bytes = DATASET_MAX_SIZE + 1 + with self.assertRaises(BadRequestException): + self.service.create_presigned_url_upload_file( + self.user, self.filename, self.analysis_id, size_bytes + ) + +class TestCreatePresignedUrlDownloadFile(SimpleTestCase): + """Contains the test cases for creating a presigned url for file downloading""" + def setUp(self): + self.service = FileManagementServiceImpl() + self.service.repository = MagicMock() + self.service.create_presigned_url_download_file_uc = MagicMock() + + def test_invalid_dataset_id(self): + """Test that usign a invalid dataset raises a NotFoundException""" + user = MagicMock() + user.id = 1 + dataset_id = "12345" + + self.service.repository.get_dataset_by_id.return_value = None + with self.assertRaises(NotFoundException): + self.service.create_presigned_url_download_file(user, dataset_id) + + def test_not_response(self): + """Test that if the response from aws is empty raises a bad request""" + user = MagicMock() + user.id = 1 + dataset_id = "12345" + + self.service.create_presigned_url_download_file_uc.exec.return_value = None + with self.assertRaises(BadRequestException): + self.service.create_presigned_url_download_file(user, dataset_id) + + def test_success_with_valid_data(self): + """Test that if the dataset is valid and aws returns something it doesnt fails""" + user = MagicMock() + user.id = 1 + dataset_id = "12345" + + self.service.create_presigned_url_download_file_uc.exec.return_value = "https://some-url" + + response = self.service.create_presigned_url_download_file(user, dataset_id) + self.assertEqual(response, "https://some-url") + + +class TestGetAnalysisDatasets(TestCase): # noqa: F821 + """Contains the test cases for getting the datasets of a given analysis""" + def setUp(self): + self.service = FileManagementServiceImpl() + self.user = User.objects.create( + name="TestName", + lastname="TestLastname", + email="test@test.com", + password="testpassword", + ) + self.org = Organization.objects.create(name="TestOrganization2") + self.workspace = Workspace.objects.create( + title="TestWorksp2ace1", + organization=self.org, + facilitator_id=self.user.id, + creator_id=self.user.id, + ) + self.test_analysis = Analysis.objects.create( + title="TestAnalysis1", + workspace_id=self.workspace.id, + end_date="2024-12-17", + creator_id=self.user.id, + ) + self.dataset = Dataset.objects.create( + filename="test.csv", + url="http://testurl/test.csv", + uploaded_by=self.user, + size_bytes=12345 + ) + self.test_analysis.datasets.add(self.dataset) + + def test_invalid_analysis_fails(self): + """Test that using an invalid analysis id raises an exception""" + invalid_analysis_id = 12345 + with self.assertRaises(NotFoundException): + self.service.get_analysis_datasets(self.user, invalid_analysis_id) + + def test_user_not_in_analysis_fails(self): + """Tests that if the user doesnt belong to the analysis it raises a forbidden exception""" + with self.assertRaises(ForbiddenException): + self.service.get_analysis_datasets(self.user, self.test_analysis.id) + + def test_valid_data(self): + """Test that if the analysis exists and the user belong to it then it return an array with datasets""" + UserAnalysisRole.objects.create( + analysis=self.test_analysis, + user=self.user, + role=Role.objects.first() + ) + response = self.service.get_analysis_datasets(self.user, self.test_analysis.id) + self.assertEqual(response[0]['id'], self.dataset.id) diff --git a/file_management/tests/test_urls.py b/file_management/tests/test_urls.py index 4ea1203..36ce2f8 100644 --- a/file_management/tests/test_urls.py +++ b/file_management/tests/test_urls.py @@ -9,6 +9,9 @@ from file_management.interfaces.controllers.create_presigned_url_upload_file_controller import ( create_presigned_url_upload_file_controller, ) +from file_management.interfaces.controllers.get_analysis_datasets_controller import ( + get_analysis_datasets_controller, +) class TestUrls(SimpleTestCase): @@ -22,4 +25,11 @@ def test_create_presigned_url_upload_files(self): def test_create_presigned_url_download_files(self): """Test that create presigned url for upload files works""" url = reverse("get_download_file_url") - self.assertEqual(resolve(url).func, create_presigned_url_download_file_controller) + self.assertEqual( + resolve(url).func, create_presigned_url_download_file_controller + ) + + def test_get_datasets_by_analysis(self): + """Test that getting the datasets from an anlysis works""" + url = reverse("get_analysis_datasets") + self.assertEqual(resolve(url).func, get_analysis_datasets_controller)