diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..ed2af3674 Binary files /dev/null and b/.DS_Store differ diff --git a/config/urls.py b/config/urls.py index 3e571af30..6a008225d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -24,7 +24,7 @@ from foodsaving.groups.api import GroupViewSet from foodsaving.history.api import HistoryViewSet from foodsaving.invitations.api import InvitationsViewSet, InvitationAcceptViewSet -from foodsaving.stores.api import StoreViewSet, PickupDateViewSet, PickupDateSeriesViewSet +from foodsaving.stores.api import StoreViewSet, PickupDateViewSet, PickupDateSeriesViewSet, FeedbackViewSet from foodsaving.userauth.api import AuthViewSet from foodsaving.users.api import UserViewSet @@ -53,6 +53,9 @@ router.register('invitations', InvitationsViewSet) router.register('invitations', InvitationAcceptViewSet) +# Feedback endpoints +router.register(r'feedback', FeedbackViewSet) + urlpatterns = [ url(r'^api/', include(router.urls, namespace='api')), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), diff --git a/foodsaving/.DS_Store b/foodsaving/.DS_Store new file mode 100644 index 000000000..878b68c6a Binary files /dev/null and b/foodsaving/.DS_Store differ diff --git a/foodsaving/stores/.DS_Store b/foodsaving/stores/.DS_Store new file mode 100644 index 000000000..939c9a1ee Binary files /dev/null and b/foodsaving/stores/.DS_Store differ diff --git a/foodsaving/stores/api.py b/foodsaving/stores/api.py index dcda668bb..fb75fb8be 100644 --- a/foodsaving/stores/api.py +++ b/foodsaving/stores/api.py @@ -7,12 +7,19 @@ from rest_framework.viewsets import GenericViewSet from foodsaving.stores.filters import PickupDatesFilter, PickupDateSeriesFilter -from foodsaving.stores.permissions import IsUpcoming, HasNotJoinedPickupDate, HasJoinedPickupDate, IsEmptyPickupDate, \ - IsNotFull -from foodsaving.stores.serializers import StoreSerializer, PickupDateSerializer, PickupDateSeriesSerializer, \ - PickupDateJoinSerializer, PickupDateLeaveSerializer -from foodsaving.stores.models import Store as StoreModel, PickupDate as PickupDateModel, \ - PickupDateSeries as PickupDateSeriesModel +from foodsaving.stores.permissions import ( + IsUpcoming, HasNotJoinedPickupDate, HasJoinedPickupDate, IsEmptyPickupDate, + IsNotFull) +from foodsaving.stores.serializers import ( + StoreSerializer, PickupDateSerializer, PickupDateSeriesSerializer, + PickupDateJoinSerializer, PickupDateLeaveSerializer, FeedbackSerializer) +from foodsaving.stores.models import ( + Store as StoreModel, + PickupDate as PickupDateModel, + PickupDateSeries as PickupDateSeriesModel, + Feedback as FeedbackModel +) + from foodsaving.utils.mixins import PartialUpdateModelMixin pre_pickup_delete = Signal() @@ -59,6 +66,20 @@ def perform_destroy(self, store): PickupDateSeriesModel.objects.filter(store=store).delete() +class FeedbackViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet +): + serializer_class = FeedbackSerializer + queryset = FeedbackModel.objects.all() + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + return self.queryset.filter(about__store__group__members=self.request.user) + + class PickupDateSeriesViewSet( mixins.CreateModelMixin, mixins.RetrieveModelMixin, @@ -67,12 +88,7 @@ class PickupDateSeriesViewSet( mixins.DestroyModelMixin, viewsets.GenericViewSet ): - """ - Pickup Date Series - # Query parameters - - `?store` - filter by store id - """ serializer_class = PickupDateSeriesSerializer queryset = PickupDateSeriesModel.objects filter_backends = (filters.DjangoFilterBackend,) diff --git a/foodsaving/stores/migrations/0016_feedback.py b/foodsaving/stores/migrations/0016_feedback.py new file mode 100644 index 000000000..3b63e8b27 --- /dev/null +++ b/foodsaving/stores/migrations/0016_feedback.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-10 09:55 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('stores', '0015_auto_20170727_0943'), + ] + + operations = [ + migrations.CreateModel( + name='Feedback', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('weight', models.PositiveIntegerField()), + ('comment', models.CharField(max_length=80)), + ('about', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stores.PickupDate')), + ('given_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_feedback', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/foodsaving/stores/migrations/0017_merge_20170815_0930.py b/foodsaving/stores/migrations/0017_merge_20170815_0930.py new file mode 100644 index 000000000..88c112577 --- /dev/null +++ b/foodsaving/stores/migrations/0017_merge_20170815_0930.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-15 09:30 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stores', '0016_feedback'), + ('stores', '0016_pickupdate_done_and_processed'), + ] + + operations = [ + ] diff --git a/foodsaving/stores/migrations/0018_auto_20170831_1256.py b/foodsaving/stores/migrations/0018_auto_20170831_1256.py new file mode 100644 index 000000000..9db71e3fa --- /dev/null +++ b/foodsaving/stores/migrations/0018_auto_20170831_1256.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 12:56 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stores', '0017_merge_20170815_0930'), + ] + + operations = [ + migrations.AlterField( + model_name='feedback', + name='weight', + field=models.PositiveIntegerField(blank=True), + ), + ] diff --git a/foodsaving/stores/migrations/0019_auto_20170831_1306.py b/foodsaving/stores/migrations/0019_auto_20170831_1306.py new file mode 100644 index 000000000..65eb8f491 --- /dev/null +++ b/foodsaving/stores/migrations/0019_auto_20170831_1306.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 13:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stores', '0018_auto_20170831_1256'), + ] + + operations = [ + migrations.AlterField( + model_name='feedback', + name='weight', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/foodsaving/stores/migrations/0020_auto_20170831_1312.py b/foodsaving/stores/migrations/0020_auto_20170831_1312.py new file mode 100644 index 000000000..c75ef3b08 --- /dev/null +++ b/foodsaving/stores/migrations/0020_auto_20170831_1312.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 13:12 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stores', '0019_auto_20170831_1306'), + ] + + operations = [ + migrations.AlterField( + model_name='feedback', + name='comment', + field=models.CharField(max_length=100000), + ), + ] diff --git a/foodsaving/stores/migrations/0021_auto_20170831_1341.py b/foodsaving/stores/migrations/0021_auto_20170831_1341.py new file mode 100644 index 000000000..dc929f13d --- /dev/null +++ b/foodsaving/stores/migrations/0021_auto_20170831_1341.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 13:41 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('stores', '0020_auto_20170831_1312'), + ] + + operations = [ + migrations.AlterField( + model_name='feedback', + name='given_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/foodsaving/stores/models.py b/foodsaving/stores/models.py index dec7e0486..1bdd9a1d2 100644 --- a/foodsaving/stores/models.py +++ b/foodsaving/stores/models.py @@ -30,6 +30,13 @@ def __str__(self): return '{} ({})'.format(self.name, self.group) +class Feedback(BaseModel): + given_by = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='feedback') + about = models.ForeignKey('PickupDate') + weight = models.PositiveIntegerField(blank=True, null=True) + comment = models.CharField(max_length=settings.DESCRIPTION_MAX_LENGTH) + + class PickupDateSeriesManager(models.Manager): @transaction.atomic def create_all_pickup_dates(self): diff --git a/foodsaving/stores/serializers.py b/foodsaving/stores/serializers.py index 1dfef8e91..a773bdcf2 100644 --- a/foodsaving/stores/serializers.py +++ b/foodsaving/stores/serializers.py @@ -8,9 +8,12 @@ from django.conf import settings from foodsaving.history.utils import get_changed_data -from foodsaving.stores.models import PickupDate as PickupDateModel -from foodsaving.stores.models import PickupDateSeries as PickupDateSeriesModel -from foodsaving.stores.models import Store as StoreModel +from foodsaving.stores.models import ( + PickupDate as PickupDateModel, + Feedback as FeedbackModel, + PickupDateSeries as PickupDateSeriesModel, + Store as StoreModel, +) from foodsaving.stores.signals import post_pickup_create, post_pickup_modify, post_pickup_join, post_pickup_leave, \ post_series_create, post_series_modify, post_store_create, post_store_modify @@ -229,3 +232,24 @@ def validate_weeks_in_advance(self, w): if w < 1: raise serializers.ValidationError(_('Set at least one week in advance')) return w + + +class FeedbackSerializer(serializers.ModelSerializer): + class Meta: + model = FeedbackModel + fields = ['id', 'weight', 'comment', 'about', 'given_by'] + read_only_fields = ('given_by',) + + # Tilmanns code (is it to save a user_id of the logged-in user?) + def create(self, validated_data): + validated_data['given_by'] = self.context['request'].user + return super().create(validated_data) + + def validate_about(self, about): + user = self.context['request'].user + group = about.store.group + if not group.is_member(user): + raise serializers.ValidationError(_('You are not member of the store\'s group.')) + if not about.is_collector(user): + raise serializers.ValidationError(_('You aren\'t assign to the pickup.')) + return about diff --git a/foodsaving/stores/tests/test_feedback_api.py b/foodsaving/stores/tests/test_feedback_api.py new file mode 100644 index 000000000..9da2b7937 --- /dev/null +++ b/foodsaving/stores/tests/test_feedback_api.py @@ -0,0 +1,169 @@ +from rest_framework import status +from rest_framework.test import APITestCase +from dateutil.relativedelta import relativedelta + +from django.utils import timezone +from foodsaving.groups.factories import GroupFactory +from foodsaving.stores.factories import StoreFactory, PickupDateFactory +from foodsaving.users.factories import UserFactory +from foodsaving.stores.models import Feedback + + +class FeedbackTest(APITestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.url = '/api/feedback/' + + cls.member = UserFactory() + cls.collector = UserFactory() + cls.group = GroupFactory(members=[cls.member, cls.collector]) + cls.store = StoreFactory(group=cls.group) + cls.pickup = PickupDateFactory(store=cls.store, date=timezone.now() + relativedelta(days=1)) + + # not a member of the group + cls.user = UserFactory() + + # past pickup date + cls.past_pickup = PickupDateFactory(store=cls.store, date=timezone.now() - relativedelta(days=1)) + + # transforms the menber into a collector + cls.past_pickup.collectors.add(cls.collector) + + # create a feedback data for POST method + cls.feedback_post = { + 'given_by': cls.collector.id, + 'about': cls.past_pickup.id, + 'weight': 2, + 'comment': 'asfjk' + } + + # create a feedback to future pickup + cls.future_feedback_post = { + 'given_by': cls.collector.id, + 'about': cls.pickup.id, + 'weight': 2, + 'comment': 'asfjk' + } + + # create a feedback data for GET method + cls.feedback_get = { + 'given_by': cls.collector, + 'about': cls.past_pickup, + 'weight': 2, + 'comment': 'asfjk' + } + + # create 2 instances of feedback for GET method + cls.feedback = Feedback.objects.create(**cls.feedback_get) + Feedback.objects.create(**cls.feedback_get) + + cls.feedback_url = cls.url + str(cls.feedback.id) + '/' + + def test_create_feedback_fails_as_non_user(self): + """ + Non-User is not allowed to give feedback. + """ + response = self.client.post(self.url, self.feedback_post, format='json') + self.assertEqual( + response.status_code, status.HTTP_403_FORBIDDEN, response.data) + + def test_create_feedback_fails_as_non_group_member(self): + """ + User is not allowed to give feedback when not a member of the stores group. + """ + self.client.force_login(user=self.user) + response = self.client.post(self.url, self.feedback_post, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.data) + self.assertEqual(response.data, {'about': ['You are not member of the store\'s group.']}) + + def test_create_feedback_fails_as_non_collector(self): + """ + Group Member is not allowed to give feedback when he is not assiged to the + Pickup. + """ + self.client.force_login(user=self.member) + response = self.client.post(self.url, self.feedback_post, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.data) + self.assertEqual(response.data, {'about': ['You aren\'t assign to the pickup.']}) + + def test_create_feedback_works_as_collector(self): + """ + Member is allowed to give feedback when he is assiged to the Pickup. + """ + self.client.force_login(user=self.collector) + response = self.client.post(self.url, self.feedback_post, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + + def test_list_feedback_fails_as_non_user(self): + """ + Non-User is NOT allowed to see list of feedback + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN, response.data) + + def test_list_feedback_works_as_non_group_member(self): + """ + Non-Member doesn't see feedback but an empty list + """ + self.client.force_login(user=self.user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.assertEqual(len(response.data), 0) + + def test_list_feedback_works_as_group_member(self): + """ + Member is allowed to see list of feedback (DOUBLE CHECK!!) + """ + self.client.force_login(user=self.member) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.assertEqual(len(response.data), 2) + + def test_list_feedback_works_as_collector(self): + """ + Collector is allowed to see list of feedback + """ + self.client.force_login(user=self.collector) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.assertEqual(len(response.data), 2) + + def test_retrieve_feedback_fails_as_non_user(self): + """ + Non-User is NOT allowed to see single feedback + """ + response = self.client.get(self.feedback_url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN, response.data) + + def test_retrieve_feedback_fails_as_non_group_member(self): + """ + Non-Member is NOT allowed to see single feedback + """ + self.client.force_login(user=self.user) + response = self.client.get(self.feedback_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND, response.data) + + def test_retrieve_feedback_works_as_group_member(self): + """ + Member is allowed to see single feedback + """ + self.client.force_login(user=self.member) + response = self.client.get(self.feedback_url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + + def test_retrieve_feedback_works_as_collector(self): + """ + Collector is allowed to see list of feedback + """ + self.client.force_login(user=self.collector) + response = self.client.get(self.feedback_url) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + + def test_create_future_feedback_fails_as_collector(self): + """ + Collector is NOT allowed to leave feedback for future pickup + """ + self.client.force_login(user=self.collector) + response = self.client.post(self.url, self.future_feedback_post, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.data) diff --git a/foodsaving/stores/tests/test_models.py b/foodsaving/stores/tests/test_models.py index 2b4a7d3ae..1d7000dc8 100644 --- a/foodsaving/stores/tests/test_models.py +++ b/foodsaving/stores/tests/test_models.py @@ -7,8 +7,9 @@ from datetime import datetime from foodsaving.groups.factories import GroupFactory -from foodsaving.stores.factories import StoreFactory -from foodsaving.stores.models import Store, PickupDateSeries, PickupDate +from foodsaving.stores.factories import StoreFactory, PickupDateFactory +from foodsaving.stores.models import Store, PickupDateSeries, PickupDate, Feedback +from foodsaving.users.factories import UserFactory class TestStoreModel(TestCase): @@ -31,6 +32,28 @@ def test_create_store_with_same_name_in_different_groups_works(self): Store.objects.create(name='abcdef', group=GroupFactory()) +class TestFeedbackModel(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.pickup = PickupDateFactory() + cls.user = UserFactory() + + def test_weight_is_negative_fails(self): + with self.assertRaises(IntegrityError): + Feedback.objects.create(weight=-1, about=self.pickup, given_by=self.user, comment="soup") + + def test_weight_is_null_passes(self): + Feedback.objects.create(about=self.pickup, given_by=self.user, comment="soup") + + def test_comment_is_null_passes(self): + Feedback.objects.create(about=self.pickup, given_by=self.user, weight=1) + + def test_create_fails_if_comment_too_long(self): + with self.assertRaises(DataError): + Feedback.objects.create(comment='a' * 100001, about=self.pickup, given_by=self.user, weight=1) + + class TestPickupDateSeriesModel(TestCase): @classmethod def setUpClass(cls):