diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d1b32b2..eda8bc4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -29,7 +29,7 @@ jobs: shell: bash - name: Run Django Tests - run: docker-compose -f docker-compose-dev.yaml exec -T web pytest + run: docker-compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest - name: Tear down the Stack - run: docker-compose -f docker-compose-dev.yaml down \ No newline at end of file + run: docker-compose -f docker-compose-dev.yaml down diff --git a/cms/settings.py b/cms/settings.py index 20f82cb..fa07a5d 100644 --- a/cms/settings.py +++ b/cms/settings.py @@ -441,6 +441,13 @@ LOCAL_INSTALL = False # it is placed here so it can be overrided on local_settings.py GLOBAL_LOGIN_REQUIRED = False +# TODO: separate settings on production/development more properly, for now +# this should be ok +CELERY_TASK_ALWAYS_EAGER = False +if os.environ.get("TESTING"): + CELERY_TASK_ALWAYS_EAGER = True + + try: # keep a local_settings.py file for local overrides from .local_settings import * # noqa diff --git a/cms/urls.py b/cms/urls.py index 56dcd6b..4470006 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -1,7 +1,7 @@ import debug_toolbar -from django.conf.urls import include, url +from django.conf.urls import include, re_path from django.contrib import admin -from django.urls import path, re_path +from django.urls import path from django.views.generic.base import TemplateView from drf_yasg import openapi from drf_yasg.views import get_schema_view @@ -15,15 +15,15 @@ schema_view = get_schema_view( urlpatterns = [ - url(r"^__debug__/", include(debug_toolbar.urls)), + re_path(r"^__debug__/", include(debug_toolbar.urls)), path( "robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), ), - url(r"^", include("files.urls")), - url(r"^", include("users.urls")), - url(r"^accounts/", include("allauth.urls")), - url(r"^api-auth/", include("rest_framework.urls")), + re_path(r"^", include("files.urls")), + re_path(r"^", include("users.urls")), + re_path(r"^accounts/", include("allauth.urls")), + re_path(r"^api-auth/", include("rest_framework.urls")), path("admin/", admin.site.urls), re_path(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), diff --git a/files/tests.py b/files/tests.py deleted file mode 100644 index e69de29..0000000 diff --git a/files/tests/__init__.py b/files/tests/__init__.py new file mode 100644 index 0000000..7c4fc7f --- /dev/null +++ b/files/tests/__init__.py @@ -0,0 +1 @@ +from .user_utils import create_account # noqa diff --git a/files/tests/user_utils.py b/files/tests/user_utils.py new file mode 100644 index 0000000..3479562 --- /dev/null +++ b/files/tests/user_utils.py @@ -0,0 +1,24 @@ +from faker import Factory + +from users.models import User + +faker = Factory.create() + + +def create_account(username=None, email=None, password=None, name=None, **kwargs): + "Allow to create accounts by passing None or specific arguements" + email = email or faker.email() + username = username or email.split('a')[0] + password = password or faker.password() + name = name or faker.name() + + description = kwargs.get('description') or faker.text() + is_superuser = kwargs.get('is_superuser') or False + is_manager = kwargs.get('is_manager') or False + is_editor = kwargs.get('is_editor') or False + + user = User.objects.create(username=username, email=email, name=name, description=description, is_superuser=is_superuser, is_staff=is_superuser, is_editor=is_editor, is_manager=is_manager) + + user.set_password(password) + user.save() + return user diff --git a/files/urls.py b/files/urls.py index 55c22b6..d40f2ac 100644 --- a/files/urls.py +++ b/files/urls.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.conf.urls import include, url +from django.conf.urls import include, re_path from django.conf.urls.static import static from django.urls import path @@ -7,85 +7,85 @@ from . import management_views, views from .feeds import IndexRSSFeed, SearchRSSFeed urlpatterns = [ - url(r"^$", views.index), - url(r"^about", views.about, name="about"), - url(r"^add_subtitle", views.add_subtitle, name="add_subtitle"), - url(r"^categories$", views.categories, name="categories"), - url(r"^contact$", views.contact, name="contact"), - url(r"^edit", views.edit_media, name="edit_media"), - url(r"^embed", views.embed_media, name="get_embed"), - url(r"^featured$", views.featured_media), - url(r"^fu/", include(("uploader.urls", "uploader"), namespace="uploader")), - url(r"^history$", views.history, name="history"), - url(r"^liked$", views.liked_media, name="liked_media"), - url(r"^latest$", views.latest_media), - url(r"^members", views.members, name="members"), - url( + re_path(r"^$", views.index), + re_path(r"^about", views.about, name="about"), + re_path(r"^add_subtitle", views.add_subtitle, name="add_subtitle"), + re_path(r"^categories$", views.categories, name="categories"), + re_path(r"^contact$", views.contact, name="contact"), + re_path(r"^edit", views.edit_media, name="edit_media"), + re_path(r"^embed", views.embed_media, name="get_embed"), + re_path(r"^featured$", views.featured_media), + re_path(r"^fu/", include(("uploader.urls", "uploader"), namespace="uploader")), + re_path(r"^history$", views.history, name="history"), + re_path(r"^liked$", views.liked_media, name="liked_media"), + re_path(r"^latest$", views.latest_media), + re_path(r"^members", views.members, name="members"), + re_path( r"^playlist/(?P[\w]*)$", views.view_playlist, name="get_playlist", ), - url( + re_path( r"^playlists/(?P[\w]*)$", views.view_playlist, name="get_playlist", ), - url(r"^popular$", views.recommended_media), - url(r"^recommended$", views.recommended_media), + re_path(r"^popular$", views.recommended_media), + re_path(r"^recommended$", views.recommended_media), path("rss/", IndexRSSFeed()), - url("^rss/search", SearchRSSFeed()), - url(r"^search", views.search, name="search"), - url(r"^scpublisher", views.upload_media, name="upload_media"), - url(r"^tags", views.tags, name="tags"), - url(r"^tos$", views.tos, name="terms_of_service"), - url(r"^view", views.view_media, name="get_media"), - url(r"^upload", views.upload_media, name="upload_media"), + re_path("^rss/search", SearchRSSFeed()), + re_path(r"^search", views.search, name="search"), + re_path(r"^scpublisher", views.upload_media, name="upload_media"), + re_path(r"^tags", views.tags, name="tags"), + re_path(r"^tos$", views.tos, name="terms_of_service"), + re_path(r"^view", views.view_media, name="get_media"), + re_path(r"^upload", views.upload_media, name="upload_media"), # API VIEWS - url(r"^api/v1/media$", views.MediaList.as_view()), - url(r"^api/v1/media/$", views.MediaList.as_view()), - url( + re_path(r"^api/v1/media$", views.MediaList.as_view()), + re_path(r"^api/v1/media/$", views.MediaList.as_view()), + re_path( r"^api/v1/media/(?P[\w]*)$", views.MediaDetail.as_view(), name="api_get_media", ), - url( + re_path( r"^api/v1/media/encoding/(?P[\w]*)$", views.EncodingDetail.as_view(), name="api_get_encoding", ), - url(r"^api/v1/search$", views.MediaSearch.as_view()), - url( + re_path(r"^api/v1/search$", views.MediaSearch.as_view()), + re_path( r"^api/v1/media/(?P[\w]*)/actions$", views.MediaActions.as_view(), ), - url(r"^api/v1/categories$", views.CategoryList.as_view()), - url(r"^api/v1/tags$", views.TagList.as_view()), - url(r"^api/v1/comments$", views.CommentList.as_view()), - url( + re_path(r"^api/v1/categories$", views.CategoryList.as_view()), + re_path(r"^api/v1/tags$", views.TagList.as_view()), + re_path(r"^api/v1/comments$", views.CommentList.as_view()), + re_path( r"^api/v1/media/(?P[\w]*)/comments$", views.CommentDetail.as_view(), ), - url( + re_path( r"^api/v1/media/(?P[\w]*)/comments/(?P[\w-]*)$", views.CommentDetail.as_view(), ), - url(r"^api/v1/playlists$", views.PlaylistList.as_view()), - url(r"^api/v1/playlists/$", views.PlaylistList.as_view()), - url( + re_path(r"^api/v1/playlists$", views.PlaylistList.as_view()), + re_path(r"^api/v1/playlists/$", views.PlaylistList.as_view()), + re_path( r"^api/v1/playlists/(?P[\w]*)$", views.PlaylistDetail.as_view(), name="api_get_playlist", ), - url(r"^api/v1/user/action/(?P[\w]*)$", views.UserActions.as_view()), + re_path(r"^api/v1/user/action/(?P[\w]*)$", views.UserActions.as_view()), # ADMIN VIEWS - url(r"^api/v1/encode_profiles/$", views.EncodeProfileList.as_view()), - url(r"^api/v1/manage_media$", management_views.MediaList.as_view()), - url(r"^api/v1/manage_comments$", management_views.CommentList.as_view()), - url(r"^api/v1/manage_users$", management_views.UserList.as_view()), - url(r"^api/v1/tasks$", views.TasksList.as_view()), - url(r"^api/v1/tasks/$", views.TasksList.as_view()), - url(r"^api/v1/tasks/(?P[\w|\W]*)$", views.TaskDetail.as_view()), - url(r"^manage/comments$", views.manage_comments, name="manage_comments"), - url(r"^manage/media$", views.manage_media, name="manage_media"), - url(r"^manage/users$", views.manage_users, name="manage_users"), + re_path(r"^api/v1/encode_profiles/$", views.EncodeProfileList.as_view()), + re_path(r"^api/v1/manage_media$", management_views.MediaList.as_view()), + re_path(r"^api/v1/manage_comments$", management_views.CommentList.as_view()), + re_path(r"^api/v1/manage_users$", management_views.UserList.as_view()), + re_path(r"^api/v1/tasks$", views.TasksList.as_view()), + re_path(r"^api/v1/tasks/$", views.TasksList.as_view()), + re_path(r"^api/v1/tasks/(?P[\w|\W]*)$", views.TaskDetail.as_view()), + re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"), + re_path(r"^manage/media$", views.manage_media, name="manage_media"), + re_path(r"^manage/users$", views.manage_users, name="manage_users"), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/manage.py b/manage.py index 3fb6ad6..20e2965 100755 --- a/manage.py +++ b/manage.py @@ -4,6 +4,8 @@ import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings") + os.environ.setdefault("TESTING", "True") + try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/tests/api/test_user_login.py b/tests/api/test_user_login.py new file mode 100644 index 0000000..019a17d --- /dev/null +++ b/tests/api/test_user_login.py @@ -0,0 +1,54 @@ +from django.test import Client, TestCase +from rest_framework.authtoken.models import Token + +from files.tests import create_account + +API_V1_LOGIN_URL = '/api/v1/login' + + +class TestUserLogin(TestCase): + fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] + + def setUp(self): + self.password = 'this_is_a_fake_password' + self.user = create_account(password=self.password) + + def test_login_endpoint(self): + client = Client() + response = client.get(API_V1_LOGIN_URL) + self.assertEqual( + response.status_code, + 405, + "GET not allowed here", + ) + + response = client.post(API_V1_LOGIN_URL, {'username': 'fake', 'password': 'fake'}) + self.assertTrue('User not found' in str(response.content), 'Expected user not to be there') + + user = self.user + response = client.post(API_V1_LOGIN_URL, {'username': user.username, 'password': self.password}) + + self.assertEqual( + response.status_code, + 200, + "Expected 200", + ) + data = response.data + + self.assertEqual( + data.get('email'), + user.email, + "Expected user email", + ) + self.assertEqual( + data.get('username'), + user.username, + "Expected username", + ) + + token = Token.objects.filter(user=user).first() + self.assertEqual( + data.get('token'), + token.key, + "Expected valid token", + ) diff --git a/tests/api/test_user_token.py b/tests/api/test_user_token.py new file mode 100644 index 0000000..170cc4b --- /dev/null +++ b/tests/api/test_user_token.py @@ -0,0 +1,49 @@ +from django.test import Client, TestCase +from rest_framework.authtoken.models import Token + +from files.tests import create_account + +API_V1_USER_TOKEN_URL = '/api/v1/user/token' + + +class TestUserToken(TestCase): + fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] + + def setUp(self): + self.password = 'this_is_a_fake_password' + self.user = create_account(password=self.password) + + def test_user_token_endpoint(self): + client = Client() + response = client.get(API_V1_USER_TOKEN_URL) + self.assertEqual( + response.status_code, + 403, + "FORBIDDEN", + ) + + user = self.user + client.force_login(user=user) + + response = client.post(API_V1_USER_TOKEN_URL) + self.assertEqual( + response.status_code, + 405, + "method not allowed here", + ) + + response = client.get(API_V1_USER_TOKEN_URL) + data = response.data + + self.assertEqual( + response.status_code, + 200, + "expected 200", + ) + + token = Token.objects.filter(user=user).first() + self.assertEqual( + data.get('token'), + token.key, + "Expected valid token", + ) diff --git a/tests/api/test_user_whoami.py b/tests/api/test_user_whoami.py new file mode 100644 index 0000000..9dc9ffd --- /dev/null +++ b/tests/api/test_user_whoami.py @@ -0,0 +1,41 @@ +from django.test import Client, TestCase + +from files.tests import create_account + +API_V1_LOGIN_URL = '/api/v1/whoami' + + +class TestUserWhoami(TestCase): + fixtures = ["fixtures/categories.json", "fixtures/encoding_profiles.json"] + + def setUp(self): + self.user = create_account() + + def test_whoami_endpoint(self): + client = Client() + response = client.get(API_V1_LOGIN_URL) + self.assertEqual( + response.status_code, + 403, + "Expected 403", + ) + + user = self.user + client.force_login(user=user) + response = client.get(API_V1_LOGIN_URL) + self.assertEqual( + response.status_code, + 200, + "Expected 200", + ) + data = response.data + self.assertEqual( + data.get('description'), + user.description, + "Expected user description", + ) + self.assertEqual( + data.get('username'), + user.username, + "Expected username", + ) diff --git a/tests/users/test_sample.py b/tests/users/test_sample.py deleted file mode 100644 index e083b96..0000000 --- a/tests/users/test_sample.py +++ /dev/null @@ -1,4 +0,0 @@ -def test_new_user(user_factory): - print(user_factory.name) - print(user_factory.description) - assert True diff --git a/uploader/urls.py b/uploader/urls.py index 2e0d06a..bee9761 100644 --- a/uploader/urls.py +++ b/uploader/urls.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -from django.conf.urls import url +from django.conf.urls import re_path from . import views app_name = "uploader" urlpatterns = [ - url(r"^upload/$", views.FineUploaderView.as_view(), name="upload"), + re_path(r"^upload/$", views.FineUploaderView.as_view(), name="upload"), ] diff --git a/users/forms.py b/users/forms.py index f7cb3b0..251271c 100644 --- a/users/forms.py +++ b/users/forms.py @@ -1,5 +1,7 @@ from django import forms +from files.methods import is_mediacms_manager + from .models import Channel, User @@ -17,7 +19,6 @@ class UserForm(forms.ModelForm): fields = ( "name", "description", - "email", "logo", "notification_on_comments", "is_featured", @@ -39,7 +40,7 @@ class UserForm(forms.ModelForm): def __init__(self, user, *args, **kwargs): super(UserForm, self).__init__(*args, **kwargs) self.fields.pop("is_featured") - if not user.is_superuser: + if not is_mediacms_manager(user): self.fields.pop("advancedUser") self.fields.pop("is_manager") self.fields.pop("is_editor") diff --git a/users/serializers.py b/users/serializers.py index 259fb72..aefb580 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,4 +1,7 @@ +from django.conf import settings +from django.contrib.auth import authenticate from rest_framework import serializers +from rest_framework.authtoken.models import Token from .models import User @@ -77,3 +80,46 @@ class UserDetailSerializer(serializers.ModelSerializer): "default_channel_edit_url", ) extra_kwargs = {"name": {"required": False}} + + +class LoginSerializer(serializers.Serializer): + email = serializers.CharField(max_length=255, required=False) + username = serializers.CharField(max_length=255, required=False) + password = serializers.CharField(max_length=128, write_only=True) + token = serializers.CharField(max_length=255, required=False) + + def validate(self, data): + email = data.get('email', None) + username = data.get('username', None) + password = data.get('password', None) + + if settings.ACCOUNT_AUTHENTICATION_METHOD == 'username' and not username: + raise serializers.ValidationError('username is required to log in.') + else: + username_or_email = username + if settings.ACCOUNT_AUTHENTICATION_METHOD == 'email' and not email: + raise serializers.ValidationError('email is required to log in.') + else: + username_or_email = email + + if settings.ACCOUNT_AUTHENTICATION_METHOD == 'username_email' and not (username or email): + raise serializers.ValidationError('username or email is required to log in.') + else: + username_or_email = username or email + + if password is None: + raise serializers.ValidationError('password is required to log in.') + + user = authenticate(username=username_or_email, password=password) + + if user is None: + raise serializers.ValidationError('User not found.') + + if not user.is_active: + raise serializers.ValidationError('User has been deactivated.') + + token = Token.objects.filter(user=user).first() + if not token: + token = Token.objects.create(user=user) + + return {'email': user.email, 'username': user.username, 'token': token.key} diff --git a/users/urls.py b/users/urls.py index 82311d4..4676c49 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,41 +1,45 @@ -from django.conf.urls import url +from django.conf.urls import re_path +from django.urls import path from . import views urlpatterns = [ - url(r"^user/(?P[\w@._-]*)$", views.view_user, name="get_user"), - url(r"^user/(?P[\w@._-]*)/$", views.view_user, name="get_user"), - url( + re_path(r"^user/(?P[\w@._-]*)$", views.view_user, name="get_user"), + re_path(r"^user/(?P[\w@._-]*)/$", views.view_user, name="get_user"), + re_path( r"^user/(?P[\w@.]*)/media$", views.view_user_media, name="get_user_media", ), - url( + re_path( r"^user/(?P[\w@.]*)/playlists$", views.view_user_playlists, name="get_user_playlists", ), - url( + re_path( r"^user/(?P[\w@.]*)/about$", views.view_user_about, name="get_user_about", ), - url(r"^user/(?P[\w@.]*)/edit$", views.edit_user, name="edit_user"), - url(r"^channel/(?P[\w]*)$", views.view_channel, name="view_channel"), - url( + re_path(r"^user/(?P[\w@.]*)/edit$", views.edit_user, name="edit_user"), + re_path(r"^channel/(?P[\w]*)$", views.view_channel, name="view_channel"), + re_path( r"^channel/(?P[\w]*)/edit$", views.edit_channel, name="edit_channel", ), # API VIEWS - url(r"^api/v1/users$", views.UserList.as_view(), name="api_users"), - url(r"^api/v1/users/$", views.UserList.as_view()), - url( + path('api/v1/whoami', views.UserWhoami.as_view(), name='user-whoami'), + path('api/v1/user/token', views.UserToken.as_view(), name='user-token'), + path('api/v1/login', views.LoginView.as_view(), name='user-login'), + re_path(r"^api/v1/users$", views.UserList.as_view(), name="api_users"), + re_path(r"^api/v1/users/$", views.UserList.as_view()), + re_path( r"^api/v1/users/(?P[\w@._-]*)$", views.UserDetail.as_view(), name="api_get_user", ), - url( + re_path( r"^api/v1/users/(?P[\w@._-]*)/contact", views.contact_user, name="api_contact_user", diff --git a/users/views.py b/users/views.py index 7f2478a..dcf9b31 100644 --- a/users/views.py +++ b/users/views.py @@ -5,7 +5,8 @@ from django.http import HttpResponseRedirect from django.shortcuts import render from drf_yasg import openapi as openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import permissions, status +from rest_framework import generics, permissions, status +from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view from rest_framework.exceptions import PermissionDenied from rest_framework.parsers import ( @@ -23,7 +24,7 @@ from files.methods import is_mediacms_editor, is_mediacms_manager from .forms import ChannelForm, UserForm from .models import Channel, User -from .serializers import UserDetailSerializer, UserSerializer +from .serializers import LoginSerializer, UserDetailSerializer, UserSerializer def get_user(username): @@ -305,3 +306,65 @@ class UserDetail(APIView): user.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class UserWhoami(generics.RetrieveAPIView): + parser_classes = (JSONParser, FormParser, MultiPartParser) + queryset = User.objects.all() + permission_classes = (permissions.IsAuthenticated,) + serializer_class = UserDetailSerializer + + def get_object(self): + return User.objects.get(id=self.request.user.id) + + @swagger_auto_schema( + tags=['Users'], + operation_summary='Whoami user information', + operation_description='Whoami user information', + responses={200: openapi.Response('response description', UserDetailSerializer), 403: 'Forbidden'}, + ) + def get(self, request, *args, **kwargs): + return super(UserWhoami, self).get(request, *args, **kwargs) + + +class UserToken(APIView): + parser_classes = (JSONParser,) + permission_classes = (permissions.IsAuthenticated,) + + @swagger_auto_schema( + tags=['Users'], + operation_summary='Get a user token', + operation_description="Returns an authenticated user's token", + responses={200: 'token', 403: 'Forbidden'}, + ) + def get(self, request, *args, **kwargs): + token = Token.objects.filter(user=request.user).first() + if not token: + token = Token.objects.create(user=request.user) + + return Response({'token': str(token)}, status=200) + + +class LoginView(APIView): + permission_classes = (permissions.AllowAny,) + serializer_class = LoginSerializer + parser_classes = (MultiPartParser, FormParser, FileUploadParser) + + @swagger_auto_schema( + tags=['Users'], + operation_summary='Login url', + operation_description="Login url endpoint. According to what the portal provides, you may provide username and/or email, plus the password", + manual_parameters=[ + openapi.Parameter(name="username", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="username"), + openapi.Parameter(name="email", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="email"), + openapi.Parameter(name="password", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=True, description="password"), + ], + responses={200: openapi.Response('user details', LoginSerializer), 404: 'Bad request'}, + ) + def post(self, request): + data = request.data + + serializer = self.serializer_class(data=data) + serializer.is_valid(raise_exception=True) + + return Response(serializer.data, status=status.HTTP_200_OK)