From de733f3f7afb591312099e325908060dc685d49b Mon Sep 17 00:00:00 2001 From: Muchogo Date: Wed, 25 Jan 2023 09:32:02 +0300 Subject: [PATCH] feat: program facilities, facility specific content --- config/api_router.py | 8 +- config/settings/base.py | 1 - mycarehub/common/filters/__init__.py | 3 +- .../common/filters/custom_filter_backends.py | 22 ----- .../migrations/0003_auto_20230124_1200.py | 27 +++++ .../migrations/0004_program_facilities.py | 18 ++++ mycarehub/common/models/common_models.py | 12 +-- mycarehub/common/models/program_models.py | 2 + .../common/serializers/common_serializers.py | 15 ++- mycarehub/common/serializers/mixins.py | 4 - mycarehub/common/tests/test_api.py | 99 +++++++++++++++---- mycarehub/common/tests/test_models.py | 2 +- .../views/common_views/drf_common_views.py | 23 ++++- mycarehub/conftest.py | 12 ++- mycarehub/content/filters.py | 15 +++ .../migrations/0002_contentitem_facilities.py | 20 ++++ mycarehub/content/models/models.py | 8 +- mycarehub/content/tests/test_filters.py | 18 ++++ mycarehub/content/views/views.py | 4 +- 19 files changed, 247 insertions(+), 66 deletions(-) create mode 100644 mycarehub/common/migrations/0003_auto_20230124_1200.py create mode 100644 mycarehub/common/migrations/0004_program_facilities.py create mode 100644 mycarehub/content/migrations/0002_contentitem_facilities.py diff --git a/config/api_router.py b/config/api_router.py index 7caa050..3e34335 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -33,25 +33,25 @@ app_name = "api" urlpatterns = router.urls + [ - path("users/", UserAPIView.as_view(), name="users-detail"), + path("users//", UserAPIView.as_view(), name="users-detail"), path( "users/", UserAPIView.as_view(), name="users-general", ), - path("organisations/", OrganisationAPIView.as_view(), name="organisations-detail"), + path("organisations//", OrganisationAPIView.as_view(), name="organisations-detail"), path( "organisations/", OrganisationAPIView.as_view(), name="organisations-general", ), - path("programs/", ProgramAPIView.as_view(), name="programs-detail"), + path("programs//", ProgramAPIView.as_view(), name="programs-detail"), path( "programs/", ProgramAPIView.as_view(), name="programs-general", ), - path("clients/", ClientAPIView.as_view(), name="clients-detail"), + path("clients//", ClientAPIView.as_view(), name="clients-detail"), path( "clients/", ClientAPIView.as_view(), diff --git a/config/settings/base.py b/config/settings/base.py index 0a12b67..7fbb832 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -325,7 +325,6 @@ "django_filters.rest_framework.DjangoFilterBackend", "rest_framework.filters.OrderingFilter", "mycarehub.common.filters.OrganisationFilterBackend", - "mycarehub.common.filters.AllottedFacilitiesFilterBackend", ), "DEFAULT_PAGINATION_CLASS": ( "rest_framework_datatables.pagination.DatatablesPageNumberPagination" diff --git a/mycarehub/common/filters/__init__.py b/mycarehub/common/filters/__init__.py index fa3535a..7e4f0c3 100644 --- a/mycarehub/common/filters/__init__.py +++ b/mycarehub/common/filters/__init__.py @@ -1,9 +1,8 @@ from .base_filters import CommonFieldsFilterset from .common_filters import FacilityFilter, UserFacilityAllotmentFilter -from .custom_filter_backends import AllottedFacilitiesFilterBackend, OrganisationFilterBackend +from .custom_filter_backends import OrganisationFilterBackend __all__ = [ - "AllottedFacilitiesFilterBackend", "CommonFieldsFilterset", "FacilityFilter", "OrganisationFilterBackend", diff --git a/mycarehub/common/filters/custom_filter_backends.py b/mycarehub/common/filters/custom_filter_backends.py index f7540f1..f3cb39b 100644 --- a/mycarehub/common/filters/custom_filter_backends.py +++ b/mycarehub/common/filters/custom_filter_backends.py @@ -2,34 +2,12 @@ from mycarehub.content.models import ContentItemCategory -from ..models import UserFacilityAllotment - - -class AllottedFacilitiesFilterBackend(filters.BaseFilterBackend): - """Users are only allowed to view records relating to facilities they have been allotted to. - - For this to work, the attribute `facility_field_lookup` must be present on a view. The - attribute should contain a lookup to a facility and should be usable from the queryset - returned by the view. If the aforementioned attribute is not present on a view, then no - filtering is performed and the queryset is returned as is. - """ - - def filter_queryset(self, request, queryset, view): - """Exclude records associated to facilities that the requesting user is not allotted.""" - lookup = getattr(view, "facility_field_lookup", None) - if not lookup: - return queryset - allotted_facilities = UserFacilityAllotment.get_facilities_for_user(request.user) - qs_filter = {"%s__in" % lookup: allotted_facilities} - return queryset.filter(**qs_filter) - class OrganisationFilterBackend(filters.BaseFilterBackend): """Users are only allowed to view records in their organisation.""" def filter_queryset(self, request, queryset, view): """Filter all records that have an organisation field by user org.""" - # Exclude models without an organisation if queryset.model in [ContentItemCategory]: return queryset return queryset.filter(organisation=request.user.organisation) diff --git a/mycarehub/common/migrations/0003_auto_20230124_1200.py b/mycarehub/common/migrations/0003_auto_20230124_1200.py new file mode 100644 index 0000000..8686997 --- /dev/null +++ b/mycarehub/common/migrations/0003_auto_20230124_1200.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.16 on 2023-01-24 09:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0002_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='facility', + name='fhir_organization_id', + ), + migrations.AlterField( + model_name='facility', + name='county', + field=models.CharField(blank=True, choices=[('Nairobi', 'Nairobi'), ('Kajiado', 'Kajiado')], max_length=64, null=True), + ), + migrations.AlterField( + model_name='facility', + name='mfl_code', + field=models.IntegerField(blank=True, help_text='MFL Code', null=True, unique=True), + ), + ] diff --git a/mycarehub/common/migrations/0004_program_facilities.py b/mycarehub/common/migrations/0004_program_facilities.py new file mode 100644 index 0000000..96f4a23 --- /dev/null +++ b/mycarehub/common/migrations/0004_program_facilities.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2023-01-25 12:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0003_auto_20230124_1200'), + ] + + operations = [ + migrations.AddField( + model_name='program', + name='facilities', + field=models.ManyToManyField(blank=True, null=True, related_name='programs', to='common.Facility'), + ), + ] diff --git a/mycarehub/common/models/common_models.py b/mycarehub/common/models/common_models.py index 4f5cf52..a5785b2 100644 --- a/mycarehub/common/models/common_models.py +++ b/mycarehub/common/models/common_models.py @@ -7,7 +7,6 @@ from django.urls import reverse from django.utils import timezone -from ..constants import WHITELIST_COUNTIES from ..utils import get_constituencies, get_counties, get_sub_counties, get_wards from .base_models import AbstractBase, AbstractBaseManager, AbstractBaseQuerySet @@ -24,9 +23,7 @@ class FacilityQuerySet(AbstractBaseQuerySet): def mycarehub_facilities(self): """Return all the facilities that are part of the FYJ program.""" - return self.active().filter( - county__in=WHITELIST_COUNTIES, - ) + return self.active() # ============================================================================= @@ -58,10 +55,9 @@ class Facility(AbstractBase): name = models.TextField(unique=True) description = models.TextField(blank=True, default="") - mfl_code = models.IntegerField(unique=True, help_text="MFL Code") - county = models.CharField(max_length=64, choices=get_counties()) + mfl_code = models.IntegerField(unique=True, help_text="MFL Code", blank=True, null=True) + county = models.CharField(max_length=64, choices=get_counties(), null=True, blank=True) phone = models.CharField(max_length=15, null=True, blank=True) - fhir_organization_id = models.CharField(unique=True, max_length=64, blank=True, null=True) objects = FacilityManager() @@ -78,7 +74,7 @@ def check_facility_name_longer_than_three_characters(self): raise ValidationError("the facility name should exceed 3 characters") def __str__(self): - return f"{self.name} - {self.mfl_code} ({self.county})" + return f"{self.name}" class Meta(AbstractBase.Meta): verbose_name_plural = "facilities" diff --git a/mycarehub/common/models/program_models.py b/mycarehub/common/models/program_models.py index 3b252c0..2d2081a 100644 --- a/mycarehub/common/models/program_models.py +++ b/mycarehub/common/models/program_models.py @@ -1,11 +1,13 @@ from django.db import models from .base_models import AbstractBase +from .common_models import Facility class Program(AbstractBase): name = models.TextField(max_length=100, unique=True) + facilities = models.ManyToManyField(Facility, blank=True, null=True, related_name="programs") class Meta: unique_together = ( diff --git a/mycarehub/common/serializers/common_serializers.py b/mycarehub/common/serializers/common_serializers.py index cc409b4..be1b271 100644 --- a/mycarehub/common/serializers/common_serializers.py +++ b/mycarehub/common/serializers/common_serializers.py @@ -1,5 +1,6 @@ """Common serializers.""" import logging +import uuid from django import forms from django.contrib.auth import get_user_model @@ -28,6 +29,8 @@ class Meta: class FacilitySerializer(BaseSerializer): + id = serializers.UUIDField(read_only=False, default=uuid.uuid4) + class Meta(BaseSerializer.Meta): model = Facility fields = "__all__" @@ -53,7 +56,17 @@ class Meta(BaseSerializer.Meta): class ProgramSerializer(BaseSerializer): class Meta(BaseSerializer.Meta): model = Program - fields = ["id", "name"] + fields = ["id", "name", "facilities"] + + def update(self, instance, validated_data): + facilities = validated_data.pop("facilities", None) + if facilities: + instance.facilities.clear() + + for facility in facilities: + instance.facilities.add(facility) + + return super().update(instance, validated_data) class OrganisationRegistrationSerializer(FormSerializer): diff --git a/mycarehub/common/serializers/mixins.py b/mycarehub/common/serializers/mixins.py index c89c6b3..5c1a72d 100644 --- a/mycarehub/common/serializers/mixins.py +++ b/mycarehub/common/serializers/mixins.py @@ -2,7 +2,6 @@ import logging from rest_framework import exceptions, serializers -from rest_framework.serializers import ValidationError from mycarehub.common.models import Organisation @@ -87,9 +86,6 @@ def populate_audit_fields(self, data, is_create): def create(self, validated_data): """Ensure that ids are not supplied when creating new instances.""" - initial_data_id = isinstance(self.initial_data, dict) and self.initial_data.get("id") - if initial_data_id or validated_data.get("id"): - raise ValidationError({"id": "You are not allowed to pass object with an id"}) self.populate_audit_fields(validated_data, True) return super().create(validated_data) diff --git a/mycarehub/common/tests/test_api.py b/mycarehub/common/tests/test_api.py index 942a93d..cbd1952 100644 --- a/mycarehub/common/tests/test_api.py +++ b/mycarehub/common/tests/test_api.py @@ -125,6 +125,7 @@ def setUp(self): organisation=self.global_organisation, ) self.data = { + "id": uuid.uuid4(), "name": fake.name(), "mfl_code": random.randint(1, 999_999_999), "county": "Nairobi", @@ -201,24 +202,6 @@ def test_create_facility_no_organisation(self): assert response.status_code == 201, response.json() assert response.data["mfl_code"] == data["mfl_code"] - def test_create_facility_error_supplied_id(self): - """Test add facility.""" - data = { - "id": uuid.uuid4(), - "name": fake.name(), - "mfl_code": random.randint(1, 999_999_999), - "county": random.choice(WHITELIST_COUNTIES), - "is_mycarehub_facility": True, - "operation_status": "Operational", - "organisation": self.global_organisation.pk, - } - - response = self.client.post(self.url_list, data) - assert response.status_code == 400, response.json() - assert ( - "You are not allowed to pass object with an id" in response.json()["id"] - ), response.json() - def test_create_facility_error_bad_organisation(self): """Test add facility.""" data = { @@ -664,6 +647,22 @@ def test_program_registration(user_with_all_permissions, client): assert response_data["id"] is not None +def test_program_list(user_with_all_permissions, client): + client.force_login(user_with_all_permissions) + url = reverse("api:programs-general") + + response = client.get( + url, + content_type="application/json", + accept="application/json", + ) + + assert response.status_code == status.HTTP_200_OK + + response_data = response.json() + assert response_data[0]["id"] is not None + + def test_program_registration_invalid_input(user_with_all_permissions, client): client.force_login(user_with_all_permissions) url = reverse("api:programs-general") @@ -678,3 +677,67 @@ def test_program_registration_invalid_input(user_with_all_permissions, client): ) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_program_patch_no_facilities(user_with_all_permissions, client): + program = baker.make(Program) + + client.force_login(user_with_all_permissions) + url = reverse("api:programs-detail", kwargs={"pk": program.id}) + + response = client.patch( + url, + data={ + "name": fake.name(), + }, + content_type="application/json", + accept="application/json", + ) + + assert response.status_code == status.HTTP_200_OK + + response_data = response.json() + assert response_data["id"] == str(program.id) + + +def test_program_patch_facilities(user_with_all_permissions, client): + program = baker.make(Program) + + client.force_login(user_with_all_permissions) + url = reverse("api:programs-detail", kwargs={"pk": program.id}) + + facility = baker.make(Facility) + + response = client.patch( + url, + data={ + "name": fake.name(), + "facilities": [facility.id], + }, + content_type="application/json", + accept="application/json", + ) + + assert response.status_code == status.HTTP_200_OK + + response_data = response.json() + assert response_data["id"] == str(program.id) + + +def test_program_patch_invalid_data(user_with_all_permissions, client): + program = baker.make(Program) + + client.force_login(user_with_all_permissions) + url = reverse("api:programs-detail", kwargs={"pk": program.id}) + + response = client.patch( + url, + data={ + "name": 12123123, + "facilities": ["invalid"], + }, + content_type="application/json", + accept="application/json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/mycarehub/common/tests/test_models.py b/mycarehub/common/tests/test_models.py index 862a071..fbd05fb 100644 --- a/mycarehub/common/tests/test_models.py +++ b/mycarehub/common/tests/test_models.py @@ -62,7 +62,7 @@ def test_facility_string_representation(): updated_by=updated_by, ) facility.save() - assert str(facility) == f"{facility_name} - {mfl_code} (Nairobi)" + assert str(facility) == f"{facility_name}" def test_google_application_credentials(): diff --git a/mycarehub/common/views/common_views/drf_common_views.py b/mycarehub/common/views/common_views/drf_common_views.py index ccdd29f..4366946 100644 --- a/mycarehub/common/views/common_views/drf_common_views.py +++ b/mycarehub/common/views/common_views/drf_common_views.py @@ -1,3 +1,4 @@ +from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -27,7 +28,6 @@ class FacilityViewSet(BaseView): "mfl_code", "registration_number", ) - facility_field_lookup = "pk" class UserFacilityViewSet(BaseView): @@ -76,6 +76,12 @@ class ProgramAPIView(APIView): queryset = Program.objects.all() serializer_class = ProgramRegistrationSerializer + def get(self, request): + programs = Program.objects.all() + serializer = ProgramSerializer(programs, many=True) + + return Response(status=status.HTTP_200_OK, data=serializer.data) + def post(self, request, format=None): serializer = self.serializer_class(data=request.data) if serializer.is_valid(): @@ -98,3 +104,18 @@ def post(self, request, format=None): ) # pragma: nocover return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, pk): + program = get_object_or_404(Program, pk=pk) + + data = request.data + serializer = ProgramSerializer( + instance=program, context={"request": request}, data=data, partial=True + ) + if serializer.is_valid(): + updated_program = serializer.save() + return Response( + status=status.HTTP_200_OK, data=ProgramSerializer(updated_program).data + ) + + return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/mycarehub/conftest.py b/mycarehub/conftest.py index 1b78df4..f1cc8f3 100644 --- a/mycarehub/conftest.py +++ b/mycarehub/conftest.py @@ -7,7 +7,7 @@ from taggit.models import Tag from wagtail.models import Page, Site -from mycarehub.common.models import Program +from mycarehub.common.models import Facility, Program from mycarehub.content.models import Author, ContentItem, ContentItemCategory, ContentItemIndexPage from mycarehub.home.models import HomePage from mycarehub.users.models import User @@ -103,7 +103,12 @@ def content_item_index(request_with_user, program): @pytest.fixture -def content_item_with_tag_and_category(content_item_index, program): +def facility(): + return baker.make(Facility) + + +@pytest.fixture +def content_item_with_tag_and_category(content_item_index, program, facility): # get a hero image hero = baker.make("wagtailimages.Image", _create_files=True) @@ -134,6 +139,9 @@ def content_item_with_tag_and_category(content_item_index, program): tag = baker.make(Tag, name="a-valid-tag") # expect slug a-valid-tag content_item.tags.add(tag) content_item.save() + + content_item.facilities.add(facility) + assert ContentItem.objects.filter(tags__name="a-valid-tag").count() == 1 # sanity checks diff --git a/mycarehub/content/filters.py b/mycarehub/content/filters.py index 1ef7aa8..db48704 100644 --- a/mycarehub/content/filters.py +++ b/mycarehub/content/filters.py @@ -81,6 +81,21 @@ def filter_queryset(self, request, queryset, view): return queryset +class FacilityFilter(BaseFilterBackend): + """ + Implements the facility_id filter which only returns pages from a specific facility + """ + + def filter_queryset(self, request, queryset, view): + query_params = request.query_params + facility_id = query_params.get("facility_id", "") + + if facility_id and queryset.model is ContentItem: + queryset = queryset.filter(Q(facilities=facility_id)) + + return queryset + + class ContentItemCategoryFilter(CommonFieldsFilterset): def category_has_content(self, queryset, field, value): """ diff --git a/mycarehub/content/migrations/0002_contentitem_facilities.py b/mycarehub/content/migrations/0002_contentitem_facilities.py new file mode 100644 index 0000000..c7e5373 --- /dev/null +++ b/mycarehub/content/migrations/0002_contentitem_facilities.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.16 on 2023-01-24 09:00 + +from django.db import migrations +import modelcluster.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0003_auto_20230124_1200'), + ('content', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='contentitem', + name='facilities', + field=modelcluster.fields.ParentalManyToManyField(help_text='Determines which facilities content is meant for.', to='common.Facility'), + ), + ] diff --git a/mycarehub/content/models/models.py b/mycarehub/content/models/models.py index 4b57706..e902c1a 100644 --- a/mycarehub/content/models/models.py +++ b/mycarehub/content/models/models.py @@ -15,7 +15,7 @@ from wagtail.search import index from wagtailmedia.edit_handlers import MediaChooserPanel -from mycarehub.common.models import Organisation, Program +from mycarehub.common.models import Facility, Organisation, Program from mycarehub.utils.general_utils import default_program from .snippets import Author, ContentItemCategory @@ -160,6 +160,7 @@ def __init__( self.fields["categories"].queryset = self.fields["categories"].queryset.filter( organisation=self.for_user.organisation, programs=parent_page.specific.program ) + self.fields["facilities"].queryset = parent_page.specific.program.facilities.all() class ContentItem(Page): @@ -193,6 +194,10 @@ class ItemTypes(models.TextChoices): related_name="%(app_label)s_%(class)s_related", ) + facilities = ParentalManyToManyField( + Facility, help_text="Determines which facilities content is meant for." + ) + date = models.DateField( "Post date", help_text="This will be shown to readers as the publication date", @@ -317,6 +322,7 @@ def clean(self): FieldPanel("hero_image"), FieldPanel("body", classname="full"), FieldPanel("time_estimate_seconds"), + FieldPanel("facilities", widget=forms.CheckboxSelectMultiple), # documents, images and media attached to content item pages # a content item can have zero or more of each of these InlinePanel("gallery_images", label="Gallery images"), diff --git a/mycarehub/content/tests/test_filters.py b/mycarehub/content/tests/test_filters.py index 8b35c2d..add771e 100644 --- a/mycarehub/content/tests/test_filters.py +++ b/mycarehub/content/tests/test_filters.py @@ -166,6 +166,24 @@ def test_client_filter_found_categories( assert response_data["meta"]["total_count"] == 1 +def test_content_facility_filter( + content_item_with_tag_and_category, request_with_user, client, facility +): + assert content_item_with_tag_and_category is not None + assert content_item_with_tag_and_category.categories.count() == 1 + assert content_item_with_tag_and_category.categories.filter(id=999_999).count() == 1 + + client.force_login(request_with_user.user) + url = ( + reverse("wagtailapi:pages:listing") + + f"?type=content.ContentItem&fields=*&facility_id={facility.id}" + ) + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert response_data["meta"]["total_count"] == 1 + + def test_category_with_program_filter( request_with_user, client, diff --git a/mycarehub/content/views/views.py b/mycarehub/content/views/views.py index 913b492..35ae191 100644 --- a/mycarehub/content/views/views.py +++ b/mycarehub/content/views/views.py @@ -15,6 +15,7 @@ CategoryFilter, ClientFilter, ContentItemCategoryFilter, + FacilityFilter, TagFilter, ) from mycarehub.content.models import ContentItemCategory @@ -26,6 +27,7 @@ class CustomPageAPIViewset(PagesAPIViewSet): filter_backends = [ TagFilter, ClientFilter, + FacilityFilter, CategoryFilter, FieldsFilter, ChildOfFilter, @@ -37,7 +39,7 @@ class CustomPageAPIViewset(PagesAPIViewSet): SearchFilter, # must be last ] known_query_parameters = PagesAPIViewSet.known_query_parameters.union( - ["tag", "category", "category_name", "client_id"] + ["tag", "category", "category_name", "client_id", "facility_id"] )