From 97aba9bd32f47ae7ae9210c5531ae3bf45d8438a Mon Sep 17 00:00:00 2001 From: Floris272 Date: Wed, 25 Sep 2024 12:23:14 +0200 Subject: [PATCH 1/3] Refactor price to need at least one option --- src/open_producten/producttypes/admin/price.py | 17 +++++++++++++++++ .../producttypes/serializers/children.py | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/src/open_producten/producttypes/admin/price.py b/src/open_producten/producttypes/admin/price.py index 113cdde..b31493e 100644 --- a/src/open_producten/producttypes/admin/price.py +++ b/src/open_producten/producttypes/admin/price.py @@ -1,12 +1,29 @@ from django.contrib import admin +from django.core.exceptions import ValidationError +from django.forms import BaseInlineFormSet from ..models import Price, PriceOption +class PriceOptionInlineFormSet(BaseInlineFormSet): + + def clean(self): + """Check that at least one option has been added.""" + super().clean() + if any(self.errors): + return + if not any( + cleaned_data and not cleaned_data.get("DELETE", False) + for cleaned_data in self.cleaned_data + ): + raise ValidationError("At least one option required.") + + class PriceOptionInline(admin.TabularInline): model = PriceOption extra = 1 ordering = ("description",) + formset = PriceOptionInlineFormSet @admin.register(Price) diff --git a/src/open_producten/producttypes/serializers/children.py b/src/open_producten/producttypes/serializers/children.py index e657918..fd490c1 100644 --- a/src/open_producten/producttypes/serializers/children.py +++ b/src/open_producten/producttypes/serializers/children.py @@ -33,6 +33,11 @@ class Meta: model = Price exclude = ("product_type",) + def validate_options(self, options): + if len(options) == 0: + raise serializers.ValidationError("At least one option is required") + return options + @transaction.atomic() def create(self, validated_data): options = validated_data.pop("options") From e9b581d210c6d8de2536c309f623f151964be2cc Mon Sep 17 00:00:00 2001 From: Floris272 Date: Wed, 25 Sep 2024 12:23:29 +0200 Subject: [PATCH 2/3] add current-prices endpoint --- .../producttypes/serializers/producttype.py | 10 + .../tests/api/test_product_type_price.py | 103 ++++- src/open_producten/producttypes/views.py | 17 +- src/openapi.yaml | 390 ++++++++++++++++-- 4 files changed, 475 insertions(+), 45 deletions(-) diff --git a/src/open_producten/producttypes/serializers/producttype.py b/src/open_producten/producttypes/serializers/producttype.py index b192ac4..5a7c5d3 100644 --- a/src/open_producten/producttypes/serializers/producttype.py +++ b/src/open_producten/producttypes/serializers/producttype.py @@ -130,3 +130,13 @@ def update(self, instance, validated_data): instance.save() return instance + + +class ProductTypeCurrentPriceSerializer(serializers.ModelSerializer): + upl_uri = serializers.ReadOnlyField(source="uniform_product_name.url") + upl_name = serializers.ReadOnlyField(source="uniform_product_name.name") + current_price = PriceSerializer(allow_null=True) + + class Meta: + model = ProductType + fields = ("id", "name", "upl_name", "upl_uri", "current_price") diff --git a/src/open_producten/producttypes/tests/api/test_product_type_price.py b/src/open_producten/producttypes/tests/api/test_product_type_price.py index 717d35f..1891b04 100644 --- a/src/open_producten/producttypes/tests/api/test_product_type_price.py +++ b/src/open_producten/producttypes/tests/api/test_product_type_price.py @@ -44,13 +44,20 @@ def test_read_price_without_credentials_returns_error(self): response = APIClient().get(self.path) self.assertEqual(response.status_code, 401) - def test_create_price(self): + def test_create_price_without_options(self): response = self.post(self.price_data) - self.assertEqual(response.status_code, 201) - self.assertEqual(Price.objects.count(), 1) + self.assertEqual(response.status_code, 400) self.assertEqual( - self.product_type.prices.first().valid_from, self.price_data["valid_from"] + response.data, + { + "options": [ + ErrorDetail( + string="At least one option is required", + code="invalid", + ) + ] + }, ) def test_create_price_with_price_option(self): @@ -69,7 +76,7 @@ def test_create_price_with_price_option(self): Decimal("74.99"), ) - def test_update_price_removing_options(self): + def test_update_price_removing_all_options(self): price = self._create_price() PriceOptionFactory.create(price=price) PriceOptionFactory.create(price=price) @@ -81,9 +88,18 @@ def test_update_price_removing_options(self): response = self.put(price.id, data) - self.assertEqual(response.status_code, 200) - self.assertEqual(Price.objects.count(), 1) - self.assertEqual(PriceOption.objects.count(), 0) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "options": [ + ErrorDetail( + string="At least one option is required", + code="invalid", + ) + ] + }, + ) def test_update_price_updating_and_removing_options(self): price = self._create_price() @@ -327,3 +343,74 @@ def test_delete_price(self): self.assertEqual(response.status_code, 204) self.assertEqual(Price.objects.count(), 0) self.assertEqual(PriceOption.objects.count(), 0) + + def test_get_current_prices_when_product_type_has_no_prices(self): + response = self.client.get("/api/v1/producttypes/current_prices/") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + [ + { + "id": str(self.product_type.id), + "name": self.product_type.name, + "upl_name": self.product_type.uniform_product_name.name, + "upl_uri": self.product_type.uniform_product_name.url, + "current_price": None, + }, + ], + ) + + def test_get_current_prices_when_product_type_only_has_price_in_future(self): + PriceFactory.create( + product_type=self.product_type, valid_from=datetime.date(2024, 2, 2) + ) + + response = self.client.get("/api/v1/producttypes/current_prices/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + [ + { + "id": str(self.product_type.id), + "name": self.product_type.name, + "upl_name": self.product_type.uniform_product_name.name, + "upl_uri": self.product_type.uniform_product_name.url, + "current_price": None, + }, + ], + ) + + def test_get_current_prices_when_product_type_has_current_price(self): + price = PriceFactory.create( + product_type=self.product_type, + valid_from=datetime.date(2024, 1, 1), + ) + + option = PriceOptionFactory.create(price=price) + + response = self.client.get("/api/v1/producttypes/current_prices/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + [ + { + "id": str(self.product_type.id), + "name": self.product_type.name, + "upl_name": self.product_type.uniform_product_name.name, + "upl_uri": self.product_type.uniform_product_name.url, + "current_price": { + "id": str(price.id), + "valid_from": "2024-01-01", + "options": [ + { + "amount": str(option.amount), + "description": option.description, + "id": str(option.id), + } + ], + }, + }, + ], + ) diff --git a/src/open_producten/producttypes/views.py b/src/open_producten/producttypes/views.py index 70040ab..8a92ef2 100644 --- a/src/open_producten/producttypes/views.py +++ b/src/open_producten/producttypes/views.py @@ -1,5 +1,8 @@ from django.shortcuts import get_object_or_404 +from rest_framework.decorators import action +from rest_framework.response import Response + from open_producten.producttypes.models import ( Category, Condition, @@ -21,7 +24,10 @@ TagSerializer, TagTypeSerializer, ) -from open_producten.producttypes.serializers.producttype import ProductTypeSerializer +from open_producten.producttypes.serializers.producttype import ( + ProductTypeCurrentPriceSerializer, + ProductTypeSerializer, +) from open_producten.utils.views import OrderedModelViewSet @@ -30,6 +36,12 @@ class ProductTypeViewSet(OrderedModelViewSet): serializer_class = ProductTypeSerializer lookup_url_kwarg = "id" + @action(detail=False, serializer_class=ProductTypeCurrentPriceSerializer) + def current_prices(self, request): + product_types = ProductType.objects.all() + serializer = ProductTypeCurrentPriceSerializer(product_types, many=True) + return Response(serializer.data) + class ProductTypeChildViewSet(OrderedModelViewSet): @@ -107,6 +119,3 @@ class TagTypeViewSet(OrderedModelViewSet): queryset = TagType.objects.all() serializer_class = TagTypeSerializer lookup_field = "id" - - def get_queryset(self): - return self.queryset.order_by("id") diff --git a/src/openapi.yaml b/src/openapi.yaml index 2cf10ec..35b118d 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -9,6 +9,13 @@ paths: /api/v1/categories/: get: operationId: categories_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer tags: - categories security: @@ -18,9 +25,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Category' + $ref: '#/components/schemas/PaginatedCategoryList' description: '' post: operationId: categories_create @@ -51,6 +56,12 @@ paths: type: string format: uuid required: true + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer tags: - categories security: @@ -60,9 +71,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Question' + $ref: '#/components/schemas/PaginatedQuestionList' description: '' post: operationId: categories_questions_create @@ -301,6 +310,13 @@ paths: /api/v1/conditions/: get: operationId: conditions_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer tags: - conditions security: @@ -310,9 +326,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Condition' + $ref: '#/components/schemas/PaginatedConditionList' description: '' post: operationId: conditions_create @@ -428,6 +442,13 @@ paths: /api/v1/products/: get: operationId: products_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer tags: - products security: @@ -437,9 +458,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Product' + $ref: '#/components/schemas/PaginatedProductList' description: '' post: operationId: products_create @@ -555,6 +574,13 @@ paths: /api/v1/producttypes/: get: operationId: producttypes_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer tags: - producttypes security: @@ -564,9 +590,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/ProductType' + $ref: '#/components/schemas/PaginatedProductTypeList' description: '' post: operationId: producttypes_create @@ -683,6 +707,12 @@ paths: get: operationId: producttypes_fields_list parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer - in: path name: product_type_id schema: @@ -699,9 +729,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Field' + $ref: '#/components/schemas/PaginatedFieldList' description: '' post: operationId: producttypes_fields_create @@ -854,6 +882,12 @@ paths: get: operationId: producttypes_links_list parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer - in: path name: product_type_id schema: @@ -870,9 +904,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Link' + $ref: '#/components/schemas/PaginatedLinkList' description: '' post: operationId: producttypes_links_create @@ -1025,6 +1057,12 @@ paths: get: operationId: producttypes_prices_list parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer - in: path name: product_type_id schema: @@ -1041,9 +1079,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Price' + $ref: '#/components/schemas/PaginatedPriceList' description: '' post: operationId: producttypes_prices_create @@ -1196,6 +1232,12 @@ paths: get: operationId: producttypes_questions_list parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer - in: path name: product_type_id schema: @@ -1211,9 +1253,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Question' + $ref: '#/components/schemas/PaginatedQuestionList' description: '' post: operationId: producttypes_questions_create @@ -1357,9 +1397,30 @@ paths: responses: '204': description: No response body + /api/v1/producttypes/current_prices/: + get: + operationId: producttypes_current_prices_retrieve + tags: + - producttypes + security: + - tokenAuth: [ ] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ProductTypeCurrentPrice' + description: '' /api/v1/tags/: get: operationId: tags_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer tags: - tags security: @@ -1369,9 +1430,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Tag' + $ref: '#/components/schemas/PaginatedTagList' description: '' post: operationId: tags_create @@ -1487,6 +1546,13 @@ paths: /api/v1/tagtypes/: get: operationId: tagtypes_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer tags: - tagtypes security: @@ -1496,9 +1562,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/TagType' + $ref: '#/components/schemas/PaginatedTagTypeList' description: '' post: operationId: tagtypes_create @@ -1827,6 +1891,236 @@ components: - id - name - url + PaginatedCategoryList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Category' + PaginatedConditionList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Condition' + PaginatedFieldList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Field' + PaginatedLinkList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Link' + PaginatedPriceList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Price' + PaginatedProductList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Product' + PaginatedProductTypeList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ProductType' + PaginatedQuestionList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Question' + PaginatedTagList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Tag' + PaginatedTagTypeList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/TagType' PatchedCategory: type: object properties: @@ -2486,6 +2780,36 @@ components: - uniform_product_name - uniform_product_name_id - updated_on + ProductTypeCurrentPrice: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + name: + type: string + description: Name of the product type + maxLength: 100 + upl_name: + type: string + description: Uniform product name + readOnly: true + upl_uri: + type: string + format: uri + description: Url to the upn definition. + readOnly: true + current_price: + allOf: + - $ref: '#/components/schemas/Price' + nullable: true + required: + - current_price + - id + - name + - upl_name + - upl_uri ProductUpdate: type: object properties: From f0b0f071cc9a3a9a2d68040d3f48c3115c7392b6 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Fri, 27 Sep 2024 09:32:43 +0200 Subject: [PATCH 3/3] rename current_prices endpoint to current-prices --- .../producttypes/tests/api/test_product_type_price.py | 6 +++--- src/open_producten/producttypes/views.py | 6 +++++- src/openapi.yaml | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/open_producten/producttypes/tests/api/test_product_type_price.py b/src/open_producten/producttypes/tests/api/test_product_type_price.py index 1891b04..4a26581 100644 --- a/src/open_producten/producttypes/tests/api/test_product_type_price.py +++ b/src/open_producten/producttypes/tests/api/test_product_type_price.py @@ -345,7 +345,7 @@ def test_delete_price(self): self.assertEqual(PriceOption.objects.count(), 0) def test_get_current_prices_when_product_type_has_no_prices(self): - response = self.client.get("/api/v1/producttypes/current_prices/") + response = self.client.get("/api/v1/producttypes/current-prices/") self.assertEqual(response.status_code, 200) self.assertEqual( response.data, @@ -365,7 +365,7 @@ def test_get_current_prices_when_product_type_only_has_price_in_future(self): product_type=self.product_type, valid_from=datetime.date(2024, 2, 2) ) - response = self.client.get("/api/v1/producttypes/current_prices/") + response = self.client.get("/api/v1/producttypes/current-prices/") self.assertEqual(response.status_code, 200) self.assertEqual( @@ -389,7 +389,7 @@ def test_get_current_prices_when_product_type_has_current_price(self): option = PriceOptionFactory.create(price=price) - response = self.client.get("/api/v1/producttypes/current_prices/") + response = self.client.get("/api/v1/producttypes/current-prices/") self.assertEqual(response.status_code, 200) self.assertEqual( diff --git a/src/open_producten/producttypes/views.py b/src/open_producten/producttypes/views.py index 8a92ef2..b72353a 100644 --- a/src/open_producten/producttypes/views.py +++ b/src/open_producten/producttypes/views.py @@ -36,7 +36,11 @@ class ProductTypeViewSet(OrderedModelViewSet): serializer_class = ProductTypeSerializer lookup_url_kwarg = "id" - @action(detail=False, serializer_class=ProductTypeCurrentPriceSerializer) + @action( + detail=False, + serializer_class=ProductTypeCurrentPriceSerializer, + url_path="current-prices", + ) def current_prices(self, request): product_types = ProductType.objects.all() serializer = ProductTypeCurrentPriceSerializer(product_types, many=True) diff --git a/src/openapi.yaml b/src/openapi.yaml index 35b118d..7acf493 100644 --- a/src/openapi.yaml +++ b/src/openapi.yaml @@ -1397,7 +1397,7 @@ paths: responses: '204': description: No response body - /api/v1/producttypes/current_prices/: + /api/v1/producttypes/current-prices/: get: operationId: producttypes_current_prices_retrieve tags: