From d50c1706d88d46a9333ab404d73e7664bc91e988 Mon Sep 17 00:00:00 2001 From: Victor Perron Date: Mon, 15 Apr 2024 16:30:21 +0200 Subject: [PATCH] feat(api) : Add datacube tenders API A very simple and flat API, with a basic but efficient authentication scheme. At the time of writing, there are about ~5000 tenders in the database. We'll see about time filtering later as the request to retrieve the whole list takes about 1 second. --- config/settings/base.py | 4 ++ lemarche/api/datacube/__init__.py | 0 lemarche/api/datacube/tests.py | 90 +++++++++++++++++++++++++++++++ lemarche/api/datacube/views.py | 69 ++++++++++++++++++++++++ lemarche/api/urls.py | 3 ++ 5 files changed, 166 insertions(+) create mode 100644 lemarche/api/datacube/__init__.py create mode 100644 lemarche/api/datacube/tests.py create mode 100644 lemarche/api/datacube/views.py diff --git a/config/settings/base.py b/config/settings/base.py index 71913e33d..fb3052223 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -12,6 +12,7 @@ import locale import os +import datetime import environ from django.contrib.messages import constants as messages @@ -901,3 +902,6 @@ ELASTICSEARCH_PASSWORD = env.str("ELASTICSEARCH_PASSWORD", "") ELASTICSEARCH_INDEX_SIAES = env.str("ELASTICSEARCH_INDEX_SIAES", "") ELASTICSEARCH_MIN_SCORE = env.float("ELASTICSEARCH_MIN_SCORE", 0.9) + +DATACUBE_API_TOKEN = env.str("DATACUBE_API_TOKEN", "") +DATACUBE_API_TENDER_START_DATE = datetime.datetime(2023, 1, 1, tzinfo=datetime.UTC) diff --git a/lemarche/api/datacube/__init__.py b/lemarche/api/datacube/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lemarche/api/datacube/tests.py b/lemarche/api/datacube/tests.py new file mode 100644 index 000000000..9ce5afb22 --- /dev/null +++ b/lemarche/api/datacube/tests.py @@ -0,0 +1,90 @@ +import freezegun +from django.test import TestCase, override_settings +from django.urls import reverse + +from lemarche.companies.factories import CompanyFactory +from lemarche.tenders.factories import TenderFactory +from lemarche.users.factories import UserFactory +from lemarche.users.models import User + + +class DatacubeApiTest(TestCase): + maxDiff = None + + @override_settings(DATACUBE_API_TOKEN="bar") + def test_list_tenders_authentication(self): + url = reverse("api:datacube-tenders") + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + # an appropriate token from the settings is required + response = self.client.get(url, headers={"Authorization": "Token "}) + self.assertEqual(response.status_code, 401) + + response = self.client.get(url, headers={"Authorization": "Token foo"}) + self.assertEqual(response.status_code, 401) + + response = self.client.get(url, headers={"Authorization": "Token bar"}) + self.assertEqual(response.status_code, 200) + + # or alternatively, if you're logged in as superuser + admin = UserFactory(kind="ADMIN") + self.client.force_login(admin) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + admin.is_superuser = True + admin.save(update_fields=["is_superuser"]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + @freezegun.freeze_time("2024-06-21 12:23:34") + @override_settings(DATACUBE_API_TOKEN="bar") + def test_list_tenders_content(self): + url = reverse("api:datacube-tenders") + response = self.client.get(url, headers={"Authorization": "Token bar"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"count": 0, "next": None, "previous": None, "results": []}) + + user = UserFactory(kind=User.KIND_BUYER) + company = CompanyFactory(name="Lagarde et Fils", users=[user]) + tender_with_company = TenderFactory(title="Sébastien Le Lopez", author=user) + + tender = TenderFactory(title="Marc Henry", presta_type=["FANFAN", "LA", "TULIPE"]) + + response = self.client.get(url, headers={"Authorization": "Token bar"}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "amount": None, + "company_name": "Lagarde et Fils", + "company_slug": "lagarde-et-fils", + "created_at": "2024-06-21T14:23:34+02:00", + "kind": "QUOTE", + "presta_type": [], + "slug": "sebastien-le-lopez", + "source": "FORM", + "status": "SENT", + "title": "Sébastien Le Lopez", + "updated_at": "2024-06-21T14:23:34+02:00", + }, + { + "amount": None, + "created_at": "2024-06-21T14:23:34+02:00", + "kind": "QUOTE", + "presta_type": ["FANFAN", "LA", "TULIPE"], + "slug": "marc-henry", + "source": "FORM", + "status": "SENT", + "title": "Marc Henry", + "updated_at": "2024-06-21T14:23:34+02:00", + }, + ], + }, + ) diff --git a/lemarche/api/datacube/views.py b/lemarche/api/datacube/views.py new file mode 100644 index 000000000..214395340 --- /dev/null +++ b/lemarche/api/datacube/views.py @@ -0,0 +1,69 @@ +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from rest_framework import authentication, exceptions, generics, permissions, serializers + +from lemarche.tenders.models import Tender + + +class DatacubeApiAnonymousUser(AnonymousUser): + pass + + +class DatacubeApiAuthentication(authentication.TokenAuthentication): + def authenticate_credentials(self, key): + configured_token = settings.DATACUBE_API_TOKEN + if configured_token and key == configured_token: + return (DatacubeApiAnonymousUser(), key) + raise exceptions.AuthenticationFailed("Invalid token.") + + +class HasTokenOrIsSuperadmin(permissions.BasePermission): + def has_permission(self, request, view): + if isinstance(request.user, DatacubeApiAnonymousUser): + return True + return request.user.is_superuser + + +class SimpleTenderSerializer(serializers.ModelSerializer): + slug = serializers.CharField(read_only=True) + company_name = serializers.CharField(source="author.company.name", read_only=True) + company_slug = serializers.CharField(source="author.company.slug", read_only=True) + + class Meta: + model = Tender + fields = [ + "created_at", + "updated_at", + "title", + "slug", + "kind", + "presta_type", + "amount", + "status", + "source", + "company_name", + "company_slug", + ] + + +class SimpleTenderList(generics.ListAPIView): + """Simplified list of tenders along with their listed companies. + + curl -H "Authorization: Token xxxxx" http://marche.fqdn/api/datacube-tenders/ + """ + + queryset = ( + Tender.objects.filter(created_at__gte=settings.DATACUBE_API_TENDER_START_DATE) + .prefetch_related("author__company") + .order_by("-created_at") + .all() + ) + serializer_class = SimpleTenderSerializer + permission_classes = [] + authentication_classes = [] + + authentication_classes = ( + DatacubeApiAuthentication, + authentication.SessionAuthentication, + ) + permission_classes = (HasTokenOrIsSuperadmin,) diff --git a/lemarche/api/urls.py b/lemarche/api/urls.py index 9a891cde8..84460ee9f 100644 --- a/lemarche/api/urls.py +++ b/lemarche/api/urls.py @@ -9,6 +9,7 @@ from lemarche.api.sectors.views import SectorViewSet from lemarche.api.siaes.views import SiaeKindViewSet, SiaePrestaTypeViewSet, SiaeViewSet from lemarche.api.tenders.views import TenderAmountViewSet, TenderKindViewSet, TenderViewSet +from lemarche.api.datacube.views import SimpleTenderList # https://docs.djangoproject.com/en/dev/topics/http/urls/#url-namespaces-and-included-urlconfs @@ -39,6 +40,8 @@ name="old_api_siae_siret", ), path("inbound-email-parsing/", InboundParsingEmailView.as_view(), name="inbound-email-parsing"), + + path("datacube-tenders/", SimpleTenderList.as_view(), name="datacube-tenders"), # Swagger / OpenAPI documentation path("schema/", SpectacularAPIView.as_view(), name="schema"), path("docs/", SpectacularSwaggerView.as_view(url_name="api:schema"), name="swagger-ui"),