diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b8add87..9d6c6e2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,5 +2,5 @@ I have completed the following tasks -- [ ] Basic endpoints -- [ ] Collaborator feature +- [x] Basic endpoints +- [x] Collaborator feature diff --git a/Pipfile b/Pipfile index a6563bd..3e49bbb 100644 --- a/Pipfile +++ b/Pipfile @@ -14,4 +14,4 @@ gunicorn = "*" drf-yasg = "*" [requires] -python_version = "3.8" +python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index f5965b1..15227a3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "90a78ef190530d701fb120f1af60525e7c1eeab94face596902fdd03cc19a9db" + "sha256": "51fcca81c5defd78ec8a5dc6b087c0bb6f9ae6d911a5135ba638a670d8577393" }, "pipfile-spec": 6, "requires": { - "python_version": "3.8" + "python_version": "3.6" }, "sources": [ { diff --git a/api/migrations/0003_todo_collaborators.py b/api/migrations/0003_todo_collaborators.py new file mode 100644 index 0000000..53c3493 --- /dev/null +++ b/api/migrations/0003_todo_collaborators.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.6 on 2020-05-12 14:37 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0002_todo_creator'), + ] + + operations = [ + migrations.AddField( + model_name='todo', + name='collaborators', + field=models.ManyToManyField(related_name='collaborators', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/api/migrations/0004_auto_20200512_2008.py b/api/migrations/0004_auto_20200512_2008.py new file mode 100644 index 0000000..71c10af --- /dev/null +++ b/api/migrations/0004_auto_20200512_2008.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.6 on 2020-05-12 14:38 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0003_todo_collaborators'), + ] + + operations = [ + migrations.AlterField( + model_name='todo', + name='collaborators', + field=models.ManyToManyField(null=True, related_name='collaborators', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/api/migrations/0005_auto_20200512_2010.py b/api/migrations/0005_auto_20200512_2010.py new file mode 100644 index 0000000..fc5030d --- /dev/null +++ b/api/migrations/0005_auto_20200512_2010.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.6 on 2020-05-12 14:40 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0004_auto_20200512_2008'), + ] + + operations = [ + migrations.AlterField( + model_name='todo', + name='collaborators', + field=models.ManyToManyField(related_name='collaborators', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/api/models.py b/api/models.py index ab3ca0a..4807f5d 100644 --- a/api/models.py +++ b/api/models.py @@ -5,6 +5,6 @@ class Todo(models.Model): creator = models.ForeignKey(User, on_delete=models.CASCADE) title = models.CharField(max_length=255) - + collaborators = models.ManyToManyField(User,related_name='collaborators') def __str__(self): return self.title \ No newline at end of file diff --git a/api/permissions.py b/api/permissions.py new file mode 100644 index 0000000..d94903b --- /dev/null +++ b/api/permissions.py @@ -0,0 +1,8 @@ +from rest_framework import permissions + +class IsOwnerOrCollaborator(permissions.BasePermission): + """ + custom permission to allow only creator and collaborator of a todo to view, edit and delete it. + """ + def has_object_permission(self, request, view, obj): + return (obj.creator == request.user) or (request.user in obj.collaborators.all()) \ No newline at end of file diff --git a/api/serializers.py b/api/serializers.py index ffd9d3a..353e0f5 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from .models import Todo - +from django.contrib.auth.models import User """ TODO: @@ -14,7 +14,6 @@ class TodoCreateSerializer(serializers.ModelSerializer): TODO: Currently, the /todo/create/ endpoint returns only 200 status code, after successful Todo creation. - Modify the below code (if required), so that this endpoint would also return the serialized Todo data (id etc.), alongwith 200 status code. """ @@ -23,7 +22,30 @@ def save(self, **kwargs): user = self.context['request'].user title = data['title'] todo = Todo.objects.create(creator=user, title=title) + return todo class Meta: model = Todo fields = ('id', 'title',) + +class UserObjectSerializer(serializers.ModelSerializer): + + class Meta: + model = User + fields = ['username'] + +class TodoSerializer(serializers.ModelSerializer): + collaborators = UserObjectSerializer(many=True, required = False) + creator = UserObjectSerializer(required = False) + class Meta: + model = Todo + fields = ['id', 'title', 'creator', 'collaborators',] +class TodoUpateSerializer(serializers.ModelSerializer): + class Meta: + model = Todo + fields = ('id', 'title',) +class CollaboratorSerializer(serializers.ModelSerializer): + collaborators = UserObjectSerializer(many=True) + class Meta: + model = Todo + fields = ['collaborators'] \ No newline at end of file diff --git a/api/urls.py b/api/urls.py index 72f4978..0332e8c 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,12 +1,18 @@ from django.urls import path -from .views import TodoCreateView +from .views import TodoCreateView, TodoViewSet, CollaboratorViewSet """ TODO: Add the urlpatterns of the endpoints, required for implementing Todo GET (List and Detail), PUT, PATCH and DELETE. """ +from rest_framework import routers + +router = routers.DefaultRouter() +router.register(r'^todo', TodoViewSet) +router.register(r'^todo', CollaboratorViewSet) urlpatterns = [ path('todo/create/', TodoCreateView.as_view()), -] \ No newline at end of file +] +urlpatterns+=router.urls \ No newline at end of file diff --git a/api/views.py b/api/views.py index d676c64..e46fb25 100644 --- a/api/views.py +++ b/api/views.py @@ -1,11 +1,16 @@ +from rest_framework.authtoken.models import Token from rest_framework import generics from rest_framework import permissions from rest_framework import status from rest_framework.response import Response -from .serializers import TodoCreateSerializer +from .serializers import TodoCreateSerializer, TodoSerializer, CollaboratorSerializer, TodoUpateSerializer +from rest_framework import viewsets from .models import Todo - - +from django.contrib.auth.models import User +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework import mixins +from .permissions import IsOwnerOrCollaborator """ TODO: Create the appropriate View classes for implementing @@ -18,7 +23,6 @@ class TodoCreateView(generics.GenericAPIView): TODO: Currently, the /todo/create/ endpoint returns only 200 status code, after successful Todo creation. - Modify the below code (if required), so that this endpoint would also return the serialized Todo data (id etc.), alongwith 200 status code. """ @@ -30,6 +34,111 @@ def post(self, request): Creates a Todo entry for the logged in user. """ serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(status=status.HTTP_200_OK) + if serializer.is_valid(raise_exception=True): + instance = serializer.save() + return Response({ + 'id':instance.id, + 'title':instance.title, + }, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status.HTTP_400_BAD_REQUEST) + +class TodoViewSet(mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): + permission_classes = (permissions.IsAuthenticated, IsOwnerOrCollaborator) + queryset = Todo.objects.all() + serializer_class = TodoSerializer + def get_serializer_class(self): + if self.action == 'update' or self.action == 'partial_update': + return TodoUpateSerializer + return TodoSerializer + def list(self, request, *args, **kwargs): + queryset1 = Todo.objects.filter(creator=request.user) + queryset2 = Todo.objects.filter(collaborators=request.user) + queryset = (queryset1 | queryset2).distinct() + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class CollaboratorViewSet(viewsets.ModelViewSet): + queryset = Todo.objects.all() + serializer_class = CollaboratorSerializer + @action(methods=['put'], detail=True, permission_classes=[permissions.IsAuthenticated, ], url_path='add-collaborator', url_name='add_collaborator') + def add_collaborator(self, request, pk=None): + try: + todo = Todo.objects.get(pk=pk) + except Todo.DoesNotExist: + return Response({'Error':'Invalid Todo Id'}, status=status.HTTP_400_BAD_REQUEST) + else: + if request.user == todo.creator: + try: + collborators = request.data["collaborators"] + except KeyError: + return Response({'Error':'Collaborator field not specified in request'}, status = status.HTTP_400_BAD_REQUEST) + else: + collab_list = [] + b=False + for collab in collborators: + try: + collab_name = collab['username'] + except KeyError: + return Response({'Error':'No username provided'}, status = status.HTTP_400_BAD_REQUEST) + else: + try: + new_collab = User.objects.get(username=collab_name) + except User.DoesNotExist: + return Response({'Invalid username':collab_name}, status=status.HTTP_400_BAD_REQUEST) + if new_collab and new_collab != todo.creator: + collab_list.append(new_collab) + else: + b=True + todo.collaborators.add(*collab_list) + todo.save() + if b: + return Response({'Denied':'Creator can not be added as collaborator'}, status=status.HTTP_206_PARTIAL_CONTENT) + else: + return Response({'Success':'Added collaborators successfully'}, status=status.HTTP_200_OK) + + else: + raise PermissionDenied("You are not the creator of this Todo.") + @action(methods=['patch'], detail=True, permission_classes=[permissions.IsAuthenticated, ], url_path='delete-collaborator', url_name='delete_collaborator') + def delete_collaborator(self, request, pk=None): + try: + todo = Todo.objects.get(pk=pk) + except Todo.DoesNotExist: + return Response({"Error":'Invalid Todo Id'}, status=status.HTTP_400_BAD_REQUEST) + if request.user == todo.creator: + try: + collborators = request.data["collaborators"] + except KeyError: + return Response({'Error':'Collaborator field not specified in request'}, status = status.HTTP_400_BAD_REQUEST) + error_list = [] + collab_list = [] + collab_obj_list = todo.collaborators.all() + for collab in collab_obj_list: + collab_list.append(collab.username) + for collab in collborators: + try: + collab_name = collab['username'] + except KeyError: + return Response({'Error':'No username provided'}, status = status.HTTP_400_BAD_REQUEST) + try: + collab_rem_obj = User.objects.get(username=collab_name) + except User.DoesNotExist: + return Response({'Invalid username':collab_name}, status=status.HTTP_400_BAD_REQUEST) + if (collab_name in collab_list) and (collab_rem_obj): + todo.collaborators.remove(collab_rem_obj) + collab_list.remove(collab_name) + todo.save() + else: + error_list.append(collab_name) + if len(error_list): + return Response({"Following name(s) were not collaborating to the task":error_list}, status = status.HTTP_206_PARTIAL_CONTENT) + else: + return Response({"success":'Removed all the specified collaborators'}, status=status.HTTP_204_NO_CONTENT) + else: + raise PermissionDenied("You are not the creator of this Todo.") + \ No newline at end of file diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..6c11d37 --- /dev/null +++ b/authentication/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.6 on 2020-05-10 11:38 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('authtoken', '0002_auto_20160226_1747'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150)), + ('token', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='authtoken.Token')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/authentication/migrations/0002_delete_profile.py b/authentication/migrations/0002_delete_profile.py new file mode 100644 index 0000000..5dcbdf6 --- /dev/null +++ b/authentication/migrations/0002_delete_profile.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.6 on 2020-05-11 06:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.DeleteModel( + name='Profile', + ), + ] diff --git a/authentication/serializers.py b/authentication/serializers.py index 47264af..2320baf 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -1,23 +1,40 @@ from rest_framework import serializers from django.contrib.auth import authenticate from django.contrib.auth.models import User - - +from rest_framework.authtoken.models import Token class TokenSerializer(serializers.Serializer): token = serializers.CharField(max_length=500) class LoginSerializer(serializers.Serializer): # TODO: Implement login functionality - pass + username = serializers.CharField(required=True, allow_blank=False) + password = serializers.CharField(required=True, allow_blank=False) -class RegisterSerializer(serializers.Serializer): - # TODO: Implement register functionality - pass - +class RegisterSerializer(serializers.ModelSerializer): + # TODO: Implement register functionality + name = serializers.CharField(source='first_name', max_length=150) + email = serializers.EmailField(max_length=255, required = True, allow_blank=False) + class Meta: + model = User + fields = ['name', 'email', 'username', 'password'] + extra_kwargs = {'password':{'write_only':True}} + def create(self, validated_data): + user = User( + first_name = validated_data['first_name'], + username = validated_data['username'], + email = validated_data['email'], + password = validated_data['password'], + ) + user.set_password((validated_data['password'])) + user.save() + return user + class UserSerializer(serializers.ModelSerializer): # TODO: Implement the functionality to display user details - pass - \ No newline at end of file + name = serializers.CharField(source='first_name', max_length=150, required=False) + class Meta: + model = User + fields = ['id', 'name', 'email', 'username'] \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py index d748434..df17c84 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -1,8 +1,11 @@ from rest_framework import permissions from rest_framework import generics from rest_framework import status +from rest_framework.views import APIView +from django.contrib.auth.models import User from rest_framework.response import Response from rest_framework.authtoken.models import Token +from django.contrib.auth import authenticate from .serializers import ( LoginSerializer, RegisterSerializer, UserSerializer, TokenSerializer) @@ -21,16 +24,40 @@ class LoginView(generics.GenericAPIView): Implement login functionality, taking username and password as input, and returning the Token. """ - pass + authentication_classes = () + permission_classes = () + serializer_class = LoginSerializer + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(raise_exception=True): + username=serializer.data['username'] + password=serializer.data['password'] + user = authenticate(username=username, password=password) + if user: + token=create_auth_token(user) + return Response({"token":token.key},status=status.HTTP_200_OK) + else: + return Response({"error":"Wrong credentials"}, status = status.HTTP_400_BAD_REQUEST) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - -class RegisterView(generics.GenericAPIView): +class RegisterView(generics.CreateAPIView): """ TODO: Implement register functionality, registering the user by taking his details, and returning the Token. """ - pass + authentication_classes = () + permission_classes = () + serializer_class = RegisterSerializer + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(raise_exception=True): + self.perform_create(serializer) + token=create_auth_token(serializer.instance) + return Response({'token':token.key}, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class UserProfileView(generics.RetrieveAPIView): @@ -39,4 +66,20 @@ class UserProfileView(generics.RetrieveAPIView): Implement the functionality to retrieve the details of the logged in user. """ - pass \ No newline at end of file + serializer_class = UserSerializer + def get(self, request, *args, **kwargs): + try: + header=request.headers['Authorization'] + except Exception: + return Response({ + "detail": "Authentication credentials were not provided." + }, status=status.HTTP_401_UNAUTHORIZED) + else: + token = header.split(' ')[1] + try: + user = Token.objects.get(key=token).user + except Token.DoesNotExist: + return Response({"detail":"Invalid Token"},status=status.HTTP_404_NOT_FOUND) + else: + serializer = self.get_serializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/todo/settings.py b/todo/settings.py index 8359271..d1a9e20 100644 --- a/todo/settings.py +++ b/todo/settings.py @@ -45,7 +45,8 @@ 'rest_framework.authtoken', 'drf_yasg', 'corsheaders', - 'api' + 'api', + 'authentication', ] MIDDLEWARE = [