From 502283924209ff7d6b7aa11b1e7c6ad301565a17 Mon Sep 17 00:00:00 2001 From: Jean-Etienne Castagnede Date: Mon, 1 Jul 2024 16:28:22 +0200 Subject: [PATCH] API endpoints and dynamic responses (#9) * add api endpoints * enable basic auth * change event to Observation * lint and set tests --- backend/dev-requirements.txt | 10 +- backend/project/accounts/admin.py | 6 +- backend/project/accounts/managers.py | 1 - .../accounts/migrations/0001_initial.py | 10 +- backend/project/accounts/models.py | 9 +- .../{events => accounts/tests}/__init__.py | 0 backend/project/accounts/tests/factories.py | 17 +++ backend/project/accounts/tests/test_admin.py | 16 +++ backend/project/accounts/tests/test_models.py | 14 ++ backend/project/api/__init__.py | 71 ----------- backend/project/api/apps.py | 8 ++ backend/project/api/filters.py | 16 ++- backend/project/api/serializers.py | 120 ------------------ .../serializers}/__init__.py | 0 backend/project/api/serializers/accounts.py | 28 ++++ backend/project/api/serializers/common.py | 10 ++ .../project/api/serializers/observations.py | 99 +++++++++++++++ .../tests.py => api/tests/__init__.py} | 0 backend/project/api/tests/test_serializers.py | 21 +++ backend/project/api/urls.py | 40 +++++- backend/project/api/views.py | 90 +++++++++++++ .../tests.py => observations/__init__.py} | 0 .../project/{events => observations}/admin.py | 36 ++++-- .../project/{events => observations}/apps.py | 4 +- .../locale/en/LC_MESSAGES/django.po | 12 +- .../locale/fr/LC_MESSAGES/django.po | 24 ++-- .../migrations/0001_initial.py | 64 +++++----- .../observations/migrations/__init__.py | 0 .../{events => observations}/models.py | 47 ++++--- backend/project/observations/tests.py | 0 backend/project/settings/__init__.py | 14 +- backend/project/settings/dev.py | 1 + backend/requirements.in | 5 +- backend/requirements.txt | 16 ++- 34 files changed, 498 insertions(+), 311 deletions(-) rename backend/project/{events => accounts/tests}/__init__.py (100%) create mode 100644 backend/project/accounts/tests/factories.py create mode 100644 backend/project/accounts/tests/test_admin.py create mode 100644 backend/project/accounts/tests/test_models.py create mode 100644 backend/project/api/apps.py delete mode 100644 backend/project/api/serializers.py rename backend/project/{events/migrations => api/serializers}/__init__.py (100%) create mode 100644 backend/project/api/serializers/accounts.py create mode 100644 backend/project/api/serializers/common.py create mode 100644 backend/project/api/serializers/observations.py rename backend/project/{accounts/tests.py => api/tests/__init__.py} (100%) create mode 100644 backend/project/api/tests/test_serializers.py create mode 100644 backend/project/api/views.py rename backend/project/{events/tests.py => observations/__init__.py} (100%) rename backend/project/{events => observations}/admin.py (77%) rename backend/project/{events => observations}/apps.py (56%) rename backend/project/{events => observations}/locale/en/LC_MESSAGES/django.po (89%) rename backend/project/{events => observations}/locale/fr/LC_MESSAGES/django.po (73%) rename backend/project/{events => observations}/migrations/0001_initial.py (85%) create mode 100644 backend/project/observations/migrations/__init__.py rename backend/project/{events => observations}/models.py (61%) create mode 100644 backend/project/observations/tests.py diff --git a/backend/dev-requirements.txt b/backend/dev-requirements.txt index 79efdcf..ab7942d 100644 --- a/backend/dev-requirements.txt +++ b/backend/dev-requirements.txt @@ -26,7 +26,7 @@ click==8.1.7 # via # black # pip-tools -coverage==7.5.3 +coverage==7.5.4 # via -r dev-requirements.in curtsies==0.4.2 # via bpython @@ -42,9 +42,9 @@ django-debug-toolbar==4.4.2 # via -r dev-requirements.in factory-boy==3.3.0 # via -r dev-requirements.in -faker==25.8.0 +faker==26.0.0 # via factory-boy -flake8==7.0.0 +flake8==7.1.0 # via -r dev-requirements.in greenlet==3.0.3 # via bpython @@ -67,7 +67,7 @@ pip-tools==7.4.1 # via -r dev-requirements.in platformdirs==4.2.2 # via black -pycodestyle==2.11.1 +pycodestyle==2.12.0 # via flake8 pyflakes==3.2.0 # via flake8 @@ -94,7 +94,7 @@ sqlparse==0.5.0 # django-debug-toolbar tblib==3.0.0 # via -r dev-requirements.in -urllib3==2.2.1 +urllib3==2.2.2 # via # -c requirements.txt # requests diff --git a/backend/project/accounts/admin.py b/backend/project/accounts/admin.py index 609d652..6625799 100644 --- a/backend/project/accounts/admin.py +++ b/backend/project/accounts/admin.py @@ -14,11 +14,14 @@ class UserAdmin(BaseUserAdmin): "first_name", "last_name", "is_active", - "is_staff", "is_superuser", "date_joined", "last_login", ) + list_filter = ( + "is_superuser", + "is_active", + ) search_fields = ("email", "first_name", "last_name") ordering = ("email",) fieldsets = ( @@ -37,7 +40,6 @@ class UserAdmin(BaseUserAdmin): { "fields": ( "is_active", - "is_staff", "is_superuser", "groups", "user_permissions", diff --git a/backend/project/accounts/managers.py b/backend/project/accounts/managers.py index 4065b93..95cf4da 100644 --- a/backend/project/accounts/managers.py +++ b/backend/project/accounts/managers.py @@ -12,7 +12,6 @@ def create_user(self, email, password=None, **extra_fields): return user def create_superuser(self, email, password=None, **extra_fields): - extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) return self.create_user(email, password, **extra_fields) diff --git a/backend/project/accounts/migrations/0001_initial.py b/backend/project/accounts/migrations/0001_initial.py index b1c1031..a0ca2f3 100644 --- a/backend/project/accounts/migrations/0001_initial.py +++ b/backend/project/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.6 on 2024-06-12 20:15 +# Generated by Django 5.0.6 on 2024-06-28 13:23 import django.db.models.functions.datetime import django.utils.timezone @@ -72,14 +72,6 @@ class Migration(migrations.Migration): verbose_name="date joined", ), ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", - ), - ), ( "is_active", models.BooleanField( diff --git a/backend/project/accounts/models.py b/backend/project/accounts/models.py index 57248e3..7a1642f 100644 --- a/backend/project/accounts/models.py +++ b/backend/project/accounts/models.py @@ -19,11 +19,6 @@ class User(AbstractBaseUser, PermissionsMixin): date_joined = models.DateTimeField( _("date joined"), default=timezone.now, db_default=Now() ) - is_staff = models.BooleanField( - _("staff status"), - default=False, - help_text=_("Designates whether the user can log into this admin site."), - ) is_active = models.BooleanField( _("active"), default=True, @@ -37,6 +32,10 @@ class User(AbstractBaseUser, PermissionsMixin): EMAIL_FIELD = "email" USERNAME_FIELD = "email" + @property + def is_staff(self): + return self.is_superuser + class Meta: verbose_name = _("user") verbose_name_plural = _("users") diff --git a/backend/project/events/__init__.py b/backend/project/accounts/tests/__init__.py similarity index 100% rename from backend/project/events/__init__.py rename to backend/project/accounts/tests/__init__.py diff --git a/backend/project/accounts/tests/factories.py b/backend/project/accounts/tests/factories.py new file mode 100644 index 0000000..df26eeb --- /dev/null +++ b/backend/project/accounts/tests/factories.py @@ -0,0 +1,17 @@ +import factory +from factory import faker + +from ..models import User + + +class UserFactory(factory.django.DjangoModelFactory): + email = faker.Faker("email") + password = factory.PostGenerationMethodCall("set_password", "password") + is_active = True + + class Meta: + model = User + + +class SuperUserFactory(UserFactory): + is_superuser = True diff --git a/backend/project/accounts/tests/test_admin.py b/backend/project/accounts/tests/test_admin.py new file mode 100644 index 0000000..5807716 --- /dev/null +++ b/backend/project/accounts/tests/test_admin.py @@ -0,0 +1,16 @@ +from django.test import TestCase + +from .factories import SuperUserFactory, UserFactory + + +class AccountAdminTestCase(TestCase): + def test_superuser_ca(self): + """Test that only superuser can log in to the admin site.""" + simple_user = UserFactory() + self.client.force_login(simple_user) + response = self.client.get("/admin/") + self.assertEqual(response.status_code, 302) + super_user = SuperUserFactory() + self.client.force_login(super_user) + response = self.client.get("/admin/") + self.assertEqual(response.status_code, 200) diff --git a/backend/project/accounts/tests/test_models.py b/backend/project/accounts/tests/test_models.py new file mode 100644 index 0000000..96a0ffe --- /dev/null +++ b/backend/project/accounts/tests/test_models.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from .factories import SuperUserFactory, UserFactory + + +class AccountTestCase(TestCase): + def test_is_staff(self): + """Test that is_staff is same value as is_superuser""" + user = UserFactory() + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + super_user = SuperUserFactory() + self.assertTrue(super_user.is_staff) + self.assertTrue(super_user.is_superuser) diff --git a/backend/project/api/__init__.py b/backend/project/api/__init__.py index d497b8c..e69de29 100644 --- a/backend/project/api/__init__.py +++ b/backend/project/api/__init__.py @@ -1,71 +0,0 @@ -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import status, viewsets -from rest_framework.decorators import action -from rest_framework.generics import GenericAPIView -from rest_framework.mixins import CreateModelMixin -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.viewsets import GenericViewSet - -from project.accounts.models import User -from project.api.filters import EventFilterSet -from project.api.serializers import ( - AccountSerializer, - EventDetailSerializer, - EventListSerializer, - EventTypeSerializer, - SettingsSerializer, -) -from project.events.models import Event, EventType - - -class SettingsApiView(GenericAPIView): - serializer_class = SettingsSerializer - - def get(self, request): - data = {} - event_types = EventType.objects.prefetch_related("sub_types").all() - event_types_serialized = EventTypeSerializer( - event_types, many=True, context={"request": request} - ) - data["event_types"] = event_types_serialized.data - data["event_url"] = reverse("api:events-list", request=request) - return Response(data) - - -class EventViewSet(viewsets.ModelViewSet): - filter_backends = (DjangoFilterBackend,) - filterset_class = EventFilterSet - - def get_serializer_class(self): - if self.action == "list": - return EventListSerializer - return EventDetailSerializer - - def get_queryset(self): - qs = Event.objects.all().select_related("source", "event_subtype__event_type") - if self.action not in ["list", "retrieve"] or self.request.user.is_anonymous: - # list and retrieve are public - if self.request.user.is_anonymous: - # anonymous can't change anything - qs = qs.none() - elif not self.request.user.is_staff and not self.request.user.is_superuser: - # nor anonymous, nor admin - qs = self.request.user.events.all() - return qs - - def perform_create(self, serializer): - observer = self.request.user if self.request.user.is_authenticated else None - serializer.save(observer=observer) - - -class AccountViewSet(CreateModelMixin, GenericViewSet): - queryset = User.objects.all() - serializer_class = AccountSerializer - - @action(detail=False, methods=["get"]) - def mine(self, request, *args, **kwargs): - if self.request.user.is_anonymous: - return Response({}, status=status.HTTP_404_NOT_FOUND) - serializer = self.get_serializer(request.user) - return Response(serializer.data) diff --git a/backend/project/api/apps.py b/backend/project/api/apps.py new file mode 100644 index 0000000..cd41906 --- /dev/null +++ b/backend/project/api/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "project.api" + verbose_name = _("API") diff --git a/backend/project/api/filters.py b/backend/project/api/filters.py index fb4c538..4c2fb13 100644 --- a/backend/project/api/filters.py +++ b/backend/project/api/filters.py @@ -1,12 +1,18 @@ from django_filters.fields import DateRangeField -from django_filters.rest_framework import FilterSet +from django_filters.rest_framework import FilterSet, filters -from project.events.models import Event +from project.observations.models import Observation -class EventFilterSet(FilterSet): +class ObservationFilterSet(FilterSet): event_date = DateRangeField() + fields = filters.CharFilter( + method="filter_fields", help_text="filter fields you want to get" + ) + + def filter_fields(self, qs): + return qs class Meta: - model = Event - fields = ["event_date", "event_subtype"] + model = Observation + fields = ["event_date", "observation_subtype", "fields"] diff --git a/backend/project/api/serializers.py b/backend/project/api/serializers.py deleted file mode 100644 index 9594045..0000000 --- a/backend/project/api/serializers.py +++ /dev/null @@ -1,120 +0,0 @@ -from django.contrib.auth.password_validation import validate_password -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field -from rest_framework import serializers -from rest_framework_gis import serializers as gis_serializers -from sorl.thumbnail import get_thumbnail - -from project.accounts.models import User -from project.events.models import Event, EventSubType, EventType, Media - - -class EventSubTypeSerializer(serializers.ModelSerializer): - class Meta: - model = EventSubType - fields = ( - "id", - "label", - "description", - "pictogram", - ) - - -class EventTypeSerializer(serializers.ModelSerializer): - sub_types = EventSubTypeSerializer(many=True) - - class Meta: - model = EventType - fields = ("id", "label", "description", "pictogram", "sub_types") - - -class SettingsSerializer(serializers.Serializer): - event_types = EventTypeSerializer(many=True, read_only=True) - event_url = serializers.HyperlinkedIdentityField(view_name="api:events-list") - - -class ThumbnailSerializer(serializers.Serializer): - small = serializers.SerializerMethodField() - medium = serializers.SerializerMethodField() - large = serializers.SerializerMethodField() - - def get_thumbnail_by_size(self, obj, height=100, width=100, format="PNG"): - if obj.media_type == Media.MediaType.IMAGE: - return self.context["request"].build_absolute_uri( - get_thumbnail(obj.media_file, f"{height}x{width}", format="PNG").url - ) - return None - - @extend_schema_field(OpenApiTypes.URI) - def get_small(self, obj): - return self.get_thumbnail_by_size(obj, 100, 100) - - @extend_schema_field(OpenApiTypes.URI) - def get_medium(self, obj): - return self.get_thumbnail_by_size(obj, 300, 300) - - @extend_schema_field(OpenApiTypes.URI) - def get_large(self, obj): - return self.get_thumbnail_by_size(obj, 600, 600) - - -class MediaSerializer(serializers.ModelSerializer): - thumbnails = ThumbnailSerializer(source="*") - - class Meta: - model = Media - fields = ("id", "uuid", "legend", "media_file", "media_type", "thumbnails") - - -class EventListSerializer(gis_serializers.GeoFeatureModelSerializer): - source = serializers.SlugRelatedField("label", read_only=True) - observer = serializers.SlugRelatedField("email", read_only=True) - subtype = serializers.SlugRelatedField("label", read_only=True) - # event_type = serializers.SlugRelatedField("event_subtype.event_type.label", read_only=True) - - class Meta: - model = Event - geo_field = "location" - fields = ( - "id", - "uuid", - "observer", - "comments", - "event_date", - "source", - "subtype", - "event_subtype", - "location", - ) - write_only_fields = ("event_subtype__id",) - - -class EventDetailSerializer(EventListSerializer): - medias = MediaSerializer(many=True, read_only=True) - - class Meta(EventListSerializer.Meta): - fields = EventListSerializer.Meta.fields + ("medias",) - - -class AccountSerializer(serializers.ModelSerializer): - password = serializers.CharField(write_only=True, validators=[validate_password]) - - def save(self, **kwargs): - password = self.validated_data.pop["password"] - super().save(**kwargs) - if password: - self.instance.set_password(password) - self.instance.save() - return self.instance - - class Meta: - model = User - fields = ( - "id", - "email", - "is_staff", - "is_superuser", - "last_name", - "first_name", - "password", - ) diff --git a/backend/project/events/migrations/__init__.py b/backend/project/api/serializers/__init__.py similarity index 100% rename from backend/project/events/migrations/__init__.py rename to backend/project/api/serializers/__init__.py diff --git a/backend/project/api/serializers/accounts.py b/backend/project/api/serializers/accounts.py new file mode 100644 index 0000000..796cecc --- /dev/null +++ b/backend/project/api/serializers/accounts.py @@ -0,0 +1,28 @@ +from django.contrib.auth.password_validation import validate_password +from rest_framework import serializers + +from project.accounts.models import User + + +class AccountSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, validators=[validate_password]) + + def save(self, **kwargs): + super().save() + if "password" in self.validated_data: + self.instance.set_password(self.validated_data["password"]) + self.instance.save() + + return self.instance + + class Meta: + model = User + fields = ( + "id", + "email", + "is_superuser", + "last_name", + "first_name", + "password", + ) + read_only_fields = ("is_superuser",) diff --git a/backend/project/api/serializers/common.py b/backend/project/api/serializers/common.py new file mode 100644 index 0000000..58179a0 --- /dev/null +++ b/backend/project/api/serializers/common.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from project.api.serializers.observations import ObservationTypeSerializer + + +class SettingsSerializer(serializers.Serializer): + observation_types = ObservationTypeSerializer(many=True, read_only=True) + observation_public_url = serializers.HyperlinkedIdentityField( + view_name="api:observations-list" + ) diff --git a/backend/project/api/serializers/observations.py b/backend/project/api/serializers/observations.py new file mode 100644 index 0000000..38f0925 --- /dev/null +++ b/backend/project/api/serializers/observations.py @@ -0,0 +1,99 @@ +from drf_dynamic_fields import DynamicFieldsMixin +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers +from rest_framework_gis import serializers as gis_serializers +from sorl.thumbnail import get_thumbnail + +from project.observations.models import ( + Media, + Observation, + ObservationSubType, + ObservationType, +) + + +class ObservationSubTypeSerializer(serializers.ModelSerializer): + class Meta: + model = ObservationSubType + fields = ( + "id", + "label", + "description", + "pictogram", + ) + + +class ObservationTypeSerializer(serializers.ModelSerializer): + sub_types = ObservationSubTypeSerializer(many=True) + + class Meta: + model = ObservationType + fields = ("id", "label", "description", "pictogram", "sub_types") + + +class ThumbnailSerializer(serializers.Serializer): + small = serializers.SerializerMethodField() + medium = serializers.SerializerMethodField() + large = serializers.SerializerMethodField() + + def get_thumbnail_by_size( + self, obj, height=100, width=100, format="JPG", quality=70 + ): + if obj.media_type == Media.MediaType.IMAGE: + return self.context["request"].build_absolute_uri( + get_thumbnail( + obj.media_file, f"{height}x{width}", format=format, quality=quality + ).url + ) + return None + + @extend_schema_field(OpenApiTypes.URI) + def get_small(self, obj): + return self.get_thumbnail_by_size(obj, 449, 599) + + @extend_schema_field(OpenApiTypes.URI) + def get_medium(self, obj): + return self.get_thumbnail_by_size(obj, 720, 1280) + + @extend_schema_field(OpenApiTypes.URI) + def get_large(self, obj): + return self.get_thumbnail_by_size(obj, 1080, 1920) + + +class MediaSerializer(serializers.ModelSerializer): + thumbnails = ThumbnailSerializer(source="*") + + class Meta: + model = Media + fields = ("id", "uuid", "legend", "media_file", "media_type", "thumbnails") + + +class ObservationListSerializer( + DynamicFieldsMixin, gis_serializers.GeoFeatureModelSerializer +): + source = serializers.SlugRelatedField("label", read_only=True) + subtype = serializers.SlugRelatedField("label", read_only=True) + # event_type = serializers.SlugRelatedField("event_subtype.event_type.label", read_only=True) + + class Meta: + model = Observation + geo_field = "location" + fields = ( + "id", + "uuid", + "comments", + "event_date", + "source", + "subtype", + "observation_subtype", + "location", + ) + write_only_fields = ("observation_subtype__id",) + + +class ObservationDetailSerializer(ObservationListSerializer): + medias = MediaSerializer(many=True, read_only=True) + + class Meta(ObservationListSerializer.Meta): + fields = ObservationListSerializer.Meta.fields + ("medias",) diff --git a/backend/project/accounts/tests.py b/backend/project/api/tests/__init__.py similarity index 100% rename from backend/project/accounts/tests.py rename to backend/project/api/tests/__init__.py diff --git a/backend/project/api/tests/test_serializers.py b/backend/project/api/tests/test_serializers.py new file mode 100644 index 0000000..883c348 --- /dev/null +++ b/backend/project/api/tests/test_serializers.py @@ -0,0 +1,21 @@ +from django.test import TestCase + +from project.accounts.tests.factories import UserFactory +from project.api.serializers.accounts import AccountSerializer + + +class AccountSerializerTestCase(TestCase): + def test_password_check_success_after_change(self): + """Test password change method""" + user = UserFactory(password="password") + self.assertTrue(user.check_password("password")) + + serializer = AccountSerializer( + instance=user, data={"email": user.email, "password": "new_password"} + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + + serializer.save() + + user.refresh_from_db() + self.assertTrue(user.check_password("new_password")) diff --git a/backend/project/api/urls.py b/backend/project/api/urls.py index c32ebe6..a392507 100644 --- a/backend/project/api/urls.py +++ b/backend/project/api/urls.py @@ -2,15 +2,25 @@ from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) -from project import api +from project.api import views as api +from project.api.views import AccountViewSet app_name = "api" router = DefaultRouter() -router.register(r"events", api.EventViewSet, basename="events") -router.register(r"accounts", api.AccountViewSet, basename="accounts") +router.register(r"observations", api.ObservationViewSet, basename="observations") +router.register( + r"accounts/me/observations", + api.AccountObservationViewset, + basename="account-observations", +) urlpatterns = [ path( @@ -27,5 +37,29 @@ name="swagger-ui", ), path("api/settings/", api.SettingsApiView.as_view(), name="settings"), + path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), + path( + "api/accounts/me/", + AccountViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="me", + ), + path( + "api/accounts/sign-up/", + AccountViewSet.as_view( + { + "post": "signup", + } + ), + name="me", + ), path("api/", include(router.urls)), ] diff --git a/backend/project/api/views.py b/backend/project/api/views.py new file mode 100644 index 0000000..a15e6de --- /dev/null +++ b/backend/project/api/views.py @@ -0,0 +1,90 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.generics import GenericAPIView +from rest_framework.mixins import ( + DestroyModelMixin, + RetrieveModelMixin, + UpdateModelMixin, +) +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.viewsets import GenericViewSet + +from project.accounts.models import User +from project.api.filters import ObservationFilterSet +from project.api.serializers.accounts import AccountSerializer +from project.api.serializers.common import SettingsSerializer +from project.api.serializers.observations import ( + ObservationDetailSerializer, + ObservationListSerializer, + ObservationTypeSerializer, +) +from project.observations.models import Observation, ObservationType + + +class SettingsApiView(GenericAPIView): + serializer_class = SettingsSerializer + + def get(self, request): + data = {} + observation_types = ObservationType.objects.prefetch_related("sub_types").all() + observation_types_serialized = ObservationTypeSerializer( + observation_types, many=True, context={"request": request} + ) + data["observation_types"] = observation_types_serialized.data + data["observation_public_url"] = reverse( + "api:observations-list", request=request + ) + return Response(data) + + +class ObservationViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Observation.objects.all().select_related( + "source", "observation_subtype__observation_type" + ) + filter_backends = (DjangoFilterBackend,) + filterset_class = ObservationFilterSet + + def get_serializer_class(self): + if self.action == "list": + return ObservationListSerializer + return ObservationDetailSerializer + + +class AccountViewSet( + UpdateModelMixin, + RetrieveModelMixin, + DestroyModelMixin, + GenericViewSet, +): + queryset = User.objects.all() + serializer_class = AccountSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + return self.request.user + + @action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny]) + def signup(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class AccountObservationViewset(viewsets.ModelViewSet): + filter_backends = (DjangoFilterBackend,) + filterset_class = ObservationFilterSet + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return self.request.user.observations.all() + + def get_serializer_class(self): + if self.action == "list": + return ObservationListSerializer + return ObservationDetailSerializer + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) diff --git a/backend/project/events/tests.py b/backend/project/observations/__init__.py similarity index 100% rename from backend/project/events/tests.py rename to backend/project/observations/__init__.py diff --git a/backend/project/events/admin.py b/backend/project/observations/admin.py similarity index 77% rename from backend/project/events/admin.py rename to backend/project/observations/admin.py index 50938f7..3014701 100644 --- a/backend/project/events/admin.py +++ b/backend/project/observations/admin.py @@ -3,11 +3,17 @@ from django.utils.safestring import mark_safe from sorl.thumbnail import get_thumbnail -from project.events.models import Event, EventSubType, EventType, Media, Source +from project.observations.models import ( + Media, + Observation, + ObservationSubType, + ObservationType, + Source, +) class SubTypeInline(admin.TabularInline): - model = EventSubType + model = ObservationSubType extra = 0 fields = ("label", "description", "pictogram", "picto_preview") readonly_fields = ("picto_preview",) @@ -52,8 +58,8 @@ def media_preview(self, obj): return "-" -@admin.register(EventType) -class EventTypeAdmin(admin.ModelAdmin): +@admin.register(ObservationType) +class ObservationTypeAdmin(admin.ModelAdmin): list_display = ("label", "description", "picto_preview") search_fields = ("label", "description") ordering = ("label",) @@ -68,11 +74,11 @@ def picto_preview(self, obj): ) -@admin.register(EventSubType) -class EventSubTypeAdmin(admin.ModelAdmin): - list_display = ("label", "event_type", "description", "picto_preview") +@admin.register(ObservationSubType) +class ObservationSubTypeAdmin(admin.ModelAdmin): + list_display = ("label", "observation_type", "description", "picto_preview") search_fields = ("label", "description") - list_filter = ("event_type",) + list_filter = ("observation_type",) ordering = ("label",) def picto_preview(self, obj): @@ -84,13 +90,13 @@ def picto_preview(self, obj): ) def get_queryset(self, request): - return super().get_queryset(request).select_related("event_type") + return super().get_queryset(request).select_related("observation_type") -@admin.register(Event) -class EventAdmin(GISModelAdmin): - list_display = ("event_subtype", "event_date", "observer") - list_filter = ("event_subtype", "event_date") +@admin.register(Observation) +class ObservationAdmin(GISModelAdmin): + list_display = ("observation_subtype", "event_date", "observer") + list_filter = ("observation_subtype", "event_date") ordering = ("-event_date",) date_hierarchy = "event_date" readonly_fields = ("uuid",) @@ -100,7 +106,9 @@ def get_queryset(self, request): return ( super() .get_queryset(request) - .select_related("event_subtype__event_type", "source", "observer") + .select_related( + "observation_subtype__observation_type", "source", "observer" + ) ) diff --git a/backend/project/events/apps.py b/backend/project/observations/apps.py similarity index 56% rename from backend/project/events/apps.py rename to backend/project/observations/apps.py index a23f80b..184bbe3 100644 --- a/backend/project/events/apps.py +++ b/backend/project/observations/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class CoreConfig(AppConfig): +class ObservationsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "project.events" + name = "project.observations" diff --git a/backend/project/events/locale/en/LC_MESSAGES/django.po b/backend/project/observations/locale/en/LC_MESSAGES/django.po similarity index 89% rename from backend/project/events/locale/en/LC_MESSAGES/django.po rename to backend/project/observations/locale/en/LC_MESSAGES/django.po index 33f35d6..ab03f4c 100644 --- a/backend/project/events/locale/en/LC_MESSAGES/django.po +++ b/backend/project/observations/locale/en/LC_MESSAGES/django.po @@ -26,19 +26,19 @@ msgid "Sources" msgstr "" #: project/events/models.py:30 -msgid "Event type" +msgid "Observation type" msgstr "" #: project/events/models.py:31 -msgid "Event types" +msgid "Observation types" msgstr "" #: project/events/models.py:47 -msgid "Event sub-type" +msgid "Observation sub-type" msgstr "" #: project/events/models.py:48 -msgid "Event sub-types" +msgid "Observation sub-types" msgstr "" #: project/events/models.py:60 @@ -46,11 +46,11 @@ msgid "Location" msgstr "" #: project/events/models.py:66 project/events/models.py:83 -msgid "Event" +msgid "Observation" msgstr "" #: project/events/models.py:67 -msgid "Events" +msgid "Observations" msgstr "" #: project/events/models.py:73 diff --git a/backend/project/events/locale/fr/LC_MESSAGES/django.po b/backend/project/observations/locale/fr/LC_MESSAGES/django.po similarity index 73% rename from backend/project/events/locale/fr/LC_MESSAGES/django.po rename to backend/project/observations/locale/fr/LC_MESSAGES/django.po index 8920bcf..a0cc7ea 100644 --- a/backend/project/events/locale/fr/LC_MESSAGES/django.po +++ b/backend/project/observations/locale/fr/LC_MESSAGES/django.po @@ -24,26 +24,26 @@ msgstr "Source" msgid "Sources" msgstr "Sources" -msgid "Event type" -msgstr "Type d'évènement" +msgid "Observation type" +msgstr "Type d'observation" -msgid "Event types" -msgstr "Types d'évènement" +msgid "Observation types" +msgstr "Types d'observation" -msgid "Event sub-type" -msgstr "Sous-type d'évènement" +msgid "Observation sub-type" +msgstr "Sous-type d'observation" -msgid "Event sub-types" -msgstr "Sous-types d'évènement" +msgid "Observation sub-types" +msgstr "Sous-types d'observation" msgid "Location" msgstr "Emplacement" -msgid "Event" -msgstr "Évènement" +msgid "Observation" +msgstr "Observation" -msgid "Events" -msgstr "Évènements" +msgid "Observations" +msgstr "Observations" msgid "Image" msgstr "Image" diff --git a/backend/project/events/migrations/0001_initial.py b/backend/project/observations/migrations/0001_initial.py similarity index 85% rename from backend/project/events/migrations/0001_initial.py rename to backend/project/observations/migrations/0001_initial.py index a9247b3..53445e5 100644 --- a/backend/project/events/migrations/0001_initial.py +++ b/backend/project/observations/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.6 on 2024-06-12 20:15 +# Generated by Django 5.0.6 on 2024-06-28 13:23 import uuid @@ -20,7 +20,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="EventSubType", + name="ObservationSubType", fields=[ ( "id", @@ -52,18 +52,18 @@ class Migration(migrations.Migration): ( "pictogram", models.ImageField( - upload_to="event_types", verbose_name="Pictogram" + upload_to="observation_types", verbose_name="Pictogram" ), ), ], options={ - "verbose_name": "Event sub-type", - "verbose_name_plural": "Event sub-types", - "ordering": ["event_type__label", "label"], + "verbose_name": "Observation sub-type", + "verbose_name_plural": "Observation sub-types", + "ordering": ["observation_type__label", "label"], }, ), migrations.CreateModel( - name="EventType", + name="ObservationType", fields=[ ( "id", @@ -95,13 +95,13 @@ class Migration(migrations.Migration): ( "pictogram", models.ImageField( - upload_to="event_types", verbose_name="Pictogram" + upload_to="observation_types", verbose_name="Pictogram" ), ), ], options={ - "verbose_name": "Event type", - "verbose_name_plural": "Event types", + "verbose_name": "Observation type", + "verbose_name_plural": "Observation types", "ordering": ["label"], }, ), @@ -143,7 +143,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="Event", + name="Observation", fields=[ ( "id", @@ -197,34 +197,28 @@ class Migration(migrations.Migration): ), ), ( - "event_subtype", + "observation_subtype", models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, - to="events.eventsubtype", + to="observations.observationsubtype", ), ), ( "source", models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="events.source" + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="observations.source", ), ), ], options={ - "verbose_name": "Event", - "verbose_name_plural": "Events", + "verbose_name": "Observation", + "verbose_name_plural": "Observations", "ordering": ["-created_at"], }, ), - migrations.AddField( - model_name="eventsubtype", - name="event_type", - field=models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="sub_types", - to="events.eventtype", - ), - ), migrations.CreateModel( name="Media", fields=[ @@ -254,7 +248,10 @@ class Migration(migrations.Migration): ), ), ("media_file", models.FileField(upload_to="media")), - ("uuid", models.UUIDField(unique=True)), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), ( "media_type", models.CharField( @@ -266,12 +263,12 @@ class Migration(migrations.Migration): ), ("legend", models.CharField(blank=True, default="", max_length=100)), ( - "event", + "observation", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="medias", - to="events.event", - verbose_name="Event", + to="observations.observation", + verbose_name="Observation", ), ), ], @@ -281,4 +278,13 @@ class Migration(migrations.Migration): "ordering": ["created_at"], }, ), + migrations.AddField( + model_name="observationsubtype", + name="observation_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="sub_types", + to="observations.observationtype", + ), + ), ] diff --git a/backend/project/observations/migrations/__init__.py b/backend/project/observations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/project/events/models.py b/backend/project/observations/models.py similarity index 61% rename from backend/project/events/models.py rename to backend/project/observations/models.py index 55487b3..6f2ed7d 100644 --- a/backend/project/events/models.py +++ b/backend/project/observations/models.py @@ -20,54 +20,60 @@ class Meta: ordering = ["label"] -class EventType(TimeStampMixin): +class ObservationType(TimeStampMixin): label = models.CharField(max_length=100, unique=True) description = models.TextField(blank=True) - pictogram = models.ImageField(upload_to="event_types", verbose_name=_("Pictogram")) + pictogram = models.ImageField( + upload_to="observation_types", verbose_name=_("Pictogram") + ) def __str__(self): return self.label class Meta: - verbose_name = _("Event type") - verbose_name_plural = _("Event types") + verbose_name = _("Observation type") + verbose_name_plural = _("Observation types") ordering = ["label"] -class EventSubType(TimeStampMixin): +class ObservationSubType(TimeStampMixin): label = models.CharField(max_length=100, unique=True) description = models.TextField(blank=True) - event_type = models.ForeignKey( - EventType, on_delete=models.PROTECT, related_name="sub_types" + observation_type = models.ForeignKey( + ObservationType, on_delete=models.PROTECT, related_name="sub_types" + ) + pictogram = models.ImageField( + upload_to="observation_types", verbose_name=_("Pictogram") ) - pictogram = models.ImageField(upload_to="event_types", verbose_name=_("Pictogram")) def __str__(self): - return f"{self.label} ({self.event_type})" + return f"{self.label} ({self.observation_type})" class Meta: - verbose_name = _("Event sub-type") - verbose_name_plural = _("Event sub-types") - ordering = ["event_type__label", "label"] + verbose_name = _("Observation sub-type") + verbose_name_plural = _("Observation sub-types") + ordering = ["observation_type__label", "label"] -class Event(TimeStampMixin): +class Observation(TimeStampMixin): uuid = models.UUIDField(unique=True, default=uuid4, editable=False) observer = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True ) comments = models.TextField() event_date = models.DateField(default=timezone_today, db_index=True) - source = models.ForeignKey(Source, on_delete=models.PROTECT) - event_subtype = models.ForeignKey(EventSubType, on_delete=models.PROTECT) + source = models.ForeignKey(Source, on_delete=models.SET_NULL, blank=True, null=True) + observation_subtype = models.ForeignKey( + ObservationSubType, on_delete=models.PROTECT + ) location = models.PointField(srid=4326, verbose_name=_("Location")) def __str__(self): return str(self.uuid) class Meta: - verbose_name = _("Event") - verbose_name_plural = _("Events") + verbose_name = _("Observation") + verbose_name_plural = _("Observations") ordering = ["-created_at"] @@ -82,8 +88,11 @@ class MediaType(models.TextChoices): max_length=10, choices=MediaType.choices, default=MediaType.IMAGE, db_index=True ) legend = models.CharField(max_length=100, blank=True, default="") - event = models.ForeignKey( - Event, on_delete=models.CASCADE, related_name="medias", verbose_name=_("Event") + observation = models.ForeignKey( + Observation, + on_delete=models.CASCADE, + related_name="medias", + verbose_name=_("Observation"), ) class Meta: diff --git a/backend/project/observations/tests.py b/backend/project/observations/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/project/settings/__init__.py b/backend/project/settings/__init__.py index 44018cb..4df8b82 100644 --- a/backend/project/settings/__init__.py +++ b/backend/project/settings/__init__.py @@ -53,13 +53,15 @@ "django.contrib.staticfiles", "django.contrib.postgres", "django.contrib.gis", + "rest_framework_simplejwt", "rest_framework", "rest_framework_gis", "drf_spectacular", "django_filters", "sorl.thumbnail", + "project.api", + "project.observations", "project.accounts", - "project.events", ] MIDDLEWARE = [ @@ -170,6 +172,10 @@ "rest_framework.throttling.UserRateThrottle", ], "DEFAULT_THROTTLE_RATES": {"anon": "100/day", "user": "1000/day"}, + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.BasicAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), } API_SWAGGER_SETTINGS = { @@ -195,3 +201,9 @@ "LOCATION": "memcached:11211", } } + +SIMPLE_JWT = { + "UPDATE_LAST_LOGIN": True, +} + +DEBUG_TOOLBAR_CONFIG = {"IS_RUNNING_TESTS": False} diff --git a/backend/project/settings/dev.py b/backend/project/settings/dev.py index b37130b..c474359 100644 --- a/backend/project/settings/dev.py +++ b/backend/project/settings/dev.py @@ -13,4 +13,5 @@ DEBUG_TOOLBAR_CONFIG = { "SHOW_TOOLBAR_CALLBACK": lambda request: True, + "IS_RUNNING_TESTS": False, } diff --git a/backend/requirements.in b/backend/requirements.in index 852255b..a8109d1 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -8,5 +8,6 @@ pillow sorl-thumbnail pymemcache sentry-sdk -pmtiles -gunicorn \ No newline at end of file +gunicorn +drf-dynamic-fields +djangorestframework-simplejwt \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 2e03843..de0d53e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,16 +17,22 @@ django==5.0.6 # -r requirements.in # django-filter # djangorestframework + # djangorestframework-simplejwt # drf-spectacular django-filter==24.2 # via -r requirements.in -djangorestframework==3.15.1 +djangorestframework==3.15.2 # via # -r requirements.in # djangorestframework-gis + # djangorestframework-simplejwt # drf-spectacular djangorestframework-gis==1.0 # via -r requirements.in +djangorestframework-simplejwt==5.3.1 + # via -r requirements.in +drf-dynamic-fields==0.4.0 + # via -r requirements.in drf-spectacular==0.27.2 # via -r requirements.in gunicorn==22.0.0 @@ -41,10 +47,10 @@ packaging==24.1 # via gunicorn pillow==10.3.0 # via -r requirements.in -pmtiles==3.3.0 - # via -r requirements.in psycopg==3.1.19 # via -r requirements.in +pyjwt==2.8.0 + # via djangorestframework-simplejwt pymemcache==4.0.0 # via -r requirements.in pyyaml==6.0.1 @@ -57,7 +63,7 @@ rpds-py==0.18.1 # via # jsonschema # referencing -sentry-sdk==2.5.1 +sentry-sdk==2.7.0 # via -r requirements.in sorl-thumbnail==12.10.0 # via -r requirements.in @@ -67,5 +73,5 @@ typing-extensions==4.12.2 # via psycopg uritemplate==4.1.1 # via drf-spectacular -urllib3==2.2.1 +urllib3==2.2.2 # via sentry-sdk