diff --git a/deployment/terraform/cdn.tf b/deployment/terraform/cdn.tf index 3506c45b0..44bb494c3 100644 --- a/deployment/terraform/cdn.tf +++ b/deployment/terraform/cdn.tf @@ -10,6 +10,31 @@ locals { path_pattern = "api/v1/production-locations*" default_ttl = var.api_production_locations_cache_default_ttl max_ttl = var.api_production_locations_cache_max_ttl + }, + { + path_pattern = "api/partner-field-groups/*" + default_ttl = var.api_partner_field_groups_cache_default_ttl + max_ttl = var.api_partner_field_groups_cache_max_ttl + }, + { + path_pattern = "api/partner-fields/*" + default_ttl = var.api_partner_fields_cache_default_ttl + max_ttl = var.api_partner_fields_cache_max_ttl + }, + { + path_pattern = "api/contributors/" + default_ttl = var.api_contributors_cache_default_ttl + max_ttl = var.api_contributors_cache_max_ttl + }, + { + path_pattern = "api/contributor-lists-sorted/*" + default_ttl = var.api_contributor_lists_sorted_cache_default_ttl + max_ttl = var.api_contributor_lists_sorted_cache_max_ttl + }, + { + path_pattern = "api/parent-companies/*" + default_ttl = var.api_parent_companies_cache_default_ttl + max_ttl = var.api_parent_companies_cache_max_ttl } ] } @@ -213,7 +238,7 @@ resource "aws_cloudfront_distribution" "cdn" { forwarded_values { query_string = true - headers = [ + headers = [ "Host", "Authorization", "X-OAR-CLIENT-KEY", diff --git a/deployment/terraform/variables.tf b/deployment/terraform/variables.tf index bc3a2308e..cbaf29f6a 100644 --- a/deployment/terraform/variables.tf +++ b/deployment/terraform/variables.tf @@ -67,6 +67,66 @@ variable "api_production_locations_cache_max_ttl" { default = 120 } +variable "api_partner_field_groups_cache_default_ttl" { + description = "Default TTL (seconds) for partner field groups endpoint" + type = number + default = 120 +} + +variable "api_partner_field_groups_cache_max_ttl" { + description = "Max TTL (seconds) for partner field groups endpoint" + type = number + default = 300 +} + +variable "api_partner_fields_cache_default_ttl" { + description = "Default TTL (seconds) for partner fields endpoint" + type = number + default = 120 +} + +variable "api_partner_fields_cache_max_ttl" { + description = "Max TTL (seconds) for partner fields endpoint" + type = number + default = 300 +} + +variable "api_contributors_cache_default_ttl" { + description = "Default TTL (seconds) for API contributors endpoints" + type = number + default = 120 +} + +variable "api_contributors_cache_max_ttl" { + description = "Max TTL (seconds) for API contributors endpoints" + type = number + default = 300 +} + +variable "api_contributor_lists_sorted_cache_default_ttl" { + description = "Default TTL (seconds) for API contributor lists sorted endpoints" + type = number + default = 120 +} + +variable "api_contributor_lists_sorted_cache_max_ttl" { + description = "Max TTL (seconds) for API contributor lists sorted endpoints" + type = number + default = 300 +} + +variable "api_parent_companies_cache_default_ttl" { + description = "Default TTL (seconds) for API parent companies endpoints" + type = number + default = 120 +} + +variable "api_parent_companies_cache_max_ttl" { + description = "Max TTL (seconds) for API parent companies endpoints" + type = number + default = 300 +} + variable "vpc_cidr_block" { default = "10.0.0.0/16" } diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index 1387d2e7a..0ca9976b8 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -30,6 +30,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html * Previously opened facility pages at `/facilities/:osID` will redirect to `/production-locations/:osID` after page refresh. * When the feature flag is disabled, accessing `/production-locations/:osID` routes will result in a "Not found" page with no automatic redirection to the legacy `/facilities/:osID` route. * [OSDEV-2353](https://opensupplyhub.atlassian.net/browse/OSDEV-2353) - Created basic layout components for new Production Location page redesign. +* [OSDEV-2356](https://opensupplyhub.atlassian.net/browse/OSDEV-2356) - Added `GET api/partner-field-groups/` endpoint to retrieve partner field groups with pagination support and CDN caching for the endpoint (and additional endpoints for partner fields and contributors). ### Release instructions * Ensure that the following commands are included in the `post_deployment` command: diff --git a/src/django/api/admin.py b/src/django/api/admin.py index fd7684dc2..d14dcf364 100644 --- a/src/django/api/admin.py +++ b/src/django/api/admin.py @@ -14,6 +14,7 @@ from django.utils.translation import gettext as _ from api.models.sector_group import SectorGroup from api.models.partner_field import PartnerField +from api.models.partner_field_group import PartnerFieldGroup from api.models.wage_indicator_country_data import WageIndicatorCountryData from api.models.wage_indicator_link_text_config import ( WageIndicatorLinkTextConfig @@ -334,14 +335,34 @@ def clean(self): class PartnerFieldAdmin(admin.ModelAdmin): form = PartnerFieldAdminForm - list_display = ('name', 'type', 'label', 'unit', 'active', 'system_field', - 'created_at') - search_fields = ('name', 'type', 'label', 'unit', 'source_by') - list_filter = ('active', 'system_field', 'type') - readonly_fields = ('uuid', 'created_at', 'updated_at') - fields = ('name', 'type', 'unit', 'label', 'source_by', 'base_url', - 'display_text', 'json_schema', 'active', 'system_field', - 'created_at', 'updated_at') + list_display = ( + "name", + "type", + "label", + "unit", + "group", + "active", + "system_field", + "created_at", + ) + search_fields = ("name", "type", "label", "unit", "source_by") + list_filter = ("active", "system_field", "type", "group") + readonly_fields = ("uuid", "created_at", "updated_at") + fields = ( + "name", + "type", + "unit", + "label", + "group", + "source_by", + "base_url", + "display_text", + "json_schema", + "active", + "system_field", + "created_at", + "updated_at", + ) def get_queryset(self, request): ''' @@ -432,6 +453,7 @@ class USCountyTigerlineAdmin(admin.ModelAdmin): admin_site.register(SectorGroup, SectorGroupAdmin) admin_site.register(models.FacilityDownloadLimit, FacilityDownloadLimitAdmin) admin_site.register(PartnerField, PartnerFieldAdmin) +admin_site.register(PartnerFieldGroup) admin_site.register(EmailAddress, EmailAddressAdmin) admin_site.register(WageIndicatorCountryData, WageIndicatorCountryDataAdmin) admin_site.register( diff --git a/src/django/api/migrations/0201_add_partnerfieldgroup_alter_partnerfield.py b/src/django/api/migrations/0201_add_partnerfieldgroup_alter_partnerfield.py new file mode 100644 index 000000000..1fdaca1ec --- /dev/null +++ b/src/django/api/migrations/0201_add_partnerfieldgroup_alter_partnerfield.py @@ -0,0 +1,94 @@ +# Generated by Django 5.2.10 on 2026-02-26 13:28 + +import django.db.models.deletion +import django_ckeditor_5.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0200_introduce_indexing_of_the_creation_date_of_the_claim_request"), + ] + + operations = [ + migrations.CreateModel( + name="PartnerFieldGroup", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="Unique identifier for the partner field group.", + primary_key=True, + serialize=False, + ), + ), + ( + "name", + models.CharField( + help_text="The partner field group name.", + max_length=200, + unique=True, + ), + ), + ( + "order", + models.IntegerField( + default=0, + help_text="Order for the partner field group in the UI.", + ), + ), + ( + "icon_file", + models.ImageField( + blank=True, + help_text="Upload an icon image.", + null=True, + upload_to="partner_field_groups/icons/", + ), + ), + ( + "description", + django_ckeditor_5.fields.CKEditor5Field( + blank=True, + help_text="Rich text description of the partner field group.", + null=True, + ), + ), + ( + "helper_text", + django_ckeditor_5.fields.CKEditor5Field( + blank=True, + help_text="Rich text helper text for the partner field group.", + null=True, + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True), + ), + ], + options={ + "verbose_name_plural": "Partner field groups", + "ordering": ["order"], + }, + ), + migrations.AddField( + model_name="partnerfield", + name="group", + field=models.ForeignKey( + blank=True, + help_text="The group this partner field belongs to.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="partner_fields", + to="api.partnerfieldgroup", + ), + ), + ] diff --git a/src/django/api/models/__init__.py b/src/django/api/models/__init__.py index ff4f61c9d..b29ba0e3e 100644 --- a/src/django/api/models/__init__.py +++ b/src/django/api/models/__init__.py @@ -65,6 +65,8 @@ HistoricalExtendedField ) from .nonstandard_field import NonstandardField +from .partner_field import PartnerField +from .partner_field_group import PartnerFieldGroup from .product_type import ProductType from .production_type import ProductionType from .request_log import RequestLog diff --git a/src/django/api/models/partner_field.py b/src/django/api/models/partner_field.py index fae2e2e32..213f78177 100644 --- a/src/django/api/models/partner_field.py +++ b/src/django/api/models/partner_field.py @@ -98,6 +98,14 @@ class Meta: "System fields cannot be deleted and have restricted editing." ), ) + group = models.ForeignKey( + 'PartnerFieldGroup', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='partner_fields', + help_text="The group this partner field belongs to.", + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/src/django/api/models/partner_field_group.py b/src/django/api/models/partner_field_group.py new file mode 100644 index 000000000..563ee2ff6 --- /dev/null +++ b/src/django/api/models/partner_field_group.py @@ -0,0 +1,51 @@ +import uuid +from django.db import models +from django_ckeditor_5.fields import CKEditor5Field + + +class PartnerFieldGroup(models.Model): + """ + Group for partner fields. + """ + + uuid = models.UUIDField( + default=uuid.uuid4, + primary_key=True, + editable=False, + help_text="Unique identifier for the partner field group.", + ) + name = models.CharField( + max_length=200, + unique=True, + null=False, + help_text="The partner field group name.", + ) + order = models.IntegerField( + default=0, + help_text="Order for the partner field group in the UI.", + ) + icon_file = models.ImageField( + upload_to="partner_field_groups/icons/", + blank=True, + null=True, + help_text="Upload an icon image.", + ) + description = CKEditor5Field( + blank=True, + null=True, + help_text="Rich text description of the partner field group.", + ) + helper_text = CKEditor5Field( + blank=True, + null=True, + help_text="Rich text helper text for the partner field group.", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name_plural = "Partner field groups" + ordering = ["order"] + + def __str__(self): + return self.name diff --git a/src/django/api/serializers/partner_field_group/partner_field_group_serializer.py b/src/django/api/serializers/partner_field_group/partner_field_group_serializer.py new file mode 100644 index 000000000..a1a9dfbcc --- /dev/null +++ b/src/django/api/serializers/partner_field_group/partner_field_group_serializer.py @@ -0,0 +1,41 @@ +""" +Serializer for partner field groups. +Specifies the fields that are returned by +the GET /api/partner-field-groups/ API endpoint. +""" + +from rest_framework import serializers +from rest_framework.serializers import ModelSerializer +from api.models.partner_field_group import PartnerFieldGroup + + +class PartnerFieldGroupSerializer(ModelSerializer): + """ + Serializer for the PartnerFieldGroup model. + Serializes the fields and related partner_fields for the API response. + """ + + partner_fields = serializers.SlugRelatedField( + many=True, + read_only=True, + slug_field="name", + ) + + class Meta: + """ + Meta class for partner field group serializer. + Specifies the fields that are returned by the API response. + """ + + model = PartnerFieldGroup + fields = [ + "uuid", + "name", + "order", + "icon_file", + "description", + "helper_text", + "partner_fields", + "created_at", + "updated_at", + ] diff --git a/src/django/api/tests/test_partner_field_groups_view_set.py b/src/django/api/tests/test_partner_field_groups_view_set.py new file mode 100644 index 000000000..c27586dd5 --- /dev/null +++ b/src/django/api/tests/test_partner_field_groups_view_set.py @@ -0,0 +1,150 @@ +""" +Integration tests for the GET /api/partner-field-groups/ API endpoint. +Those are smoke tests to verify that the endpoint is working as expected. +""" + +from rest_framework import status +from rest_framework.test import APITestCase + +from api.models.partner_field import PartnerField +from api.models.partner_field_group import PartnerFieldGroup + + +class PartnerFieldGroupsViewSetTest(APITestCase): + """ + Test cases for the partner field groups API endpoint. + They are testing the following: + - Returns 200 for all users. + - Returns paginated partner field groups. + - Limit parameter controls page size. + - Ordering is respected. + - Returns partner fields in the group. + - Can't create partner field groups. + """ + + def setUp(self): + self.url = "/api/partner-field-groups/" + + def _create_partner_field_group(self, name, order=0): + """Helper to create a partner field group.""" + return PartnerFieldGroup.objects.create( + name=name, + order=order, + ) + + def _create_partner_field( + self, + name, + group=None, + field_type=PartnerField.STRING, + active=True, + system_field=False, + ): + """Helper to create a partner field.""" + return PartnerField.objects.get_all_including_inactive().create( + name=name, + group=group, + type=field_type, + active=active, + system_field=system_field, + ) + + def test_returns_200_for_all_users(self): + """Verify endpoint returns 200 for all users.""" + response = self.client.get(self.url) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + def test_returns_partner_field_groups(self): + """Verify endpoint returns paginated partner field groups.""" + groups = [ + self._create_partner_field_group("Group 1", 1), + self._create_partner_field_group("Group 2", 2), + ] + response = self.client.get(self.url) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertIn("results", response.data) + self.assertEqual(len(response.data["results"]), 2) + + result_uuids = [result["uuid"] for result in response.data["results"]] + for group in groups: + self.assertIn(str(group.uuid), result_uuids) + + def test_limit_parameter_controls_page_size(self): + """Verify ?limit= parameter controls page size.""" + for i in range(5): + self._create_partner_field_group(f"limit_test_group_{i}") + + limit = 2 + response = self.client.get(self.url, {"limit": limit}) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + self.assertEqual(len(response.data["results"]), limit) + + def test_ordering_is_respected(self): + """Verify partner field groups are ordered by 'order' field.""" + groups = [ + self._create_partner_field_group("Group 1", 1), + self._create_partner_field_group("Group 3", 3), + self._create_partner_field_group("Group 2", 2), + ] + response = self.client.get(self.url) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + + results = response.data["results"] + self.assertEqual(len(results), len(groups)) + response_uuids = [result["uuid"] for result in results] + + sorted_groups = sorted(groups, key=lambda group: group.order) + sorted_uuids = [str(group.uuid) for group in sorted_groups] + + for sorted_uuid, response_uuid in zip( + sorted_uuids, response_uuids, strict=True + ): + self.assertEqual(sorted_uuid, response_uuid) + + def test_returns_partner_fields_in_group(self): + """Verify partner fields are returned in the group.""" + group = self._create_partner_field_group("Test Group") + fields = [ + self._create_partner_field("Field 1", group=group), + self._create_partner_field("Field 2", group=group), + ] + + response = self.client.get(self.url) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + (result,) = response.data["results"] + self.assertEqual(result["uuid"], str(group.uuid)) + self.assertIn("partner_fields", result) + self.assertEqual(len(result["partner_fields"]), len(fields)) + + for field in fields: + self.assertIn(field.name, result["partner_fields"]) + + def test_cant_create_partner_field_groups(self): + """Verify partner field groups can't be created via API.""" + response = self.client.post( + self.url, + {"name": "New Group"}, + ) + self.assertEqual( + response.status_code, + status.HTTP_405_METHOD_NOT_ALLOWED, + ) diff --git a/src/django/api/views/__init__.py b/src/django/api/views/__init__.py index d131115da..51eeade95 100644 --- a/src/django/api/views/__init__.py +++ b/src/django/api/views/__init__.py @@ -71,3 +71,6 @@ from .sectors import sectors from .claim_statuses import claim_statuses from .partner_fields.partner_fields_view_set import PartnerFieldsViewSet +from .partner_field_groups.partner_field_groups_view_set import ( + PartnerFieldGroupsViewSet, +) diff --git a/src/django/api/views/partner_field_groups/partner_field_groups_view_set.py b/src/django/api/views/partner_field_groups/partner_field_groups_view_set.py new file mode 100644 index 000000000..f945178a1 --- /dev/null +++ b/src/django/api/views/partner_field_groups/partner_field_groups_view_set.py @@ -0,0 +1,39 @@ +""" +Viewset for partner field groups. +Allows listing of the partner field groups with pagination. +Available for all users. +""" + +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.pagination import CursorPagination + +from api.models.partner_field_group import PartnerFieldGroup +from api.serializers.partner_field_group.\ + partner_field_group_serializer import PartnerFieldGroupSerializer + + +class PartnerFieldGroupCursorPagination(CursorPagination): + """ + Cursor based pagination for partner field groups. + Allows the client to control the page size via the ?limit= parameter. + And adds the default ordering by the `order` field. + """ + + page_size = 20 + ordering = "order" + page_size_query_param = "limit" + max_page_size = 100 + + +class PartnerFieldGroupsViewSet(ReadOnlyModelViewSet): + """ + Allows listing of the partner field groups. + Also, prefetches the related partner fields to avoid N+1 queries. + Available for all users. + """ + + queryset = PartnerFieldGroup.objects.prefetch_related( + "partner_fields" + ).all() + serializer_class = PartnerFieldGroupSerializer + pagination_class = PartnerFieldGroupCursorPagination diff --git a/src/django/oar/urls.py b/src/django/oar/urls.py index 413956ab9..b2257831a 100644 --- a/src/django/oar/urls.py +++ b/src/django/oar/urls.py @@ -115,6 +115,11 @@ views.PartnerFieldsViewSet.as_view({'get': 'list'}), name='partner_fields' ), + path( + 'api/partner-field-groups/', + views.PartnerFieldGroupsViewSet.as_view({'get': 'list'}), + name='partner_field_groups' + ), ] api_v1 = [path('api/v1/', include(v1_router.urls + v1_custom_routes))]