From bdb91564d4db9e11ccc337a0cad9c4eaf65416d3 Mon Sep 17 00:00:00 2001 From: krmax44 Date: Mon, 11 Nov 2024 10:24:01 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20create=20+=20update=20endpoin?= =?UTF-8?q?ts=20to=20message=20api,=20refactor=20api=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../foirequest/{api_views => api}/__init__.py | 0 froide/foirequest/api/permissions.py | 16 + froide/foirequest/{ => api}/serializers.py | 91 ++++-- froide/foirequest/api/views/__init__.py | 0 .../{api_views => api/views}/attachment.py | 17 +- froide/foirequest/api/views/message.py | 55 ++++ .../{api_views => api/views}/request.py | 10 +- froide/foirequest/api_views/message.py | 38 --- froide/foirequest/apps.py | 6 +- froide/foirequest/auth.py | 9 +- froide/foirequest/models/message.py | 19 +- froide/foirequest/models/request.py | 2 +- froide/foirequest/tests/__init__.py | 2 +- froide/foirequest/tests/test_api_message.py | 114 +++++++ .../{test_api.py => test_api_request.py} | 11 +- froide/foirequest/utils.py | 2 +- froide/foirequest/views/make_request.py | 2 +- froide/foirequest/views/message.py | 2 +- froide/foirequestfollower/api_views.py | 7 +- froide/foirequestfollower/configuration.py | 3 +- froide/foirequestfollower/tests.py | 2 +- froide/georegion/api_views.py | 6 +- froide/helper/auth.py | 15 +- froide/publicbody/api/__init__.py | 0 froide/publicbody/api/serializers.py | 295 +++++++++++++++++ .../publicbody/{api_views.py => api/views.py} | 300 +----------------- froide/publicbody/apps.py | 2 +- froide/publicbody/models.py | 6 +- froide/settings.py | 2 +- 29 files changed, 628 insertions(+), 406 deletions(-) rename froide/foirequest/{api_views => api}/__init__.py (100%) create mode 100644 froide/foirequest/api/permissions.py rename froide/foirequest/{ => api}/serializers.py (82%) create mode 100644 froide/foirequest/api/views/__init__.py rename froide/foirequest/{api_views => api/views}/attachment.py (82%) create mode 100644 froide/foirequest/api/views/message.py rename froide/foirequest/{api_views => api/views}/request.py (97%) delete mode 100644 froide/foirequest/api_views/message.py create mode 100644 froide/foirequest/tests/test_api_message.py rename froide/foirequest/tests/{test_api.py => test_api_request.py} (97%) create mode 100644 froide/publicbody/api/__init__.py create mode 100644 froide/publicbody/api/serializers.py rename froide/publicbody/{api_views.py => api/views.py} (55%) diff --git a/froide/foirequest/api_views/__init__.py b/froide/foirequest/api/__init__.py similarity index 100% rename from froide/foirequest/api_views/__init__.py rename to froide/foirequest/api/__init__.py diff --git a/froide/foirequest/api/permissions.py b/froide/foirequest/api/permissions.py new file mode 100644 index 000000000..21d46a205 --- /dev/null +++ b/froide/foirequest/api/permissions.py @@ -0,0 +1,16 @@ +from rest_framework import permissions +from rest_framework.views import Request + +from ..auth import can_write_foirequest + + +class OwnsFoiRequestPermission(permissions.BasePermission): + def __init__(self, request_field: str = "request") -> None: + self.request_field = request_field + super().__init__() + + def has_object_permission(self, request: Request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + + return can_write_foirequest(getattr(obj, self.request_field), request) diff --git a/froide/foirequest/serializers.py b/froide/foirequest/api/serializers.py similarity index 82% rename from froide/foirequest/serializers.py rename to froide/foirequest/api/serializers.py index 5302d33fa..4b2dbb976 100644 --- a/froide/foirequest/serializers.py +++ b/froide/foirequest/api/serializers.py @@ -1,26 +1,30 @@ from django.db.models import Prefetch +from django.utils import timezone +from django.utils.translation import gettext as _ from rest_framework import serializers +from rest_framework.views import PermissionDenied from froide.document.api_views import DocumentSerializer from froide.foirequest.forms.message import TransferUploadForm -from froide.foirequest.models.message import MessageKind -from froide.helper.auth import get_write_queryset +from froide.foirequest.models.message import MESSAGE_KIND_USER_ALLOWED from froide.helper.text_diff import get_differences -from froide.publicbody.api_views import ( +from froide.publicbody.api.serializers import ( FoiLawSerializer, PublicBodySerializer, SimplePublicBodySerializer, ) from froide.publicbody.models import PublicBody -from .auth import ( +from ..auth import ( can_read_foirequest_authenticated, + can_write_foirequest, get_read_foiattachment_queryset, + get_read_foirequest_queryset, ) -from .models import FoiAttachment, FoiMessage, FoiRequest -from .services import CreateRequestService -from .validators import clean_reference +from ..models import FoiAttachment, FoiMessage, FoiRequest +from ..services import CreateRequestService +from ..validators import clean_reference class TagListField(serializers.CharField): @@ -157,13 +161,16 @@ def create(self, validated_data): return service.execute(validated_data["request"]) +class FoiRequestRelatedField(serializers.HyperlinkedRelatedField): + view_name = "api:request-detail" + + def get_queryset(self): + return get_read_foirequest_queryset(self.context["request"]) + + class FoiMessageSerializer(serializers.HyperlinkedModelSerializer): - resource_uri = serializers.HyperlinkedIdentityField( - view_name="api:message-detail", lookup_field="pk" - ) - request = serializers.HyperlinkedRelatedField( - read_only=True, view_name="api:request-detail" - ) + resource_uri = serializers.HyperlinkedIdentityField(view_name="api:message-detail") + request = FoiRequestRelatedField() attachments = serializers.SerializerMethodField(source="get_attachments") sender_public_body = serializers.HyperlinkedRelatedField( read_only=True, view_name="api:publicbody-detail" @@ -176,9 +183,13 @@ class FoiMessageSerializer(serializers.HyperlinkedModelSerializer): content = serializers.SerializerMethodField(source="get_content") redacted_subject = serializers.SerializerMethodField(source="get_redacted_subject") redacted_content = serializers.SerializerMethodField(source="get_redacted_content") - sender = serializers.CharField() - url = serializers.CharField(source="get_absolute_domain_url") - status_name = serializers.CharField(source="get_status_display") + sender = serializers.CharField(read_only=True) + url = serializers.CharField(source="get_absolute_domain_url", read_only=True) + status = serializers.ChoiceField(choices=FoiRequest.STATUS.choices, required=False) + is_draft = serializers.BooleanField(required=False) + status_name = serializers.CharField(source="get_status_display", read_only=True) + not_publishable = serializers.BooleanField(read_only=True) + timestamp = serializers.DateTimeField(default=timezone.now) class Meta: model = FoiMessage @@ -254,6 +265,32 @@ def get_attachments(self, obj): ) return serializer.data + def validate_kind(self, value): + # forbid users from e.g. creating a fake e-mail message + if value not in MESSAGE_KIND_USER_ALLOWED: + raise serializers.ValidationError( + "This message kind can not be created via the API." + ) + return value + + def validate_request(self, value): + if not can_write_foirequest(value, self.context["request"]): + raise PermissionDenied( + _("You do not have permission to add a message to this request.") + ) + return value + + def ensure_draft(self, instance): + if not instance.is_draft: + raise serializers.ValidationError("Only draft messages can be altered.") + + def create(self, validated_data): + return super().create(validated_data) + + def update(self, instance, validated_data): + self.ensure_draft(instance) + return super().update(instance, validated_data) + class FoiAttachmentSerializer(serializers.HyperlinkedModelSerializer): resource_uri = serializers.HyperlinkedIdentityField( @@ -310,25 +347,13 @@ def get_file_url(self, obj): class FoiAttachmentTusSerializer(serializers.Serializer): - message = serializers.IntegerField() + message = serializers.HyperlinkedRelatedField( + view_name="api:message-detail", + lookup_field="pk", + queryset=FoiMessage.objects.all(), + ) upload = serializers.CharField() - def validate_message(self, value): - writable_requests = get_write_queryset( - FoiRequest.objects.all(), - self.context["request"], - has_team=True, - scope="upload:message", - ) - try: - return FoiMessage.objects.get( - pk=value, - request__in=writable_requests, - kind=MessageKind.POST, - ) - except FoiMessage.DoesNotExist as e: - raise serializers.ValidationError("Message not found") from e - def validate(self, data): self.form = TransferUploadForm( data=data, foimessage=data["message"], user=self.context["request"].user diff --git a/froide/foirequest/api/views/__init__.py b/froide/foirequest/api/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/froide/foirequest/api_views/attachment.py b/froide/foirequest/api/views/attachment.py similarity index 82% rename from froide/foirequest/api_views/attachment.py rename to froide/foirequest/api/views/attachment.py index 2a882c824..023ec388a 100644 --- a/froide/foirequest/api_views/attachment.py +++ b/froide/foirequest/api/views/attachment.py @@ -4,11 +4,13 @@ from rest_framework import mixins, status, viewsets from rest_framework.response import Response -from ..auth import ( +from froide.foirequest.api.permissions import OwnsFoiRequestPermission + +from ...auth import ( CreateOnlyWithScopePermission, get_read_foiattachment_queryset, ) -from ..models import FoiAttachment +from ...models import FoiAttachment from ..serializers import ( FoiAttachmentSerializer, FoiAttachmentTusSerializer, @@ -43,8 +45,15 @@ class FoiAttachmentViewSet( } filter_backends = (filters.DjangoFilterBackend,) filterset_class = FoiAttachmentFilter - permission_classes = (CreateOnlyWithScopePermission,) - required_scopes = ["upload:message"] + required_scopes = ["make:message"] + + def get_permissions(self): + if self.action == "list" or self.action == "retrieve": + return [] + return [ + CreateOnlyWithScopePermission(), + OwnsFoiRequestPermission("belongs_to__request"), + ] def get_serializer_class(self): try: diff --git a/froide/foirequest/api/views/message.py b/froide/foirequest/api/views/message.py new file mode 100644 index 000000000..be115ada6 --- /dev/null +++ b/froide/foirequest/api/views/message.py @@ -0,0 +1,55 @@ +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ + +from django_filters import rest_framework as filters +from rest_framework import permissions, viewsets +from rest_framework.exceptions import PermissionDenied +from rest_framework.views import Request + +from froide.foirequest.api.permissions import OwnsFoiRequestPermission + +from ...auth import ( + get_read_foimessage_queryset, +) +from ...models import FoiMessage +from ..serializers import FoiMessageSerializer, optimize_message_queryset + +User = get_user_model() + + +class FoiMessageFilter(filters.FilterSet): + class Meta: + model = FoiMessage + fields = ("request", "kind", "is_response", "is_draft") + + +class FoiMessageViewSet(viewsets.ModelViewSet): + serializer_class = FoiMessageSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = FoiMessageFilter + required_scopes = ["make:message"] + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + return [] + return [permissions.IsAuthenticated(), OwnsFoiRequestPermission()] + + def get_queryset(self): + qs = get_read_foimessage_queryset(self.request).order_by() + return self.optimize_query(qs) + + def optimize_query(self, qs): + return optimize_message_queryset(self.request, qs) + + def ensure_draft(self): + # only drafts can be edited + if not self.get_object().is_draft: + raise PermissionDenied(_("Cannot alter non-draft messages")) + + def update(self, request: Request, *args, **kwargs): + self.ensure_draft() + return super().update(request, *args, **kwargs) + + def destroy(self, request: Request, *args, **kwargs): + self.ensure_draft() + return super().destroy(request, *args, **kwargs) diff --git a/froide/foirequest/api_views/request.py b/froide/foirequest/api/views/request.py similarity index 97% rename from froide/foirequest/api_views/request.py rename to froide/foirequest/api/views/request.py index d01a4cc80..8357c14e9 100644 --- a/froide/foirequest/api_views/request.py +++ b/froide/foirequest/api/views/request.py @@ -10,20 +10,20 @@ from froide.campaign.models import Campaign from froide.helper.search.api_views import ESQueryMixin -from ..auth import ( +from ...auth import ( CreateOnlyWithScopePermission, get_read_foirequest_queryset, throttle_action, ) -from ..documents import FoiRequestDocument -from ..filters import FoiRequestFilterSet -from ..models import FoiRequest +from ...documents import FoiRequestDocument +from ...filters import FoiRequestFilterSet +from ...models import FoiRequest +from ...utils import check_throttle from ..serializers import ( FoiRequestDetailSerializer, FoiRequestListSerializer, MakeRequestSerializer, ) -from ..utils import check_throttle User = get_user_model() diff --git a/froide/foirequest/api_views/message.py b/froide/foirequest/api_views/message.py deleted file mode 100644 index 4b7f953c2..000000000 --- a/froide/foirequest/api_views/message.py +++ /dev/null @@ -1,38 +0,0 @@ -from django.contrib.auth import get_user_model - -from django_filters import rest_framework as filters -from rest_framework import viewsets - -from ..auth import ( - get_read_foimessage_queryset, -) -from ..models import FoiMessage -from ..serializers import FoiMessageSerializer, optimize_message_queryset - -User = get_user_model() - - -class FoiMessageFilter(filters.FilterSet): - class Meta: - model = FoiMessage - fields = ( - "request", - "kind", - "is_response", - ) - - -class FoiMessageViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = FoiMessageSerializer - filter_backends = (filters.DjangoFilterBackend,) - filterset_class = FoiMessageFilter - - def get_queryset(self): - qs = get_read_foimessage_queryset(self.request).order_by() - return self.optimize_query(qs) - - def optimize_query(self, qs): - return optimize_message_queryset(self.request, qs) - - # @action(methods=["get", "post"], detail=False, url_name="draft") - # def get_or_create_draft(self, request): diff --git a/froide/foirequest/apps.py b/froide/foirequest/apps.py index c02d1e065..1f10ca79c 100644 --- a/froide/foirequest/apps.py +++ b/froide/foirequest/apps.py @@ -23,9 +23,9 @@ def ready(self): from froide.account.export import registry from froide.api import api_router from froide.foirequest import signals # noqa - from froide.foirequest.api_views.attachment import FoiAttachmentViewSet - from froide.foirequest.api_views.message import FoiMessageViewSet - from froide.foirequest.api_views.request import FoiRequestViewSet + from froide.foirequest.api.views.attachment import FoiAttachmentViewSet + from froide.foirequest.api.views.message import FoiMessageViewSet + from froide.foirequest.api.views.request import FoiRequestViewSet from froide.helper.search import search_registry from froide.team import team_changed diff --git a/froide/foirequest/auth.py b/froide/foirequest/auth.py index 1d6eba0a1..64f406d8b 100644 --- a/froide/foirequest/auth.py +++ b/froide/foirequest/auth.py @@ -27,7 +27,8 @@ def get_campaign_auth_foirequests_filter(request: HttpRequest, fk_path=None): - if not request.user.is_staff: + # request is not available when called from manage.py generateschema + if not request or not request.user.is_staff: return None # staff user can act on all campaign-requests @@ -157,8 +158,10 @@ def can_read_foiproject_authenticated( @lru_cache() -def can_write_foirequest(foirequest: FoiRequest, request: HttpRequest) -> bool: - if can_write_object(foirequest, request): +def can_write_foirequest( + foirequest: FoiRequest, request: HttpRequest, scope=None +) -> bool: + if can_write_object(foirequest, request, scope): return True if foirequest.project: diff --git a/froide/foirequest/models/message.py b/froide/foirequest/models/message.py index 69eca030b..be79a50bf 100644 --- a/froide/foirequest/models/message.py +++ b/froide/foirequest/models/message.py @@ -37,10 +37,8 @@ def get_throttle_filter(self, queryset, user, extra_filters=None): qs = qs.filter(**extra_filters) return qs, "timestamp" - -class FoiMessageNoDraftsManager(FoiMessageManager): - def get_queryset(self): - return super().get_queryset().filter(is_draft=False) + def get_drafts(self, drafts=True): + return super().get_queryset().filter(is_draft=drafts) class MessageTag(TagBase): @@ -64,12 +62,21 @@ class MessageKind(models.TextChoices): EMAIL = ("email", _("email")) POST = ("post", _("postal mail")) FAX = ("fax", _("fax")) + # uploads by public bodies using link in foirequest UPLOAD = ("upload", _("upload")) PHONE = ("phone", _("phone call")) VISIT = ("visit", _("visit in person")) IMPORT = ("import", _("automatically imported")) +# users are allowed to only create messages of these kinds +# the other kinds can only be created by the system +MESSAGE_KIND_USER_ALLOWED = [ + MessageKind.POST, + MessageKind.PHONE, + MessageKind.VISIT, +] + MESSAGE_KIND_ICONS = { MessageKind.EMAIL: "mail", MessageKind.POST: "newspaper-o", @@ -176,7 +183,6 @@ class FoiMessage(models.Model): confirmation_sent = models.BooleanField(_("Confirmation sent?"), default=False) objects = FoiMessageManager() - no_drafts = FoiMessageNoDraftsManager() class Meta: get_latest_by = "timestamp" @@ -201,6 +207,9 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) + def is_public(self) -> bool: + return self.is_draft is False + @property def is_postal(self): return self.kind == MessageKind.POST diff --git a/froide/foirequest/models/request.py b/froide/foirequest/models/request.py index 6b5122adb..f3e4bac19 100644 --- a/froide/foirequest/models/request.py +++ b/froide/foirequest/models/request.py @@ -475,7 +475,7 @@ def ident(self): def get_messages(self, with_tags=False): qs = ( - self.foimessage_set(manager="no_drafts") + self.foimessage_set.filter(is_draft=False) .select_related( "sender_user", "sender_public_body", "recipient_public_body" ) diff --git a/froide/foirequest/tests/__init__.py b/froide/foirequest/tests/__init__.py index 2aa859b15..902825e1c 100644 --- a/froide/foirequest/tests/__init__.py +++ b/froide/foirequest/tests/__init__.py @@ -1,5 +1,5 @@ from .test_admin import * # noqa -from .test_api import * # noqa +from .test_api_request import * # noqa from .test_mail import * # noqa from .test_request import * # noqa from .test_web import * # noqa diff --git a/froide/foirequest/tests/test_api_message.py b/froide/foirequest/tests/test_api_message.py new file mode 100644 index 000000000..42d7dcbc8 --- /dev/null +++ b/froide/foirequest/tests/test_api_message.py @@ -0,0 +1,114 @@ +import json + +from django.test import Client +from django.urls import reverse + +import pytest + +from froide.foirequest.tests import factories + + +@pytest.mark.django_db +def test_message_draft(client: Client, user): + request = factories.FoiRequestFactory.create(user=user) + ok = client.login(email=user.email, password="froide") + assert ok + + # create message draft + response = client.post( + "/api/v1/message/", + data={ + "request": reverse("api:request-detail", kwargs={"pk": request.pk}), + "is_draft": True, + }, + content_type="application/json", + ) + assert response.status_code == 201 + + message_id = json.loads(response.content)["id"] + resource_uri = reverse("api:message-detail", kwargs={"pk": message_id}) + + response = client.patch( + resource_uri, data={"status": "resolved"}, content_type="application/json" + ) + data = json.loads(response.content) + assert response.status_code == 200 + assert data["status"] == "resolved" + + response = client.delete(resource_uri) + assert response.status_code == 204 + + +@pytest.mark.django_db +def test_message_not_editable(client: Client, user): + ok = client.login(email=user.email, password="froide") + assert ok + request = factories.FoiRequestFactory.create(user=user) + + # not a draft + response = client.post( + "/api/v1/message/", + data={ + "request": reverse("api:request-detail", kwargs={"pk": request.pk}), + }, + content_type="application/json", + ) + assert response.status_code == 201 + + message_id = json.loads(response.content)["id"] + resource_uri = reverse("api:message-detail", kwargs={"pk": message_id}) + + response = client.delete(resource_uri) + assert response.status_code == 403 + + # first draft, then not + response = client.post( + "/api/v1/message/", + data={ + "request": reverse("api:request-detail", kwargs={"pk": request.pk}), + "is_draft": True, + }, + content_type="application/json", + ) + assert response.status_code == 201 + + message_id = json.loads(response.content)["id"] + resource_uri = reverse("api:message-detail", kwargs={"pk": message_id}) + + response = client.patch( + resource_uri, data={"is_draft": False}, content_type="application/json" + ) + assert response.status_code == 200 + + response = client.delete(resource_uri) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_auth(client, user): + user2 = factories.UserFactory.create() + request = factories.FoiRequestFactory.create(user=user2) + + # need to be logged in + client.logout() + response = client.post( + "/api/v1/message/", + data={ + "request": reverse("api:request-detail", kwargs={"pk": request.pk}), + "is_draft": True, + }, + content_type="application/json", + ) + assert response.status_code == 401 + + # needs to be own request + client.login(email=user.email, password="froide") + response = client.post( + "/api/v1/message/", + data={ + "request": reverse("api:request-detail", kwargs={"pk": request.pk}), + "is_draft": True, + }, + content_type="application/json", + ) + assert response.status_code == 403 diff --git a/froide/foirequest/tests/test_api.py b/froide/foirequest/tests/test_api_request.py similarity index 97% rename from froide/foirequest/tests/test_api.py rename to froide/foirequest/tests/test_api_request.py index 94b5d16a9..c998245cd 100644 --- a/froide/foirequest/tests/test_api.py +++ b/froide/foirequest/tests/test_api_request.py @@ -25,6 +25,7 @@ class ApiTest(TestCase): def setUp(self): self.site = factories.make_world() + self.test_user = User.objects.get(username="dummy") def test_list(self): response = self.client.get("/api/v1/request/") @@ -215,6 +216,7 @@ def test_list_private_requests_when_logged_in(self): self.assertEqual(response.status_code, 200) result = json.loads(response.content.decode("utf-8")) self.assertEqual(result["meta"]["total_count"], 2) + self.client.logout() def test_list_private_requests_without_scope(self): response, result = self.api_get(self.request_list_url) @@ -349,6 +351,7 @@ def test_request_creation_with_scope(self): def test_foiattachment_upload(self): mes = factories.FoiMessageFactory.create(request=self.req, kind="post") + mes_url = reverse("api:message-detail", kwargs={"pk": mes.pk}) att_url = reverse("api:attachment-list") upload = Upload.objects.create( @@ -360,22 +363,22 @@ def test_foiattachment_upload(self): upload_url = reverse("api:upload-detail", kwargs={"guid": str(upload.guid)}) response, result = self.api_post( - att_url, {"message": mes.pk, "upload": upload_url} + att_url, {"message": mes_url, "upload": upload_url} ) assert response.status_code == 403 # Set correct scope - self.access_token.scope = "read:request upload:message" + self.access_token.scope = "read:request make:message" self.access_token.save() fake_upload_url = "y" + upload_url[1:] response, result = self.api_post( - att_url, {"message": mes.pk, "upload": fake_upload_url} + att_url, {"message": mes_url, "upload": fake_upload_url} ) assert response.status_code == 400 response, result = self.api_post( - att_url, {"message": mes.pk, "upload": upload_url} + att_url, {"message": mes_url, "upload": upload_url} ) assert response.status_code == 201 diff --git a/froide/foirequest/utils.py b/froide/foirequest/utils.py index 10b04b7b5..ef9b25193 100644 --- a/froide/foirequest/utils.py +++ b/froide/foirequest/utils.py @@ -746,7 +746,7 @@ def tz(x): def export_user_data(user): from froide.helper.api_utils import get_fake_api_context - from .api_views import ( + from .api.serializers import ( FoiAttachmentSerializer, FoiMessageSerializer, FoiRequestListSerializer, diff --git a/froide/foirequest/views/make_request.py b/froide/foirequest/views/make_request.py index dfa4f80ef..001e63d6f 100644 --- a/froide/foirequest/views/make_request.py +++ b/froide/foirequest/views/make_request.py @@ -21,7 +21,7 @@ from froide.helper.auth import get_read_queryset from froide.helper.utils import update_query_params from froide.proof.forms import ProofMessageForm -from froide.publicbody.api_views import PublicBodyListSerializer +from froide.publicbody.api.serializers import PublicBodyListSerializer from froide.publicbody.forms import MultiplePublicBodyForm, PublicBodyForm from froide.publicbody.models import PublicBody from froide.publicbody.widgets import get_widget_context diff --git a/froide/foirequest/views/message.py b/froide/foirequest/views/message.py index f4bfe7e62..5811c9bdb 100644 --- a/froide/foirequest/views/message.py +++ b/froide/foirequest/views/message.py @@ -23,6 +23,7 @@ from froide.proof.forms import handle_proof_form from froide.upload.forms import get_uppy_i18n +from ..api.serializers import FoiAttachmentSerializer, FoiMessageSerializer from ..decorators import ( allow_moderate_foirequest, allow_read_foirequest_authenticated, @@ -47,7 +48,6 @@ from ..models import FoiAttachment, FoiEvent, FoiMessage, FoiRequest from ..models.attachment import IMAGE_FILETYPES, PDF_FILETYPES, POSTAL_CONTENT_TYPES from ..models.message import MessageKind -from ..serializers import FoiAttachmentSerializer, FoiMessageSerializer from ..services import ResendBouncedMessageService from ..tasks import convert_images_to_pdf_task from ..utils import check_throttle diff --git a/froide/foirequestfollower/api_views.py b/froide/foirequestfollower/api_views.py index 32f20b6ed..6a609644f 100644 --- a/froide/foirequestfollower/api_views.py +++ b/froide/foirequestfollower/api_views.py @@ -188,11 +188,14 @@ def get_foirequest_queryset(self, requests=None): def get_request_filter(self): if not hasattr(self, "_requests_filter"): - requests = self.request.query_params.get("request", "").split(",") try: + requests = self.request.query_params.get("request", "").split(",") requests = [int(r) for r in requests] - except ValueError: + except (ValueError, AttributeError): + # request is not available when called from manage.py generateschema + # request.query_params therefore raises an AttributeError requests = [] + self._requests_filter = requests return self._requests_filter diff --git a/froide/foirequestfollower/configuration.py b/froide/foirequestfollower/configuration.py index 4d6848d89..bdd2168c5 100644 --- a/froide/foirequestfollower/configuration.py +++ b/froide/foirequestfollower/configuration.py @@ -34,7 +34,8 @@ class FoiRequestFollowConfiguration(FollowConfiguration): def get_content_object_queryset(self, request): qs = get_read_foirequest_queryset(request) - if request.user.is_authenticated: + # request is not available when called from manage.py generateschema + if request and request.user.is_authenticated: qs = qs.exclude(user=request.user) return qs diff --git a/froide/foirequestfollower/tests.py b/froide/foirequestfollower/tests.py index 134b7d09e..4c361b1fd 100644 --- a/froide/foirequestfollower/tests.py +++ b/froide/foirequestfollower/tests.py @@ -14,7 +14,7 @@ from froide.foirequest.models import FoiRequest from froide.foirequest.models.message import MessageKind from froide.foirequest.tests import factories -from froide.foirequest.tests.test_api import OAuthAPIMixin +from froide.foirequest.tests.test_api_request import OAuthAPIMixin from froide.follow.notifications import run_batch_update from .configuration import FoiRequestFollowConfiguration diff --git a/froide/georegion/api_views.py b/froide/georegion/api_views.py index 972ace7c4..2573a2142 100644 --- a/froide/georegion/api_views.py +++ b/froide/georegion/api_views.py @@ -174,11 +174,13 @@ class RECONCILIATION_META: properties_dict = {p["id"]: p for p in properties} def get_serializer_class(self): - if self.request.user.is_superuser: - return GeoRegionDetailSerializer try: + if self.request.user.is_superuser: + return GeoRegionDetailSerializer return self.serializer_action_classes[self.action] except (KeyError, AttributeError): + # request is not available when called from manage.py generateschema + # request.user therefore raises an AttributeError return GeoRegionSerializer def _search_reconciliation_results(self, query, filters, limit): diff --git a/froide/helper/auth.py b/froide/helper/auth.py index 3059e5dcf..c7f3ae7e5 100644 --- a/froide/helper/auth.py +++ b/froide/helper/auth.py @@ -75,8 +75,8 @@ def can_read_object_authenticated(obj, request=None): @lru_cache() -def can_write_object(obj, request): - return has_authenticated_access(obj, request) +def can_write_object(obj, request, scope=None): + return has_authenticated_access(obj, request, scope=scope) @lru_cache() @@ -118,7 +118,6 @@ def get_read_queryset( fk_path=None, user_read_filter=None, ): - user = request.user filters = [] if public_field is not None: if public_q is not None: @@ -131,6 +130,11 @@ def get_read_queryset( else: unauth_qs = qs.none() + # request is not available when called from manage.py generateschema + if not request: + return unauth_qs + user = request.user + if not user.is_authenticated: return unauth_qs @@ -169,10 +173,9 @@ def get_read_queryset( def get_write_queryset( qs, request, has_team=False, user_write_filter=None, scope=None, fk_path=None ): - user = request.user - - if not user.is_authenticated: + if not request or not request.user.is_authenticated: return qs.none() + user = request.user # OAuth token token = getattr(request, "auth", None) diff --git a/froide/publicbody/api/__init__.py b/froide/publicbody/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/froide/publicbody/api/serializers.py b/froide/publicbody/api/serializers.py new file mode 100644 index 000000000..8cc3442d1 --- /dev/null +++ b/froide/publicbody/api/serializers.py @@ -0,0 +1,295 @@ +import json + +from django.conf import settings +from django.utils import translation + +from rest_framework import serializers + +from froide.helper.api_utils import ( + SearchFacetListSerializer, +) + +from ..models import Category, Classification, FoiLaw, Jurisdiction, PublicBody + + +def get_language_from_query(request): + # request is not available when called from manage.py generateschema + if request: + lang = request.GET.get("language", settings.LANGUAGE_CODE) + lang_dict = dict(settings.LANGUAGES) + if lang in lang_dict: + return lang + return settings.LANGUAGE_CODE + + +class JurisdictionSerializer(serializers.HyperlinkedModelSerializer): + resource_uri = serializers.HyperlinkedIdentityField( + view_name="api:jurisdiction-detail", lookup_field="pk" + ) + region = serializers.HyperlinkedRelatedField( + view_name="api:georegion-detail", lookup_field="pk", read_only=True + ) + site_url = serializers.CharField(source="get_absolute_domain_url") + + class Meta: + model = Jurisdiction + depth = 0 + fields = ( + "resource_uri", + "id", + "name", + "rank", + "description", + "slug", + "site_url", + "region", + "last_modified_at", + ) + + +class SimpleFoiLawSerializer(serializers.HyperlinkedModelSerializer): + resource_uri = serializers.HyperlinkedIdentityField( + view_name="api:law-detail", lookup_field="pk" + ) + jurisdiction = serializers.HyperlinkedRelatedField( + view_name="api:jurisdiction-detail", lookup_field="pk", read_only=True + ) + mediator = serializers.HyperlinkedRelatedField( + view_name="api:publicbody-detail", lookup_field="pk", read_only=True + ) + site_url = serializers.SerializerMethodField() + last_modified_at = serializers.CharField(source="updated") + + class Meta: + model = FoiLaw + depth = 0 + fields = ( + "resource_uri", + "id", + "name", + "slug", + "description", + "long_description", + "law_type", + "created", + "request_note", + "request_note_html", + "meta", + "site_url", + "jurisdiction", + "email_only", + "mediator", + "priority", + "url", + "max_response_time", + "email_only", + "requires_signature", + "max_response_time_unit", + "letter_start", + "letter_end", + "last_modified_at", + ) + + def get_site_url(self, obj): + language = get_language_from_query(self.context.get("request")) + with translation.override(language): + return obj.get_absolute_domain_url() + + def to_representation(self, instance): + """Activate language based on request query param.""" + language = get_language_from_query(self.context.get("request")) + instance.set_current_language(language) + ret = super().to_representation(instance) + return ret + + +class FoiLawSerializer(SimpleFoiLawSerializer): + combined = serializers.HyperlinkedRelatedField( + view_name="api:law-detail", lookup_field="pk", read_only=True, many=True + ) + + class Meta(SimpleFoiLawSerializer.Meta): + fields = SimpleFoiLawSerializer.Meta.fields + ( + "refusal_reasons", + "combined", + ) + + +class SimpleClassificationSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Classification + fields = ( + "id", + "name", + "slug", + "depth", + ) + + +class ClassificationSerializer(SimpleClassificationSerializer): + parent = serializers.HyperlinkedRelatedField( + source="get_parent", read_only=True, view_name="api:classification-detail" + ) + children = serializers.HyperlinkedRelatedField( + source="get_children", + many=True, + read_only=True, + view_name="api:classification-detail", + ) + + class Meta(SimpleClassificationSerializer.Meta): + fields = SimpleClassificationSerializer.Meta.fields + ("parent", "children") + + +class TreeMixin(object): + def get_parent(self, obj): + return obj.get_parent() + + def get_children(self, obj): + return obj.get_children() + + +class SimpleCategorySerializer(TreeMixin, serializers.HyperlinkedModelSerializer): + class Meta: + model = Category + fields = ( + "id", + "name", + "slug", + "is_topic", + "depth", + ) + + +class CategorySerializer(SimpleCategorySerializer): + parent = serializers.HyperlinkedRelatedField( + source="get_parent", read_only=True, view_name="api:category-detail" + ) + children = serializers.HyperlinkedRelatedField( + source="get_children", + many=True, + read_only=True, + view_name="api:category-detail", + ) + + class Meta(SimpleCategorySerializer.Meta): + fields = SimpleCategorySerializer.Meta.fields + ("parent", "children") + + +class SimplePublicBodySerializer(serializers.HyperlinkedModelSerializer): + resource_uri = serializers.HyperlinkedIdentityField( + view_name="api:publicbody-detail", lookup_field="pk" + ) + id = serializers.IntegerField(source="pk") + jurisdiction = serializers.HyperlinkedRelatedField( + view_name="api:jurisdiction-detail", + read_only=True, + ) + classification = serializers.HyperlinkedRelatedField( + view_name="api:classification-detail", read_only=True + ) + + site_url = serializers.CharField(source="get_absolute_domain_url") + geo = serializers.SerializerMethodField() + last_modified_at = serializers.CharField(source="updated_at") + + class Meta: + model = PublicBody + depth = 0 + fields = ( + "resource_uri", + "id", + "name", + "slug", + "other_names", + "description", + "url", + "depth", + "classification", + "email", + "contact", + "address", + "fax", + "request_note", + "number_of_requests", + "site_url", + "jurisdiction", + "request_note_html", + "geo", + "last_modified_at", + ) + + def get_geo(self, obj): + if obj.geo is not None: + return json.loads(obj.geo.json) + return None + + +class PublicBodyListSerializer(serializers.HyperlinkedModelSerializer): + resource_uri = serializers.HyperlinkedIdentityField( + view_name="api:publicbody-detail", lookup_field="pk" + ) + root = serializers.HyperlinkedRelatedField( + view_name="api:publicbody-detail", read_only=True + ) + parent = serializers.HyperlinkedRelatedField( + view_name="api:publicbody-detail", read_only=True + ) + + id = serializers.IntegerField(source="pk") + jurisdiction = JurisdictionSerializer(read_only=True) + laws = serializers.HyperlinkedRelatedField( + view_name="api:law-detail", many=True, read_only=True + ) + categories = SimpleCategorySerializer(read_only=True, many=True) + classification = SimpleClassificationSerializer(read_only=True) + regions = serializers.HyperlinkedRelatedField( + view_name="api:georegion-detail", read_only=True, many=True + ) + + site_url = serializers.CharField(source="get_absolute_domain_url") + geo = serializers.SerializerMethodField() + + class Meta: + model = PublicBody + list_serializer_class = SearchFacetListSerializer + depth = 0 + fields = ( + "resource_uri", + "id", + "name", + "slug", + "other_names", + "description", + "url", + "parent", + "root", + "depth", + "classification", + "categories", + "email", + "contact", + "address", + "fax", + "request_note", + "number_of_requests", + "site_url", + "request_note_html", + "jurisdiction", + "laws", + "regions", + "source_reference", + "alternative_emails", + "wikidata_item", + "extra_data", + "geo", + ) + + def get_geo(self, obj): + if obj.geo is not None: + return json.loads(obj.geo.json) + return None + + +class PublicBodySerializer(PublicBodyListSerializer): + laws = FoiLawSerializer(many=True, read_only=True) diff --git a/froide/publicbody/api_views.py b/froide/publicbody/api/views.py similarity index 55% rename from froide/publicbody/api_views.py rename to froide/publicbody/api/views.py index 85a9d5a13..6c11c57d3 100644 --- a/froide/publicbody/api_views.py +++ b/froide/publicbody/api/views.py @@ -1,13 +1,10 @@ -import json - from django.conf import settings from django.contrib.gis.geos import Point from django.db.models import Q -from django.utils import translation from django_filters import rest_framework as filters from elasticsearch_dsl.query import Q as ESQ -from rest_framework import serializers, viewsets +from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.settings import api_settings from rest_framework_jsonp.renderers import JSONPRenderer @@ -15,22 +12,20 @@ from froide.georegion.models import GeoRegion from froide.helper.api_utils import ( OpenRefineReconciliationMixin, - SearchFacetListSerializer, ) from froide.helper.search import SearchQuerySetWrapper from froide.helper.search.api_views import ESQueryMixin -from .documents import PublicBodyDocument -from .models import Category, Classification, FoiLaw, Jurisdiction, PublicBody - - -def get_language_from_query(request): - if request: - lang = request.GET.get("language", settings.LANGUAGE_CODE) - lang_dict = dict(settings.LANGUAGES) - if lang in lang_dict: - return lang - return settings.LANGUAGE_CODE +from ..documents import PublicBodyDocument +from ..models import Category, Classification, FoiLaw, Jurisdiction, PublicBody +from .serializers import ( + CategorySerializer, + ClassificationSerializer, + FoiLawSerializer, + JurisdictionSerializer, + PublicBodyListSerializer, + PublicBodySerializer, +) def make_search_filter(field): @@ -45,104 +40,11 @@ def search_filter(self, queryset, name, value): return search_filter -class JurisdictionSerializer(serializers.HyperlinkedModelSerializer): - resource_uri = serializers.HyperlinkedIdentityField( - view_name="api:jurisdiction-detail", lookup_field="pk" - ) - region = serializers.HyperlinkedRelatedField( - view_name="api:georegion-detail", lookup_field="pk", read_only=True - ) - site_url = serializers.CharField(source="get_absolute_domain_url") - - class Meta: - model = Jurisdiction - depth = 0 - fields = ( - "resource_uri", - "id", - "name", - "rank", - "description", - "slug", - "site_url", - "region", - "last_modified_at", - ) - - class JurisdictionViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = JurisdictionSerializer queryset = Jurisdiction.objects.all() -class SimpleFoiLawSerializer(serializers.HyperlinkedModelSerializer): - resource_uri = serializers.HyperlinkedIdentityField( - view_name="api:law-detail", lookup_field="pk" - ) - jurisdiction = serializers.HyperlinkedRelatedField( - view_name="api:jurisdiction-detail", lookup_field="pk", read_only=True - ) - mediator = serializers.HyperlinkedRelatedField( - view_name="api:publicbody-detail", lookup_field="pk", read_only=True - ) - site_url = serializers.SerializerMethodField() - last_modified_at = serializers.CharField(source="updated") - - class Meta: - model = FoiLaw - depth = 0 - fields = ( - "resource_uri", - "id", - "name", - "slug", - "description", - "long_description", - "law_type", - "created", - "request_note", - "request_note_html", - "meta", - "site_url", - "jurisdiction", - "email_only", - "mediator", - "priority", - "url", - "max_response_time", - "email_only", - "requires_signature", - "max_response_time_unit", - "letter_start", - "letter_end", - "last_modified_at", - ) - - def get_site_url(self, obj): - language = get_language_from_query(self.context.get("request")) - with translation.override(language): - return obj.get_absolute_domain_url() - - def to_representation(self, instance): - """Activate language based on request query param.""" - language = get_language_from_query(self.context.get("request")) - instance.set_current_language(language) - ret = super().to_representation(instance) - return ret - - -class FoiLawSerializer(SimpleFoiLawSerializer): - combined = serializers.HyperlinkedRelatedField( - view_name="api:law-detail", lookup_field="pk", read_only=True, many=True - ) - - class Meta(SimpleFoiLawSerializer.Meta): - fields = SimpleFoiLawSerializer.Meta.fields + ( - "refusal_reasons", - "combined", - ) - - class FoiLawFilter(filters.FilterSet): id = filters.CharFilter(method="id_filter") q = filters.CharFilter(method="search_filter") @@ -195,40 +97,6 @@ def autocomplete(self, request): ) -class TreeMixin(object): - def get_parent(self, obj): - return obj.get_parent() - - def get_children(self, obj): - return obj.get_children() - - -class SimpleClassificationSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = Classification - fields = ( - "id", - "name", - "slug", - "depth", - ) - - -class ClassificationSerializer(SimpleClassificationSerializer): - parent = serializers.HyperlinkedRelatedField( - source="get_parent", read_only=True, view_name="api:classification-detail" - ) - children = serializers.HyperlinkedRelatedField( - source="get_children", - many=True, - read_only=True, - view_name="api:classification-detail", - ) - - class Meta(SimpleClassificationSerializer.Meta): - fields = SimpleClassificationSerializer.Meta.fields + ("parent", "children") - - class TreeFilterMixin(object): def parent_filter(self, queryset, name, value): return queryset.intersection(value.get_children()) @@ -263,33 +131,6 @@ class ClassificationViewSet(viewsets.ReadOnlyModelViewSet): filterset_class = ClassificationFilter -class SimpleCategorySerializer(TreeMixin, serializers.HyperlinkedModelSerializer): - class Meta: - model = Category - fields = ( - "id", - "name", - "slug", - "is_topic", - "depth", - ) - - -class CategorySerializer(SimpleCategorySerializer): - parent = serializers.HyperlinkedRelatedField( - source="get_parent", read_only=True, view_name="api:category-detail" - ) - children = serializers.HyperlinkedRelatedField( - source="get_children", - many=True, - read_only=True, - view_name="api:category-detail", - ) - - class Meta(SimpleCategorySerializer.Meta): - fields = SimpleCategorySerializer.Meta.fields + ("parent", "children") - - class CategoryFilter(TreeFilterMixin, filters.FilterSet): q = filters.CharFilter(method="search_filter") parent = filters.ModelChoiceFilter( @@ -328,125 +169,6 @@ def autocomplete(self, request): ) -class SimplePublicBodySerializer(serializers.HyperlinkedModelSerializer): - resource_uri = serializers.HyperlinkedIdentityField( - view_name="api:publicbody-detail", lookup_field="pk" - ) - id = serializers.IntegerField(source="pk") - jurisdiction = serializers.HyperlinkedRelatedField( - view_name="api:jurisdiction-detail", - read_only=True, - ) - classification = serializers.HyperlinkedRelatedField( - view_name="api:classification-detail", read_only=True - ) - - site_url = serializers.CharField(source="get_absolute_domain_url") - geo = serializers.SerializerMethodField() - last_modified_at = serializers.CharField(source="updated_at") - - class Meta: - model = PublicBody - depth = 0 - fields = ( - "resource_uri", - "id", - "name", - "slug", - "other_names", - "description", - "url", - "depth", - "classification", - "email", - "contact", - "address", - "fax", - "request_note", - "number_of_requests", - "site_url", - "jurisdiction", - "request_note_html", - "geo", - "last_modified_at", - ) - - def get_geo(self, obj): - if obj.geo is not None: - return json.loads(obj.geo.json) - return None - - -class PublicBodyListSerializer(serializers.HyperlinkedModelSerializer): - resource_uri = serializers.HyperlinkedIdentityField( - view_name="api:publicbody-detail", lookup_field="pk" - ) - root = serializers.HyperlinkedRelatedField( - view_name="api:publicbody-detail", read_only=True - ) - parent = serializers.HyperlinkedRelatedField( - view_name="api:publicbody-detail", read_only=True - ) - - id = serializers.IntegerField(source="pk") - jurisdiction = JurisdictionSerializer(read_only=True) - laws = serializers.HyperlinkedRelatedField( - view_name="api:law-detail", many=True, read_only=True - ) - categories = SimpleCategorySerializer(read_only=True, many=True) - classification = SimpleClassificationSerializer(read_only=True) - regions = serializers.HyperlinkedRelatedField( - view_name="api:georegion-detail", read_only=True, many=True - ) - - site_url = serializers.CharField(source="get_absolute_domain_url") - geo = serializers.SerializerMethodField() - - class Meta: - model = PublicBody - list_serializer_class = SearchFacetListSerializer - depth = 0 - fields = ( - "resource_uri", - "id", - "name", - "slug", - "other_names", - "description", - "url", - "parent", - "root", - "depth", - "classification", - "categories", - "email", - "contact", - "address", - "fax", - "request_note", - "number_of_requests", - "site_url", - "request_note_html", - "jurisdiction", - "laws", - "regions", - "source_reference", - "alternative_emails", - "wikidata_item", - "extra_data", - "geo", - ) - - def get_geo(self, obj): - if obj.geo is not None: - return json.loads(obj.geo.json) - return None - - -class PublicBodySerializer(PublicBodyListSerializer): - laws = FoiLawSerializer(many=True, read_only=True) - - class PublicBodyFilter(filters.FilterSet): q = filters.CharFilter(method="search_filter") classification = filters.ModelChoiceFilter( diff --git a/froide/publicbody/apps.py b/froide/publicbody/apps.py index 5582fcad7..e9db1f056 100644 --- a/froide/publicbody/apps.py +++ b/froide/publicbody/apps.py @@ -20,7 +20,7 @@ def ready(self): from froide.api import api_router - from .api_views import ( + from .api.views import ( CategoryViewSet, ClassificationViewSet, FoiLawViewSet, diff --git a/froide/publicbody/models.py b/froide/publicbody/models.py index 77f1084d1..d25a673cf 100644 --- a/froide/publicbody/models.py +++ b/froide/publicbody/models.py @@ -216,7 +216,7 @@ def get_refusal_reason_choices(self): def as_data(self, request=None): from froide.helper.api_utils import get_fake_api_context - from .api_views import FoiLawSerializer + from .api.serializers import FoiLawSerializer if request is None: ctx = get_fake_api_context() @@ -584,12 +584,12 @@ def _as_data(self, serializer_klass, request=None): return serializer_klass(self, context=ctx).data def as_data(self, request=None): - from .api_views import PublicBodyListSerializer + from .api.serializers import PublicBodyListSerializer return self._as_data(PublicBodyListSerializer) def as_simple_data(self, request=None): - from .api_views import SimplePublicBodySerializer + from .api.serializers import SimplePublicBodySerializer return self._as_data(SimplePublicBodySerializer) diff --git a/froide/settings.py b/froide/settings.py index 428349cbe..8b85e96c4 100644 --- a/froide/settings.py +++ b/froide/settings.py @@ -497,7 +497,7 @@ def is_pkce_required(client_id): "read:email": _("Read user email"), "read:request": _("Read your (private) requests"), "make:request": _("Make requests on your behalf"), - "upload:message": _("Upload postal messages"), + "create:message": _("Add messages and attachments to user's request"), "follow:request": _("Follow/Unfollow requests"), "read:document": _("Read your (private) documents"), },