diff --git a/DEPLOYING.md b/DEPLOYING.md index eda5dcaf..144f7fcf 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -45,6 +45,7 @@ The following are the steps to perform a deployment in production. In case you w - `CGDS_CHUNK_SIZE`: size **in bytes** of the chunk in which the files of a CGDS study are downloaded, the bigger it is, the faster the download is, but the more server memory it consumes. Default `2097152`, i.e. 2MB. - `THRESHOLD_ORDINAL`: number of different values for the GEM (CNA) information to be considered ordinal, if the number is <= to this value then it is considered categorical/ordinal and a boxplot is displayed, otherwise, it is considered continuous and the common correlation graph is displayed. Default `5`. - `THRESHOLD_GEM_SIZE_TO_COLLECT`: GEM file size threshold (in MB) for the GEM dataset to be available in memory. This has a HUGE impact on the performance of the analysis. If the size is less than or equal to this threshold, it is allocated in memory, otherwise, it will be read lazily from the disk. If None GGCA automatically allocates in memory when the GEM dataset size is small (<= 100MB). Therefore, if you want to force to always use RAM to improve performance you should set a very high threshold, on the contrary, if you want a minimum memory usage at the cost of poor performance, set it to `0`. Default `None`. + - `MIN_PASSWORD_LEN`: Defines the minimum required length for user passwords when updating their profile. If the provided password is shorter than this length, the update will be rejected. Default `8`. - PostgreSQL: - `POSTGRES_USERNAME`: PostgreSQL connection username. **Must be equal to** `POSTGRES_USER`. - `POSTGRES_PASSWORD`: PostgreSQL connection password. **Must be equal to** `POSTGRES_PASSWORD`. diff --git a/src/frontend/serializers.py b/src/frontend/serializers.py deleted file mode 100644 index a36cb4a0..00000000 --- a/src/frontend/serializers.py +++ /dev/null @@ -1,31 +0,0 @@ -from rest_framework import serializers -from django.contrib.auth import get_user_model -from institutions.models import Institution - - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = get_user_model() - fields = ['id', 'username', 'is_superuser'] - - def to_representation(self, instance): - # Adds custom fields - data = super(UserSerializer, self).to_representation(instance) - - # Check if current user has any institutions where he's admin - data['is_institution_admin'] = Institution.objects.filter( - institutionadministration__user=instance, - institutionadministration__is_institution_admin=True - ).exists() - - # User at this point is not anonymous - data['is_anonymous'] = False - - return data - - -class UserSimpleSerializer(serializers.ModelSerializer): - """User serializer with fewer data""" - class Meta: - model = get_user_model() - fields = ['id', 'username'] diff --git a/src/frontend/urls.py b/src/frontend/urls.py index 1aaa0b6e..256915fc 100644 --- a/src/frontend/urls.py +++ b/src/frontend/urls.py @@ -1,21 +1,12 @@ -from django.conf import settings from django.urls import path from . import views urlpatterns = [ path('', views.index_action, name='index'), path('gem', views.gem_action, name='gem'), - path('login', views.login_action, name='login'), path('my-datasets', views.datasets_action, name='datasets'), path('survival', views.survival_action, name='survival'), - path('authenticate', views.authenticate_action, name='authentication'), - path('create-user', views.create_user_action, name='create_user'), path('about-us', views.about_us_action, name='about_us'), - path('site-policy', views.terms_and_privacy_policy_action, name='site_policy'), - path('logout', views.logout_action, name='logout'), - path('user', views.CurrentUserView.as_view(), name='current_user') + path('site-policy', views.terms_and_privacy_policy_action, name='site_policy') ] -if settings.DEBUG: - urlpatterns.append(path('new-user-email-test', views.test_new_user_email)) - urlpatterns.append(path('confirmation-email-test', views.test_confirmation_email)) diff --git a/src/frontend/views.py b/src/frontend/views.py index f8fc9d65..68914f4c 100644 --- a/src/frontend/views.py +++ b/src/frontend/views.py @@ -1,17 +1,6 @@ -import logging -from typing import Optional, Union -from django.contrib.auth.base_user import AbstractBaseUser -from django.shortcuts import render, redirect -from django.contrib.auth import authenticate, logout, login -from django.contrib.auth.models import User +from django.shortcuts import render from django.contrib.auth.decorators import login_required -from django_email_verification import sendConfirm -from rest_framework import permissions -from rest_framework.response import Response -from rest_framework.views import APIView -from .serializers import UserSerializer from django.conf import settings -from django.db import transaction, InternalError def index_action(request): @@ -52,169 +41,3 @@ def datasets_action(request): def survival_action(request): """Survival Analysis view""" return render(request, "frontend/survival.html") - - -def login_action(request): - """Login view""" - if request.user.is_authenticated: - return redirect('index') - - return render(request, "frontend/login.html") - - -def view_bad_credentials(request): - """Returns login template with bad credential error in Its context""" - return render(request, "frontend/login.html", {'loginError': 'Your username or password is invalid'}) - - -def authenticate_action(request): - """Authentication view""" - username: Optional[str] = request.POST.get('username') - password: Optional[str] = request.POST.get('password') - if username is None or password is None: - return view_bad_credentials(request) - - user: Union[AbstractBaseUser, AbstractBaseUser, None] = authenticate(username=username, password=password) - - if user is not None: - login(request, user) - # If 'next' param is specified, it redirects to that URL - next_url = request.POST.get('next') - return redirect(next_url if next_url is not None else 'index') - else: - # Checks if User is active - try: - user: User = User.objects.get(username=username) - if user is not None and not user.is_active: - return render( - request, - "frontend/login.html", - { - 'loginWarning': 'Your account has not been validated yet. Please, check your email account for ' - 'our verification email' - } - ) - return view_bad_credentials(request) - except User.DoesNotExist: - return view_bad_credentials(request) - - -def is_sign_up_form_valid(username: str, email: str, password: str, password_repeated: str) -> bool: - """ - Checks if Sign Up form is valid - @param username: Username to check - @param email: Email to check - @param password: Password to check - @param password_repeated: Repeated password to check - @return: True if is all the field valid. False otherwise - """ - password_striped = password.strip() - password_repeated_striped = password_repeated.strip() - return len(username.strip()) > 0 \ - and len(email.strip()) > 0 \ - and len(password_striped) > 0 \ - and len(password_repeated_striped) > 0 \ - and password_striped == password_repeated_striped - - -def user_already_exists_username(username: str) -> bool: - """ - Check if exists any user in the DB with the same username passed by parameter - @param username: Username to check - @return: True if exists the user. False otherwise - """ - return User.objects.filter(username=username).exists() - - -def user_already_exists_email(email: str) -> bool: - """ - Check if exists any user in the DB with the same email passed by parameter - @param email: Email to check - @return: True if exists the user. False otherwise - """ - return User.objects.filter(email=email).exists() - - -@transaction.atomic -def create_user_action(request): - """User creation view""" - username: str = request.POST.get('newUsername', '') - email: str = request.POST.get('email', '') - password: str = request.POST.get('newPassword', '') - password_repeated: str = request.POST.get('newPasswordRepeated', '') - if not is_sign_up_form_valid(username, email, password, password_repeated): - return render(request, "frontend/login.html", {'loginError': 'Invalid fields'}) - - if user_already_exists_username(username): - return render(request, "frontend/login.html", {'loginError': 'There is already a user with that username'}) - - if user_already_exists_email(email): - return render(request, "frontend/login.html", {'loginError': 'There is already a user with that email'}) - - # If it's set to not send an email asking for confirmation just creates the user and redirect to login - if not settings.EMAIL_NEW_USER_CONFIRMATION_ENABLED: - User.objects.create_user(username, email, password) - return render(request, "frontend/login.html", {'loginSuccess': 'User created successfully'}) - - # Creates the user and sends email - # FIXME: library does not return exceptions thrown in sender thread. After a PR it should be tested - error_sending_email = False - try: - with transaction.atomic(): - user = User.objects.create_user(username, email, password) - try: - sendConfirm(user) - except Exception as ex: - logging.warning(f'Error sending email -> {ex}') - raise InternalError # Rollback - except InternalError: - error_sending_email = True - - if error_sending_email: - return render(request, "frontend/login.html", {'loginError': 'An error have occurred. Please, try again. If the' - ' problem persists contact us, please'}) - - # Hides email - idx_at = email.index('@') - idx_to_replace = idx_at - 2 - hidden_email = '*' * idx_to_replace + email[idx_to_replace:] - - return render( - request, - "frontend/login.html", - {'loginWarning': f'We have sent a confirmation email to {hidden_email}'} - ) - - -def test_new_user_email(request): - """ - To test new user email template in browser. Only accessible in DEBUG mode - @param request: Request object - @return: HTTP Response - """ - return render(request, settings.EMAIL_MAIL_HTML, {'link': '#'}) - - -def test_confirmation_email(request): - """ - To test confirmation email template in browser. Only accessible in DEBUG mode - @param request: Request object - @return: HTTP Response - """ - return render(request, settings.EMAIL_PAGE_TEMPLATE, {'success': True}) - - -def logout_action(request): - """Closes User session""" - logout(request) - return redirect('index') - - -class CurrentUserView(APIView): - """Gets current User info""" - permission_classes = [permissions.IsAuthenticated] - - @staticmethod - def get(request): - serializer = UserSerializer(request.user) - return Response(serializer.data) diff --git a/src/institutions/serializers.py b/src/institutions/serializers.py index a2ce6426..629cdc4a 100644 --- a/src/institutions/serializers.py +++ b/src/institutions/serializers.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import Institution -from frontend.serializers import UserSerializer +from users.serializers import UserSerializer class InstitutionSerializer(serializers.ModelSerializer): diff --git a/src/multiomics_intermediate/settings.py b/src/multiomics_intermediate/settings.py index 3718507c..182a0c45 100644 --- a/src/multiomics_intermediate/settings.py +++ b/src/multiomics_intermediate/settings.py @@ -62,6 +62,7 @@ 'inferences', 'molecules_details', 'chunked_upload', + 'users', ] MIDDLEWARE = [ @@ -151,6 +152,8 @@ }, ] +MIN_PASSWORD_LEN: int = int(os.getenv('MIN_PASSWORD_LEN', 8)) + # Internationalization # https://docs.djangoproject.com/en/3.0/topics/i18n/ @@ -296,7 +299,6 @@ EMAIL_PAGE_TEMPLATE = 'confirm_template.html' EMAIL_PAGE_DOMAIN = 'https://multiomix.org' - # Modulector settings MODULECTOR_SETTINGS = { 'host': os.getenv('MODULECTOR_HOST', 'modulector.multiomix.org'), diff --git a/src/multiomics_intermediate/urls.py b/src/multiomics_intermediate/urls.py index f277e55f..e5029a6b 100644 --- a/src/multiomics_intermediate/urls.py +++ b/src/multiomics_intermediate/urls.py @@ -36,4 +36,5 @@ path('molecules/', include('molecules_details.urls')), path('admin/', admin.site.urls), path('email/', include(mail_urls)), + path('users/', include('users.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/src/user_files/serializers.py b/src/user_files/serializers.py index f6b78b5d..b957cbe0 100644 --- a/src/user_files/serializers.py +++ b/src/user_files/serializers.py @@ -5,12 +5,12 @@ from rest_framework import serializers from common.functions import get_enum_from_value, create_survival_columns_from_json from datasets_synchronization.models import SurvivalColumnsTupleUserFile -from .models_choices import FileType -from .utils import has_uploaded_file_valid_format, get_invalid_format_response -from frontend.serializers import UserSimpleSerializer +from user_files.models_choices import FileType +from user_files.utils import has_uploaded_file_valid_format, get_invalid_format_response +from users.serializers import UserSimpleSerializer from institutions.serializers import InstitutionSimpleSerializer from tags.serializers import TagSerializer -from .models import UserFile +from user_files.models import UserFile class SurvivalColumnsTupleUserFileSimpleSerializer(serializers.ModelSerializer): diff --git a/src/user_files/views.py b/src/user_files/views.py index 7aeff2ef..e3b9cd19 100644 --- a/src/user_files/views.py +++ b/src/user_files/views.py @@ -11,7 +11,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.views import APIView from common.pagination import StandardResultsSetPagination -from .serializers import UserFileSerializer, UserFileWithoutFileObjSerializer +from user_files.serializers import UserFileSerializer, UserFileWithoutFileObjSerializer from .models import UserFile from rest_framework.response import Response diff --git a/src/users/admin.py b/src/users/admin.py new file mode 100644 index 00000000..e69de29b diff --git a/src/users/apps.py b/src/users/apps.py new file mode 100644 index 00000000..72b14010 --- /dev/null +++ b/src/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' diff --git a/src/users/migrations/__init__.py b/src/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/users/models.py b/src/users/models.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/users/models.py @@ -0,0 +1 @@ + diff --git a/src/users/serializers.py b/src/users/serializers.py new file mode 100644 index 00000000..d3ea7a20 --- /dev/null +++ b/src/users/serializers.py @@ -0,0 +1,67 @@ +from django.forms import ValidationError +from rest_framework import serializers +from django.contrib.auth import get_user_model +from institutions.models import Institution +from django.db import transaction +from django.contrib.auth.hashers import make_password + +from multiomics_intermediate import settings + + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['id', 'username', 'is_superuser', 'first_name', 'last_name'] + + def to_representation(self, instance): + # Adds custom fields + data = super(UserSerializer, self).to_representation(instance) + + # Check if current user has any institutions where he's admin + data['is_institution_admin'] = Institution.objects.filter( + institutionadministration__user=instance, + institutionadministration__is_institution_admin=True + ).exists() + + # User at this point is not anonymous + data['is_anonymous'] = False + + return data + + +class UserSimpleSerializer(serializers.ModelSerializer): + """User serializer with fewer data""" + class Meta: + model = get_user_model() + fields = ['id', 'username'] + +class UserUpdateSerializer(serializers.ModelSerializer): + """"fields that are received to update the user""" + first_name = serializers.CharField() + last_name = serializers.CharField() + password = serializers.CharField(write_only=True, required=False, min_length=8) + + class Meta: + model = get_user_model() + fields = ['first_name', 'last_name', 'password'] + + def update(self, instance, validated_data): + """Updates the users first_name, last_name, and password fields.""" + with transaction.atomic(): + instance.first_name = validated_data.get('first_name', instance.first_name) + instance.last_name = validated_data.get('last_name', instance.last_name) + + password = validated_data.pop('password', '') + password_length = len(password) + + if password_length != 0: + minimum_password_len = settings.MIN_PASSWORD_LEN + if password_length < minimum_password_len: + raise ValidationError(f'Password must be at least {minimum_password_len} chars long') + else: + instance.set_password(password) + + instance.save() + return instance + \ No newline at end of file diff --git a/src/users/urls.py b/src/users/urls.py new file mode 100644 index 00000000..ae789c04 --- /dev/null +++ b/src/users/urls.py @@ -0,0 +1,16 @@ +from django.conf import settings +from django.urls import path +from users import views + +urlpatterns = [ + path('login', views.login_action, name='login'), + path('authenticate', views.authenticate_action, name='authentication'), + path('create-user', views.create_user_action, name='create_user'), + path('logout', views.logout_action, name='logout'), + path('user', views.CurrentUserView.as_view(), name='current_user'), + path('edit-profile', views.UserRetrieveUpdateView.as_view(), name='update_user') +] + +if settings.DEBUG: + urlpatterns.append(path('new-user-email-test', views.test_new_user_email)) + urlpatterns.append(path('confirmation-email-test', views.test_confirmation_email)) \ No newline at end of file diff --git a/src/users/views.py b/src/users/views.py new file mode 100644 index 00000000..fdfbea18 --- /dev/null +++ b/src/users/views.py @@ -0,0 +1,188 @@ +import logging +from typing import Optional, Union +from django.contrib.auth.base_user import AbstractBaseUser +from django.shortcuts import render, redirect +from django.contrib.auth import authenticate, logout, login +from django.contrib.auth.models import User +from django_email_verification import sendConfirm +from rest_framework import permissions, generics +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView +from users.serializers import UserSerializer, UserUpdateSerializer +from django.conf import settings +from django.db import transaction, InternalError + + +def login_action(request): + """Login view""" + if request.user.is_authenticated: + return redirect('index') + + return render(request, "frontend/login.html") + + +def view_bad_credentials(request): + """Returns login template with bad credential error in Its context""" + return render(request, "frontend/login.html", {'loginError': 'Your username or password is invalid'}) + + +def authenticate_action(request): + """Authentication view""" + username: Optional[str] = request.POST.get('username') + password: Optional[str] = request.POST.get('password') + if username is None or password is None: + return view_bad_credentials(request) + + user: Union[AbstractBaseUser, AbstractBaseUser, None] = authenticate(username=username, password=password) + + if user is not None: + login(request, user) + # If 'next' param is specified, it redirects to that URL + next_url = request.POST.get('next') + return redirect(next_url if next_url is not None else 'index') + else: + # Checks if User is active + try: + user: User = User.objects.get(username=username) + if user is not None and not user.is_active: + return render( + request, + "frontend/login.html", + { + 'loginWarning': 'Your account has not been validated yet. Please, check your email account for ' + 'our verification email' + } + ) + return view_bad_credentials(request) + except User.DoesNotExist: + return view_bad_credentials(request) + + +def is_sign_up_form_valid(username: str, email: str, password: str, password_repeated: str) -> bool: + """ + Checks if Sign Up form is valid + @param username: Username to check + @param email: Email to check + @param password: Password to check + @param password_repeated: Repeated password to check + @return: True if is all the field valid. False otherwise + """ + password_striped = password.strip() + password_repeated_striped = password_repeated.strip() + return len(username.strip()) > 0 \ + and len(email.strip()) > 0 \ + and len(password_striped) > 0 \ + and len(password_repeated_striped) > 0 \ + and password_striped == password_repeated_striped + + +def user_already_exists_username(username: str) -> bool: + """ + Check if exists any user in the DB with the same username passed by parameter + @param username: Username to check + @return: True if exists the user. False otherwise + """ + return User.objects.filter(username=username).exists() + + +def user_already_exists_email(email: str) -> bool: + """ + Check if exists any user in the DB with the same email passed by parameter + @param email: Email to check + @return: True if exists the user. False otherwise + """ + return User.objects.filter(email=email).exists() + + +@transaction.atomic +def create_user_action(request): + """User creation view""" + username: str = request.POST.get('newUsername', '') + email: str = request.POST.get('email', '') + password: str = request.POST.get('newPassword', '') + password_repeated: str = request.POST.get('newPasswordRepeated', '') + if not is_sign_up_form_valid(username, email, password, password_repeated): + return render(request, "frontend/login.html", {'loginError': 'Invalid fields'}) + + if user_already_exists_username(username): + return render(request, "frontend/login.html", {'loginError': 'There is already a user with that username'}) + + if user_already_exists_email(email): + return render(request, "frontend/login.html", {'loginError': 'There is already a user with that email'}) + + # If it's set to not send an email asking for confirmation just creates the user and redirect to login + if not settings.EMAIL_NEW_USER_CONFIRMATION_ENABLED: + User.objects.create_user(username, email, password) + return render(request, "frontend/login.html", {'loginSuccess': 'User created successfully'}) + + # Creates the user and sends email + # FIXME: library does not return exceptions thrown in sender thread. After a PR it should be tested + error_sending_email = False + try: + with transaction.atomic(): + user = User.objects.create_user(username, email, password) + try: + sendConfirm(user) + except Exception as ex: + logging.warning(f'Error sending email -> {ex}') + raise InternalError # Rollback + except InternalError: + error_sending_email = True + + if error_sending_email: + return render(request, "frontend/login.html", {'loginError': 'An error have occurred. Please, try again. If the' + ' problem persists contact us, please'}) + + # Hides email + idx_at = email.index('@') + idx_to_replace = idx_at - 2 + hidden_email = '*' * idx_to_replace + email[idx_to_replace:] + + return render( + request, + "frontend/login.html", + {'loginWarning': f'We have sent a confirmation email to {hidden_email}'} + ) + + +def test_new_user_email(request): + """ + To test new user email template in browser. Only accessible in DEBUG mode + @param request: Request object + @return: HTTP Response + """ + return render(request, settings.EMAIL_MAIL_HTML, {'link': '#'}) + + +def test_confirmation_email(request): + """ + To test confirmation email template in browser. Only accessible in DEBUG mode + @param request: Request object + @return: HTTP Response + """ + return render(request, settings.EMAIL_PAGE_TEMPLATE, {'success': True}) + + +def logout_action(request): + """Closes User session""" + logout(request) + return redirect('index') + + +class CurrentUserView(APIView): + """Gets current User info""" + permission_classes = [permissions.IsAuthenticated] + + @staticmethod + def get(request): + serializer = UserSerializer(request.user) + return Response(serializer.data) + + +class UserRetrieveUpdateView(generics.RetrieveUpdateAPIView): + serializer_class = UserUpdateSerializer + permission_classes = [IsAuthenticated] + + def get_object(self): + return self.request.user