From 6c49e64c458fc2b21b319d7736fae7b36a2b8f9b Mon Sep 17 00:00:00 2001 From: Yanic Olivier Date: Mon, 18 Jan 2021 09:11:28 -0500 Subject: [PATCH] Add Tag model to be able to tag Event --- .../apps/user/tests/tests_view_users.py | 5 + api_volontaria/apps/volunteer/admin.py | 5 +- .../volunteer/migrations/0004_add_tags.py | 29 +++ api_volontaria/apps/volunteer/models.py | 47 ++++ api_volontaria/apps/volunteer/serializers.py | 20 +- .../apps/volunteer/tests/test_view_events.py | 105 +++++++- .../apps/volunteer/tests/test_view_tags.py | 234 ++++++++++++++++++ api_volontaria/apps/volunteer/urls.py | 1 + api_volontaria/apps/volunteer/views.py | 13 + requirements.txt | 1 + 10 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 api_volontaria/apps/volunteer/migrations/0004_add_tags.py create mode 100644 api_volontaria/apps/volunteer/tests/test_view_tags.py diff --git a/api_volontaria/apps/user/tests/tests_view_users.py b/api_volontaria/apps/user/tests/tests_view_users.py index eb2f4db8..d5e17f7f 100644 --- a/api_volontaria/apps/user/tests/tests_view_users.py +++ b/api_volontaria/apps/user/tests/tests_view_users.py @@ -83,7 +83,12 @@ def test_profile(self): 'update': False, 'destroy': False, }, + 'tag': { + 'create': False, + 'write': False + } } + self.assertEqual( content['permissions'], permissions diff --git a/api_volontaria/apps/volunteer/admin.py b/api_volontaria/apps/volunteer/admin.py index c4e2d7c3..dbd47597 100644 --- a/api_volontaria/apps/volunteer/admin.py +++ b/api_volontaria/apps/volunteer/admin.py @@ -6,7 +6,7 @@ TaskType, Participation, Cell, -) + Tag) class ParticipationAdmin(ImportExportActionModelAdmin): @@ -107,9 +107,11 @@ class EventAdmin(admin.ModelAdmin): list_filter = [ 'cell__name', 'task_type__name', + 'tags' ] date_hierarchy = 'start_time' ordering = ('start_time',) + filter_horizontal = ('tags', ) @staticmethod def status_volunteers(obj): @@ -122,6 +124,7 @@ def status_volunteers_standby(obj): str(obj.nb_volunteers_standby_needed) +admin.site.register(Tag) admin.site.register(Event, EventAdmin) admin.site.register(TaskType) admin.site.register(Participation, ParticipationAdmin) diff --git a/api_volontaria/apps/volunteer/migrations/0004_add_tags.py b/api_volontaria/apps/volunteer/migrations/0004_add_tags.py new file mode 100644 index 00000000..02e4c982 --- /dev/null +++ b/api_volontaria/apps/volunteer/migrations/0004_add_tags.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.12 on 2021-01-12 16:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('volunteer', '0003_event_description'), + ] + + operations = [ + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ], + options={ + 'verbose_name': 'Tag', + 'verbose_name_plural': 'Tags', + }, + ), + migrations.AddField( + model_name='event', + name='tags', + field=models.ManyToManyField(related_name='events', to='volunteer.Tag'), + ), + ] diff --git a/api_volontaria/apps/volunteer/models.py b/api_volontaria/apps/volunteer/models.py index baadbb93..5e93c95d 100644 --- a/api_volontaria/apps/volunteer/models.py +++ b/api_volontaria/apps/volunteer/models.py @@ -131,6 +131,48 @@ def has_object_update_permission(self, request): return False +class Tag(models.Model): + """ + This class represents a event tag. + """ + + class Meta: + verbose_name = _('Tag') + verbose_name_plural = _('Tags') + + name = models.CharField( + verbose_name="Name", + max_length=100, + ) + + def __str__(self): + return self.name + + @staticmethod + def has_create_permission(request): + return request.user.is_staff + + @staticmethod + def has_write_permission(request): + return request.user.is_staff + + @staticmethod + def has_delete_permission(request): + return request.user.is_staff + + @staticmethod + def has_list_permission(request): + return True + + @authenticated_users + def has_object_update_permission(self, request): + return request.user.is_staff + + @authenticated_users + def has_object_destroy_permission(self, request): + return request.user.is_staff + + class Event(models.Model): """ This class represents an event where volunteer can come to help. @@ -184,6 +226,11 @@ class Meta: on_delete=models.PROTECT, ) + tags = models.ManyToManyField( + Tag, + related_name='events', + ) + def __str__(self): return str(self.start_time) + ' - ' + str(self.end_time) diff --git a/api_volontaria/apps/volunteer/serializers.py b/api_volontaria/apps/volunteer/serializers.py index 59d4c013..40ba1ef8 100644 --- a/api_volontaria/apps/volunteer/serializers.py +++ b/api_volontaria/apps/volunteer/serializers.py @@ -1,3 +1,4 @@ +from drf_writable_nested import WritableNestedModelSerializer from rest_framework import serializers from api_volontaria.apps.user.serializers import UserLightSerializer @@ -6,7 +7,7 @@ Participation, Cell, Event, -) + Tag) class CellSerializer(serializers.HyperlinkedModelSerializer): @@ -60,9 +61,23 @@ def to_representation(self, instance): return data -class EventSerializer(serializers.HyperlinkedModelSerializer): +class TagSerializer(serializers.HyperlinkedModelSerializer): id = serializers.ReadOnlyField() + class Meta: + model = Tag + fields = [ + 'id', + 'url', + 'name', + ] + + +class EventSerializer(serializers.HyperlinkedModelSerializer, + WritableNestedModelSerializer): + id = serializers.ReadOnlyField() + tags = TagSerializer(many=True) + class Meta: model = Event fields = [ @@ -77,6 +92,7 @@ class Meta: 'nb_volunteers_standby', 'cell', 'task_type', + 'tags', ] def to_representation(self, instance): diff --git a/api_volontaria/apps/volunteer/tests/test_view_events.py b/api_volontaria/apps/volunteer/tests/test_view_events.py index 4f20edff..180414e2 100644 --- a/api_volontaria/apps/volunteer/tests/test_view_events.py +++ b/api_volontaria/apps/volunteer/tests/test_view_events.py @@ -11,7 +11,7 @@ Event, Cell, TaskType, -) + Tag) from api_volontaria.factories import ( UserFactory, AdminFactory, @@ -37,7 +37,8 @@ class EventsTests(CustomAPITestCase): 'cell', 'task_type', 'nb_volunteers_standby', - 'nb_volunteers' + 'nb_volunteers', + 'tags', ] def setUp(self): @@ -68,6 +69,10 @@ def setUp(self): name='My new tasktype', ) + self.tag = Tag.objects.create( + name='My Tag', + ) + self.event = Event.objects.create( start_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 15, 8)), end_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 17, 12)), @@ -77,6 +82,8 @@ def setUp(self): task_type=self.tasktype, ) + self.event.tags.add(self.tag) + def test_create_new_event_as_admin(self): """ Ensure we can create a new event if we are an admin. @@ -94,7 +101,15 @@ def test_create_new_event_as_admin(self): "task_type": reverse( 'tasktype-detail', args=[self.tasktype.id], - ) + ), + "tags": [ + { + "name": "Tag 1", + }, + { + "name": "Tag 2", + }, + ], } self.client.force_authenticate(user=self.admin) @@ -108,8 +123,57 @@ def test_create_new_event_as_admin(self): content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + self.assertEqual(len(content['tags']), 2) + self.check_attributes(content) + # def test_create_new_event_as_admin_with_existing_tag(self): + # """ + # Ensure we can create a new event if we are + # an admin and add existing tag. + # """ + # data_post = { + # "description": "My new event description", + # "start_time": LOCAL_TIMEZONE.localize(datetime(2100, 1, 13, 9)), + # "end_time": LOCAL_TIMEZONE.localize(datetime(2100, 1, 15, 10)), + # "nb_volunteers_needed": 10, + # "nb_volunteers_standby_needed": 0, + # "cell": reverse( + # 'cell-detail', + # args=[self.cell.id], + # ), + # "task_type": reverse( + # 'tasktype-detail', + # args=[self.tasktype.id], + # ), + # "tags": [ + # { + # "url": "http://testserver/tags/" + str(self.tag.id), + # } + # ] + # } + # + # self.client.force_authenticate(user=self.admin) + # + # response = self.client.post( + # reverse('event-list'), + # data_post, + # format='json', + # ) + # + # content = json.loads(response.content) + # + # self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # + # test_tags_response = [ + # {'id': 2, 'url': 'http://testserver/tags/2', 'name': 'Other Tag'} + # ] + # + # self.assertEqual(content['tags'], test_tags_response) + # + # self.check_attributes(content) + def test_create_new_event(self): """ Ensure we can't create a new event if we are a simple user. @@ -264,6 +328,41 @@ def test_list_events(self): self.assertEqual(len(content['results']), 1) self.check_attributes(content['results'][0]) + def test_list_events_with_tag_filter(self): + """ + Ensure we can list events with tag filtering. + """ + self.client.force_authenticate(user=self.user) + + reverse_string = str(reverse('event-list') + "?tags__name=My Tag") + + response = self.client.get( + reverse_string, + ) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(content['results']), 1) + self.check_attributes(content['results'][0]) + + def test_failed_to_list_events_with_tag_filter(self): + """ + Ensure we can list events with tag filtering. + """ + self.client.force_authenticate(user=self.user) + + reverse_string = str(reverse('event-list') + "?tags__name=WRONG TAG") + + response = self.client.get( + reverse_string, + ) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(content['results']), 0) + def test_bulk_events_as_users(self): """ Ensure we can't bulk add events if we are a simple user. diff --git a/api_volontaria/apps/volunteer/tests/test_view_tags.py b/api_volontaria/apps/volunteer/tests/test_view_tags.py new file mode 100644 index 00000000..aae92c7c --- /dev/null +++ b/api_volontaria/apps/volunteer/tests/test_view_tags.py @@ -0,0 +1,234 @@ +import json + +from rest_framework import status +from rest_framework.test import APIClient, APIRequestFactory +from django.urls import reverse + +from api_volontaria.apps.volunteer.models import ( + Event, + Cell, + TaskType, + Tag) +from api_volontaria.factories import ( + UserFactory, + AdminFactory, +) +from api_volontaria.testClasses import CustomAPITestCase + +from datetime import datetime +import pytz +from django.conf import settings +LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) + + +class TagsTests(CustomAPITestCase): + + ATTRIBUTES = [ + 'id', + 'url', + 'name', + ] + + def setUp(self): + self.client = APIClient() + + factory = APIRequestFactory() + self.request = factory.get('/') + + self.user = UserFactory() + self.user.set_password('Test123!') + self.user.save() + + self.admin = AdminFactory() + self.admin.set_password('Test123!') + self.admin.save() + + self.cell = Cell.objects.create( + name='My new cell', + address_line_1='373 Rue villeneuve E', + postal_code='H2T 1M1', + city='Montreal', + state_province='Quebec', + longitude='45.540237', + latitude='-73.603421', + ) + + self.tasktype = TaskType.objects.create( + name='My new tasktype', + ) + + self.tag = Tag.objects.create( + name='My Tag', + ) + + self.event = Event.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 17, 12)), + nb_volunteers_needed=10, + nb_volunteers_standby_needed=0, + cell=self.cell, + task_type=self.tasktype, + ) + + def test_create_new_tag_as_admin(self): + """ + Ensure we can create a new tag if we are an admin. + """ + data_post = { + "name": 'My Tag Test', + } + + self.client.force_authenticate(user=self.admin) + + response = self.client.post( + reverse('tag-list'), + data_post, + format='json', + ) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.check_attributes(content) + + def test_create_new_tag(self): + """ + Ensure we can't create a new tag if we are a simple user. + """ + data_post = { + "name": 'My Tag Test', + } + + self.client.force_authenticate(user=self.user) + + response = self.client.post( + reverse('tag-list'), + data_post, + format='json', + ) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + content, + { + 'detail': 'You do not have permission to perform this action.' + } + ) + + def test_update_tag_as_admin(self): + """ + Ensure we can update a tag if we are an admin. + """ + data_post = { + 'name': 'New tag Name', + } + + self.client.force_authenticate(user=self.admin) + + response = self.client.patch( + reverse( + 'tag-detail', + kwargs={ + 'pk': self.tag.id + }, + ), + data_post, + format='json', + ) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.check_attributes(content) + + def test_update_tag(self): + """ + Ensure we can't update a tag if we are a simple user. + """ + data_post = { + 'name': 'New tag Name', + } + + self.client.force_authenticate(user=self.user) + + response = self.client.patch( + reverse( + 'tag-detail', + kwargs={ + 'pk': self.tag.id + }, + ), + data_post, + format='json', + ) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + content, + { + 'detail': 'You do not have permission to perform this action.' + } + ) + + def test_delete_tag_as_admin(self): + """ + Ensure we can delete a tag if we are an admin. + """ + self.client.force_authenticate(user=self.admin) + + response = self.client.delete( + reverse( + 'tag-detail', + kwargs={ + 'pk': self.tag.id + }, + ) + ) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.content, b'') + + def test_delete_tag(self): + """ + Ensure we can't delete a tag if we are a simple user. + """ + self.client.force_authenticate(user=self.user) + + response = self.client.patch( + reverse( + 'tag-detail', + kwargs={ + 'pk': self.tag.id + }, + ) + ) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + content, + { + 'detail': 'You do not have permission to perform this action.' + } + ) + + def test_list_tags(self): + """ + Ensure we can list tags. + """ + self.client.force_authenticate(user=self.user) + + response = self.client.get( + reverse('tag-list'), + ) + + content = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(content['results']), 1) + self.check_attributes(content['results'][0]) diff --git a/api_volontaria/apps/volunteer/urls.py b/api_volontaria/apps/volunteer/urls.py index 8dc68ae7..7ac3fe38 100644 --- a/api_volontaria/apps/volunteer/urls.py +++ b/api_volontaria/apps/volunteer/urls.py @@ -21,6 +21,7 @@ def __init__(self, *args, **kwargs): router.register('task_types', views.TaskTypeViewSet) router.register('events', views.EventViewSet) router.register('participations', views.ParticipationViewSet) +router.register('tags', views.TagViewSet) urlpatterns = [ path('', include(router.urls)), # includes router generated URL diff --git a/api_volontaria/apps/volunteer/views.py b/api_volontaria/apps/volunteer/views.py index cbf2861d..ec1d1de1 100644 --- a/api_volontaria/apps/volunteer/views.py +++ b/api_volontaria/apps/volunteer/views.py @@ -22,12 +22,14 @@ Event, TaskType, Participation, + Tag ) from api_volontaria.apps.volunteer.serializers import ( CellSerializer, EventSerializer, TaskTypeSerializer, ParticipationSerializer, + TagSerializer ) @@ -71,6 +73,7 @@ class EventViewSet(viewsets.ModelViewSet): 'start_time': ['exact', 'gte', 'lte'], 'end_time': ['exact', 'gte', 'lte'], 'cell': ['exact'], + 'tags__name': ['exact'], } permission_classes = (DRYPermissions, DjangoFilterBackend) parser_classes = (JSONParser, FormParser, MultiPartParser) @@ -157,3 +160,13 @@ class ParticipationViewSet(viewsets.ModelViewSet): } permission_classes = (DRYPermissions,) filter_backends = (ParticipationFilterBackend, DjangoFilterBackend) + + +class TagViewSet(viewsets.ModelViewSet): + + serializer_class = TagSerializer + queryset = Tag.objects.all() + filter_fields = '__all__' + permission_classes = (DRYPermissions,) + filter_backends = (DjangoFilterBackend, ) + filterset_fields = ['name', ] diff --git a/requirements.txt b/requirements.txt index b7437e39..495caf56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ django-model-utils==4.0.0 babel==2.8.0 django-import-export==2.0.2 django-money==1.1 +drf-writable-nested==0.6.2 # Documentation tools mkdocs==1.1.2