diff --git a/adhocracy4/comments_async/api.py b/adhocracy4/comments_async/api.py index 788919db1..a71e8e961 100644 --- a/adhocracy4/comments_async/api.py +++ b/adhocracy4/comments_async/api.py @@ -2,10 +2,13 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.urls import reverse +from django.urls.exceptions import NoReverseMatch +from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as filters from rest_framework import mixins from rest_framework import status from rest_framework import viewsets +from rest_framework.exceptions import ValidationError from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response @@ -113,15 +116,11 @@ def get_serializer_class(self): def _save_terms_agreement(self): if hasattr(settings, 'A4_USE_ORGANISATION_TERMS_OF_USE') \ - and settings.A4_USE_ORGANISATION_TERMS_OF_USE: + and settings.A4_USE_ORGANISATION_TERMS_OF_USE \ + and not self._user_has_agreed(self.request.user): if 'agreed_terms_of_use' in self.request.data and \ - self.request.data['agreed_terms_of_use']: - organisation_model = apps.get_model( - settings.A4_ORGANISATIONS_MODEL) - OrganisationTermsOfUse = apps.get_model( - organisation_model._meta.app_label, - 'OrganisationTermsOfUse' - ) + self.request.data['agreed_terms_of_use']: + OrganisationTermsOfUse = self._get_org_terms_model() OrganisationTermsOfUse.objects.update_or_create( user=self.request.user, organisation=self.content_object.project.organisation, @@ -130,6 +129,11 @@ def _save_terms_agreement(self): self.request.data['agreed_terms_of_use'] } ) + else: + raise ValidationError({ + 'comment': + _("Please agree to the organisation's terms of use.") + }) def perform_create(self, serializer): self._save_terms_agreement() @@ -166,6 +170,27 @@ def rules_method_map(self): ) ) + def _get_org_terms_model(self): + """Make sure, only used with A4_USE_ORGANISATION_TERMS_OF_USE.""" + organisation_model = apps.get_model( + settings.A4_ORGANISATIONS_MODEL) + OrganisationTermsOfUse = apps.get_model( + organisation_model._meta.app_label, + 'OrganisationTermsOfUse' + ) + return OrganisationTermsOfUse + + def _user_has_agreed(self, user): + OrganisationTermsOfUse = self._get_org_terms_model() + organisation = self.content_object.project.organisation + user_has_agreed = \ + OrganisationTermsOfUse.objects.filter( + user=user, + organisation=organisation, + has_agreed=True + ).exists() + return user_has_agreed + def destroy(self, request, *args, **kwargs): comment = self.get_object() if self.request.user == comment.creator: @@ -192,16 +217,18 @@ def list(self, request, *args, **kwargs): user_has_agreed = None use_org_terms_of_use = True organisation = self.content_object.project.organisation - org_terms_url = reverse( - 'organisation-terms-of-use', kwargs={ - 'organisation_slug': organisation.slug - } - ) + try: + org_terms_url = reverse( + 'organisation-terms-of-use', kwargs={ + 'organisation_slug': organisation.slug + } + ) + except NoReverseMatch: + raise NotImplementedError('Add org terms of use view.') if hasattr(request, 'user'): user = request.user if user.is_authenticated: - user_has_agreed = \ - user.has_agreed_on_org_terms(organisation) + user_has_agreed = self._user_has_agreed(user) response.data['user_has_agreed'] = user_has_agreed response.data['org_terms_url'] = org_terms_url response.data['use_org_terms_of_use'] = use_org_terms_of_use diff --git a/tests/apps/organisations/factories.py b/tests/apps/organisations/factories.py index e1df8dc2d..347c55da6 100644 --- a/tests/apps/organisations/factories.py +++ b/tests/apps/organisations/factories.py @@ -4,6 +4,7 @@ from adhocracy4.test.factories import UserFactory from tests.apps.organisations.models import Member from tests.apps.organisations.models import Organisation +from tests.apps.organisations.models import OrganisationTermsOfUse class OrganisationFactory(factory.django.DjangoModelFactory): @@ -33,3 +34,11 @@ class Meta: member = factory.SubFactory(USER_FACTORY) organisation = factory.SubFactory(ORGANISATION_FACTORY) + + +class OrganisationTermsOfUseFactory(factory.django.DjangoModelFactory): + class Meta: + model = OrganisationTermsOfUse + + user = factory.SubFactory(UserFactory) + organisation = factory.SubFactory(OrganisationFactory) diff --git a/tests/apps/organisations/migrations/0003_organisationtermsofuse.py b/tests/apps/organisations/migrations/0003_organisationtermsofuse.py new file mode 100644 index 000000000..22fdb38ab --- /dev/null +++ b/tests/apps/organisations/migrations/0003_organisationtermsofuse.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.13 on 2022-06-21 14:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('a4test_organisations', '0002_member'), + ] + + operations = [ + migrations.CreateModel( + name='OrganisationTermsOfUse', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('has_agreed', models.BooleanField(default=False)), + ('organisation', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.A4_ORGANISATIONS_MODEL)), + ('user', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'organisation')}, + }, + ), + ] diff --git a/tests/apps/organisations/models.py b/tests/apps/organisations/models.py index d56d713f1..90d4312f6 100644 --- a/tests/apps/organisations/models.py +++ b/tests/apps/organisations/models.py @@ -15,7 +15,7 @@ class Organisation(models.Model): ) groups = models.ManyToManyField( Group, - blank=True + blank=True, ) def __str__(self): @@ -38,10 +38,33 @@ def get_absolute_url(self): class Member(models.Model): - member = models.ForeignKey(User, - on_delete=models.CASCADE) - organisation = models.ForeignKey(settings.A4_ORGANISATIONS_MODEL, - on_delete=models.CASCADE) + member = models.ForeignKey( + User, + on_delete=models.CASCADE, + ) + organisation = models.ForeignKey( + settings.A4_ORGANISATIONS_MODEL, + on_delete=models.CASCADE, + ) class Meta: unique_together = [('member', 'organisation')] + + +class OrganisationTermsOfUse(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + editable=False, + ) + organisation = models.ForeignKey( + settings.A4_ORGANISATIONS_MODEL, + on_delete=models.CASCADE, + editable=False, + ) + has_agreed = models.BooleanField( + default=False, + ) + + class Meta: + unique_together = [('user', 'organisation')] diff --git a/tests/comments/test_async_api.py b/tests/comments/test_async_api.py index eede69cf3..d786fd8cb 100644 --- a/tests/comments/test_async_api.py +++ b/tests/comments/test_async_api.py @@ -346,10 +346,6 @@ def test_fields(user, apiclient, question_ct, question): with active_phase(question.module, AskPhase): apiclient.post(url, data, format='json') - url = reverse( - 'comments_async-list', - kwargs={'content_type': question_ct.pk, - 'object_pk': question.pk}) response = apiclient.get(url) assert len(response.data) == 8 @@ -361,6 +357,8 @@ def test_fields(user, apiclient, question_ct, question): assert 'would_have_commenting_permission' in response.data assert 'project_is_public' in response.data assert response.data['count'] == 1 + assert 'use_org_terms_of_use' in response.data + assert not response.data['use_org_terms_of_use'] commentDict = response.data['results'][0] assert len(commentDict) == 21 diff --git a/tests/comments/test_async_api_org_terms.py b/tests/comments/test_async_api_org_terms.py new file mode 100644 index 000000000..65165edd9 --- /dev/null +++ b/tests/comments/test_async_api_org_terms.py @@ -0,0 +1,300 @@ +from unittest.mock import patch + +import pytest +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings +from django.urls import reverse +from rest_framework import status + +from tests.apps.organisations.models import OrganisationTermsOfUse +from tests.apps.questions.models import Question +from tests.apps.questions.phases import AskPhase +from tests.helpers import active_phase + + +@pytest.mark.django_db +@pytest.fixture +def question_ct(): + return ContentType.objects.get_for_model(Question) + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +def test_agreement_save_with_create(user, apiclient, question_ct, question): + apiclient.force_authenticate(user=user) + url = reverse( + 'comments_async-list', + kwargs={'content_type': question_ct.pk, + 'object_pk': question.pk}) + data = { + 'comment': 'comment comment', + 'agreed_terms_of_use': True + } + with active_phase(question.module, AskPhase): + apiclient.post(url, data, format='json') + + terms = OrganisationTermsOfUse.objects.all() + assert len(terms) == 1 + assert terms[0].user == user + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +def test_agreement_save_with_update(comment, apiclient): + apiclient.force_authenticate(user=comment.creator) + url = reverse( + 'comments_async-detail', + kwargs={ + 'pk': comment.pk, + 'content_type': comment.content_type.pk, + 'object_pk': comment.object_pk + }) + data = { + 'comment': 'comment comment comment', + 'agreed_terms_of_use': True + } + + with active_phase(comment.content_object.module, AskPhase): + response = apiclient.patch(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['comment'] == 'comment comment comment' + + terms = OrganisationTermsOfUse.objects.all() + assert len(terms) == 1 + assert terms[0].user == comment.creator + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +def test_agreement_update_with_create( + user, apiclient, question_ct, question, + organisation_terms_of_use_factory): + organisation_terms_of_use = organisation_terms_of_use_factory( + user=question.creator, + organisation=question.module.project.organisation, + has_agreed=False, + ) + assert not organisation_terms_of_use.has_agreed + + apiclient.force_authenticate(user=user) + url = reverse( + 'comments_async-list', + kwargs={'content_type': question_ct.pk, + 'object_pk': question.pk}) + data = { + 'comment': 'comment comment', + 'agreed_terms_of_use': True + } + with active_phase(question.module, AskPhase): + apiclient.post(url, data, format='json') + + organisation_terms_of_use.refresh_from_db() + assert organisation_terms_of_use.has_agreed + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +def test_agreement_update_with_update( + comment, apiclient, organisation_terms_of_use_factory): + organisation_terms_of_use = organisation_terms_of_use_factory( + user=comment.creator, + organisation=comment.module.project.organisation, + has_agreed=False, + ) + assert not organisation_terms_of_use.has_agreed + apiclient.force_authenticate(user=comment.creator) + url = reverse( + 'comments_async-detail', + kwargs={ + 'pk': comment.pk, + 'content_type': comment.content_type.pk, + 'object_pk': comment.object_pk + }) + data = { + 'comment': 'comment comment comment', + 'agreed_terms_of_use': True + } + + with active_phase(comment.content_object.module, AskPhase): + response = apiclient.patch(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['comment'] == 'comment comment comment' + + organisation_terms_of_use.refresh_from_db() + assert organisation_terms_of_use.has_agreed + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +def test_admin_agreement_save_with_update(comment, apiclient, admin): + apiclient.force_authenticate(user=admin) + url = reverse( + 'comments_async-detail', + kwargs={ + 'pk': comment.pk, + 'content_type': comment.content_type.pk, + 'object_pk': comment.object_pk + }) + data = { + 'comment': 'comment comment comment', + 'agreed_terms_of_use': True + } + + with active_phase(comment.content_object.module, AskPhase): + response = apiclient.patch(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['comment'] == 'comment comment comment' + + terms = OrganisationTermsOfUse.objects.all() + assert len(terms) == 1 + assert terms[0].user == admin + + +@pytest.mark.django_db +def test_agreement_save_no_settings_no_effect( + comment, apiclient): + apiclient.force_authenticate(user=comment.creator) + url = reverse( + 'comments_async-detail', + kwargs={ + 'pk': comment.pk, + 'content_type': comment.content_type.pk, + 'object_pk': comment.object_pk + }) + data = { + 'comment': 'comment comment comment', + 'agreed_terms_of_use': True + } + + with active_phase(comment.content_object.module, AskPhase): + response = apiclient.patch(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['comment'] == 'comment comment comment' + + terms = OrganisationTermsOfUse.objects.all() + assert len(terms) == 0 + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +def test_comment_save_without_agreement_forbidden( + user, apiclient, question_ct, question): + apiclient.force_authenticate(user=user) + url = reverse( + 'comments_async-list', + kwargs={'content_type': question_ct.pk, + 'object_pk': question.pk}) + data = { + 'comment': 'comment comment', + 'agreed_terms_of_use': False + } + with active_phase(question.module, AskPhase): + response = apiclient.post(url, data, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + terms = OrganisationTermsOfUse.objects.all() + assert len(terms) == 0 + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +def test_comment_save_without_agreement_data_forbidden( + user, apiclient, question_ct, question): + apiclient.force_authenticate(user=user) + url = reverse( + 'comments_async-list', + kwargs={'content_type': question_ct.pk, + 'object_pk': question.pk}) + data = { + 'comment': 'comment comment' + } + with active_phase(question.module, AskPhase): + response = apiclient.post(url, data, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + terms = OrganisationTermsOfUse.objects.all() + assert len(terms) == 0 + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +def test_comment_save_with_already_agreed( + user, apiclient, question_ct, question, + organisation_terms_of_use_factory): + organisation_terms_of_use_factory( + user=user, + organisation=question.module.project.organisation, + has_agreed=True, + ) + apiclient.force_authenticate(user=user) + url = reverse( + 'comments_async-list', + kwargs={'content_type': question_ct.pk, + 'object_pk': question.pk}) + data = { + 'comment': 'comment comment', + } + with active_phase(question.module, AskPhase): + response = apiclient.post(url, data, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['comment'] == 'comment comment' + + +@pytest.mark.django_db +@override_settings( + A4_USE_ORGANISATION_TERMS_OF_USE=True +) +@patch('adhocracy4.comments_async.api.reverse', return_value='/') +def test_agreement_info( + mock_provider, user, another_user, apiclient, question_ct, + question, organisation_terms_of_use_factory): + organisation_terms_of_use_factory( + user=question.creator, + organisation=question.module.project.organisation, + has_agreed=True, + ) + url = reverse( + 'comments_async-list', + kwargs={'content_type': question_ct.pk, + 'object_pk': question.pk}) + data = {'comment': 'comment comment'} + with active_phase(question.module, AskPhase): + apiclient.post(url, data, format='json') + + response = apiclient.get(url) + assert len(response.data) == 10 + assert 'use_org_terms_of_use' in response.data + assert response.data['use_org_terms_of_use'] + assert 'user_has_agreed' in response.data + assert response.data['user_has_agreed'] is None + assert 'org_terms_url' in response.data + assert response.data['org_terms_url'] == '/' + + # user has agreed + apiclient.force_authenticate(user=user) + response = apiclient.get(url) + assert len(response.data) == 10 + assert response.data['user_has_agreed'] + + # another_user has not agreed + apiclient.force_authenticate(user=another_user) + response = apiclient.get(url) + assert len(response.data) == 10 + assert not response.data['user_has_agreed'] diff --git a/tests/conftest.py b/tests/conftest.py index 496b51daf..b7ec8dfcf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from tests.apps.locations import factories as location_factories from tests.apps.organisations.factories import MemberFactory from tests.apps.organisations.factories import OrganisationFactory +from tests.apps.organisations.factories import OrganisationTermsOfUseFactory from tests.apps.questions import factories as q_factories from tests.images import factories as img_factories @@ -29,6 +30,7 @@ def image_factory(): register(OrganisationFactory) register(factories.UserFactory) register(MemberFactory) +register(OrganisationTermsOfUseFactory) register(factories.GroupFactory) register(factories.AdminFactory, 'admin') register(factories.UserFactory, 'another_user')