diff --git a/documentation/docs/developer/database.md b/documentation/docs/developer/database.md index fb5ec270..22e081f6 100644 --- a/documentation/docs/developer/database.md +++ b/documentation/docs/developer/database.md @@ -15,29 +15,27 @@ Database structure has few tables. The models are as follows: - **Patient**: There is one record for each child in the audit - **Visit**: There are multiple records for each patient. In NPDA, children are seen 4 times a year in clinic and have additional contacts. During these visits details regarding care processes and diabetes management are captured. -- **Site**: There is only one active site at any one time, but more than one site might be responsible for the care of a patient over the audit year. ### Reporting tables Tables also track the progress of each child through the audit, as well how they are scoring with regard to their key performance indicators (KPIs). These KPIs are aggregated periodically to feed reports on KPIs at different levels of abstractions (organisational, trust-level or health board, integrated care board, NHS England region and country level) -- **AuditProgress**: Has a one to one relationship with Registration. Stores information on how many fields in each form have been completed. - **KPI**: scores each individual child against the national KPI standards. It stores information on whether a given measure has been passed, failed, has yet to be completed, or whether the child in not eligible to be scored. - **KPIAggregation**: This base model stores results of aggregations of each measure. The base model is subclassed for models representing each geographical level of abstraction. Aggregations are run at scheduled intervals asynchronously and pulled into the dashboards. +- **OrganisationEmployer**: This model serves the middle model between NPDAUser and PaediatricDiabetesUnit. It tracks the number of organisations/PDUs a user is a member of and which is the primary organisation. +- **Submission**: This tracks all csv upload submissions and allocates them to audit years and quarters thereof. Only one active submission at a time can exist. New ones will overwrite the previous ones. +- **Transfer**: This tracks any transfers between paediatric diabetes units. It stores the reason for transferring and the date of transfer. It provide the middle table between Patient and Paediatric Diabetes Unit - **VisitActivity**: Stores user access/visit activity, including number of login attempts and ISP address as well as timestamp -### Link Tables - -There are some many to many relationships. Django normally handles this for you, but the development team chose to implement the link tables in these cases separately to be able to store information about the relationship between the tables. - -- **Site**: The relationships here are complicated since one child may have their diabetes care in different Organisations across a year, though only one centre can be active in the care of a child at any one time. Each Case therefore can have a many to many relationship with the Organisation trust model (since one Organisation can have multiple Cases and one Case can have multiple Organisations). The Site model therefore is a link model between the two. It is used in this way, rather than relying on the Django built-in many-to-many solution, because additional information relating to the organisation can be stored per Case, for example whether the site is actively involved in diabetes care. - ### Lookup Tables -These classes are used as look up tables throughout the NPDA application. They are seeded in the first migrations, either pulling content from the the ```constants``` folder, or from SNOMED CT. Note that the RCPCH NHS Organisation repository maintains the primary source list and these models are kept up to date against this periodically. +The RCPCH NHS Organisation repository maintains the primary source list and these models are kept up to date against this periodically. - **NPDAUser**: The User base model in Django is too basic for the requirements of NPDA and therefore a custom class has been created to describe the different users who either administer or deliver the audit, either on behalf of RCPCH, or the hospital trusts. +### Schema / ERD + +
#### Boundary files and geography extension pack diff --git a/documentation/docs/developer/organisations.md b/documentation/docs/developer/organisations.md index a1574555..e061f20c 100644 --- a/documentation/docs/developer/organisations.md +++ b/documentation/docs/developer/organisations.md @@ -25,10 +25,6 @@ These exist only in Wales and are both equivalent to Trust and ICB in England. O Paediatric Diabetes Units (PSUs) are usually Organisations or Trusts which submit audit data for children. PDUs have unique PZ codes. -### OPENUK Networks - -These are [networks](https://www.rcpch.ac.uk/resources/open-uk-organisation-paediatric-epilepsy-networks-uk) of NHS Health Boards and Trusts that provide care for children with epilepsies, organised regionally and overseen by a UK Working Group. Not all centres are members of an OPEN UK network. There are no boundary shapes to describe each region, but each one has its own identifier, and therefore there is an entity model to hold information on each OPEN UK network referenced by each organisation. - ### NHS England Regions There are 7 of these in England and their model is taken from NHS Digital. Each one has its own boundary code. ICBs fit neatly inside each one. diff --git a/documentation/docs/developer/testing.md b/documentation/docs/developer/testing.md new file mode 100644 index 00000000..eea8c708 --- /dev/null +++ b/documentation/docs/developer/testing.md @@ -0,0 +1,198 @@ +--- +title: Testing +reviewers: Dr Anchit Chandran +--- + +## Getting started + +Run all tests from the root directory: + +```shell +pytest +``` + +## Useful Pytest flags + +Some useful flags to improve the DX of testing. + +### Only re-run failing tests + +```shell +pytest -lf +``` + +### Run a specific test file, e.g. run all tests in only `test_npda_user_model_actions.py` + +```shell +pytest project/npda/tests/permissions_tests/test_npda_user_model_actions.py +``` + +Use `::` notation to run a specific test within a file e.g.: + +```shell +pytest project/npda/tests/permissions_tests/test_npda_user_model_actions.py::test_npda_user_list_view_rcpch_audit_team_can_view_all_users +``` + +### Start a completely clean run, clearing the cache (usually not required) + +```shell +pytest --cache-clear +``` + +### Run tests through keyword expression + +NOTE: this is sometimes slightly slower. + +```shell +pytest -k "MyClass and not method" +``` + +## `pytest.ini` config file + +Explanations and notes on our global `pytest.ini` configuration. + +### Creating new tests + +Test files are found as any `.py` prepended with `test_`: + +```ini +# SET FILENAME FORMATS OF TESTS +python_files = test_*.py +``` + +### Flags set for all `pytest` runs + +```init +# RE USE TEST DB AS DEFAULT +addopts = + --reuse-db + -k "not examples" +``` + +- `--reuse-db` allows a specified starting state testing database to be used between tests. All tests begin with this seeded starting state. The testing db is rolled back to the starting state after each state. + +## Test Database + +Every first test in a file should include the following fixtures to ensure the test database is correctly set up for when a particular test file is run independently: + +```python +@pytest.mark.django_db +def test_npda_user_list_view_users_can_only_see_users_from_their_pdu( + seed_users_fixture, + seed_groups_fixture, + seed_patients_fixture, +): +``` + +### `seed_users_fixture` + +The testing database should include `8 NPDAUsers`. + +At each of the `2` PDUs, GOSH and Alder Hey: + +```py title='seed_users.py' +GOSH_PZ_CODE = "PZ196" +ALDER_HEY_PZ_CODE = "PZ074" +``` + +The following `4` user types are seeded: + +```py title='seed_users.py' +users = [ + test_user_audit_centre_reader_data, + test_user_audit_centre_editor_data, + test_user_audit_centre_coordinator_data, + test_user_rcpch_audit_team_data, +] +``` + +### `seed_groups_fixture` + +Uses the `groups_seeder` to set `Groups` for `NPDAUsers`. + +### `seed_patients_fixture` + +*not yet implemented* + +## Factories + +Factories enable the intuitive and fast creation of testing instances, particularly in cases where multiple related models are required. + +Factories are set up to set sensible defaults wherever possible, which can be overridden through keyword arguments. + +Ideally, in all tests, if a model instance is being created, it should be done through its model Factory. + +### `NPDAUserFactory` + +Example usage below. + +NOTE: we do not need to manually create `OrganisationEmployer`s and `PaediatricsDiabetesUnit` with associations. + +Once an instance of `NPDAUserFactory` is created, the related models will also be created and assigned the relations. These are set using the `organisation_employers` kwarg, with the value being an array of `pz_codes` as strings. + +```py title="seed_users.py" +# GOSH User +new_user_gosh = NPDAUserFactory( + first_name=first_name, + role=user.role, + # Assign flags based on user role + is_active=is_active, + is_staff=is_staff, + is_rcpch_audit_team_member=is_rcpch_audit_team_member, + is_rcpch_staff=is_rcpch_staff, + groups=[user.group_name], + view_preference=( + VIEW_PREFERENCES[2][0] + if user.role == RCPCH_AUDIT_TEAM + else VIEW_PREFERENCES[0][0] + ), + organisation_employers=[GOSH_PZ_CODE], +) + +# Alder hey user +new_user_alder_hey = NPDAUserFactory( + first_name=first_name, + role=user.role, + # Assign flags based on user role + is_active=is_active, + is_staff=is_staff, + is_rcpch_audit_team_member=is_rcpch_audit_team_member, + is_rcpch_staff=is_rcpch_staff, + groups=[user.group_name], + organisation_employers=[ALDER_HEY_PZ_CODE], +) +``` + +### `PatientFactory` + +Once an instance of `PatientFactory` is created, a related `TransferFactory` instance is also generated with the associated `PaediatricsDiabetesUnitFactory` instance. + +### `PaediatricsDiabetesUnitFactory` + +Multiple parent factories require the instantiation of multiple `PaediatricsDiabetesUnitFactory`s. + +As there is a composite unique constraint set on the [`pz_code`, `ods_code`] attributes, we do not want to create multiple instances with duplicate values; instead, we want to mimic the Django ORM's `.get_or_create()` method. + +This is emulated using an override on this factory's `._create()` method: + +```py title="paediatrics_diabetes_unit_factory.py" +@classmethod +def _create(cls, model_class, *args, **kwargs): + """ + Custom create method to handle get_or_create logic for PaediatricsDiabetesUnit. + + Each PDU has a composite unique constraint for pz_code and ods_code. This mimics + a get or create operation every time a new PDUFactory instance is created. + """ + pz_code = kwargs.pop("pz_code", None) + ods_code = kwargs.pop("ods_code", None) + + if pz_code and ods_code: + pdu, created = PaediatricDiabetesUnit.objects.get_or_create( + pz_code=pz_code, + ods_code=ods_code, + ) + return pdu + + return super()._create(model_class, *args, **kwargs) +``` \ No newline at end of file diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index b9b256a9..bac186e1 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -146,3 +146,4 @@ nav: - 'developer/manual-setup.md' - 'developer/organisations.md' - 'developer/users.md' + - 'developer/testing.md' diff --git a/project/constants/pz_codes.py b/project/constants/pz_codes.py index 15181ef8..c4ab2ce9 100644 --- a/project/constants/pz_codes.py +++ b/project/constants/pz_codes.py @@ -1,3 +1,16 @@ +from dataclasses import dataclass +from enum import Enum + +@dataclass +class PZCode: + pz_code: str + name: str + +class DummyPZCodes(Enum): + RCPCH = PZCode(pz_code="PZ999", name="RCPCH") + NOT_FOUND = PZCode(pz_code="PZ998", name="NOT_FOUND") + + pzs = [ "RM102", "RXF05", diff --git a/project/npda/admin.py b/project/npda/admin.py index 4a347118..3a1d450d 100644 --- a/project/npda/admin.py +++ b/project/npda/admin.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.contrib import admin from .models import ( @@ -11,6 +12,8 @@ ) from django.contrib.sessions.models import Session +PaediatricDiabetesUnit = apps.get_model("npda", "PaediatricDiabetesUnit") + @admin.register(OrganisationEmployer) class OrganisationEmployerAdmin(admin.ModelAdmin): @@ -27,6 +30,11 @@ class PatientAdmin(admin.ModelAdmin): search_fields = ("nhs_number_icontains", "pk") +@admin.register(PaediatricDiabetesUnit) +class PaediatricDiabetesUnitAdmin(admin.ModelAdmin): + search_fields = ("pk", "ods_code", "pz_code") + + @admin.register(Transfer) class TransferAdmin(admin.ModelAdmin): search_fields = ("paediatric_diabetes_unit", "patient", "pk") diff --git a/project/npda/forms/npda_user_form.py b/project/npda/forms/npda_user_form.py index 449ed8c8..fede9428 100644 --- a/project/npda/forms/npda_user_form.py +++ b/project/npda/forms/npda_user_form.py @@ -2,6 +2,7 @@ import logging # django imports +from django.apps import apps from django.conf import settings from django.contrib.auth.forms import SetPasswordForm, AuthenticationForm from django import forms @@ -15,7 +16,7 @@ # RCPCH imports from ...constants.styles.form_styles import * -from ..models import NPDAUser, OrganisationEmployer +from ..models import NPDAUser from project.npda.general_functions import ( organisations_adapter, ) @@ -27,14 +28,15 @@ class NPDAUserForm(forms.ModelForm): - use_required_attribute = False add_employer = forms.ChoiceField( choices=[], # Initially empty, will be populated dynamically - required=True, + required=False, widget=forms.Select(attrs={"class": SELECT}), label="Add Employer", ) + organisation_choices = [] + class Meta: model = NPDAUser fields = [ @@ -47,6 +49,7 @@ class Meta: "is_rcpch_audit_team_member", "is_rcpch_staff", "role", + "add_employer", ] widgets = { "title": forms.Select(attrs={"class": SELECT}), @@ -60,9 +63,19 @@ class Meta: ), "is_rcpch_staff": forms.CheckboxInput(attrs={"class": "accent-rcpch_pink"}), "role": forms.Select(attrs={"class": SELECT}), + "add_employer": forms.Select( + attrs={ + "class": SELECT, + "required": False, + "name": "add_employer", + "id": "id_add_employer", + } + ), } def __init__(self, *args, **kwargs) -> None: + PaediatricDiabetesUnit = apps.get_model("npda", "PaediatricDiabetesUnit") + OrganisationEmployer = apps.get_model("npda", "OrganisationEmployer") # get the request object from the kwargs self.request = kwargs.pop("request", None) @@ -73,43 +86,64 @@ def __init__(self, *args, **kwargs) -> None: self.fields["surname"].required = True self.fields["email"].required = True self.fields["role"].required = True - self.fields["add_employer"].required = True - - if self.request: - if ( - self.request.user.is_superuser - or self.request.user.is_rcpch_audit_team_member - or self.request.user.is_rcpch_staff - ): - # populate the add_employer field with organisations that the user is not already affiliated with - self.fields["add_employer"].choices = ( - (item[0], item[1]) - for item in organisations_adapter.get_all_nhs_organisations_affiliated_with_paediatric_diabetes_unit() - if item[0] - not in OrganisationEmployer.objects.filter( - npda_user=self.instance - ).values_list("paediatric_diabetes_unit__ods_code", flat=True) - ) - else: - pz_code = self.request.session.get("pz_code") - sibling_organisations = ( - organisations_adapter.get_single_pdu_from_pz_code( - pz_number=pz_code - ).organisations + self.fields["add_employer"].required = False + + # only if the form is bound - this user is being updated + if self.instance.pk is not None: + if self.request.GET: + # populate the add_employer choices with organisations that the user is not already affiliated with, based on user permissions + # this is only necessary if the form is being instantiated to serve to the user, not on form submission + # populating the choices includes an API call and this takes time, so we only want to do it when necessary + self.organisation_choices = ( + organisations_adapter.organisations_to_populate_select_field( + request=self.request, user_instance=self.instance + ) ) - # filter out organisations that the user is already affiliated with - self.fields["add_employer"].choices = [ - (org.ods_code, org.name) - for org in sibling_organisations - if org.ods_code - not in OrganisationEmployer.objects.filter( - npda_user=self.instance - ).values_list("paediatric_diabetes_unit__ods_code", flat=True) - ] - - # set the default value to the current user's organisation - self.fields["add_employer"].initial = self.request.session.get("ods_code") + # this is a bit of a hack but necessary due to htmx. + # The add_employer field is not part of the model, so we need to remove it from the data dictionary + # in a bound form on form submission, so that the form will validate correctly + # the add employer work flow happens via htmx and not form submission + self.data = self.data.copy() + self.data.pop("add_employer", None) + + else: + # this means the form is unbound and this user is being created - therefore need the organisation_choices to be populated with all organisations + # but only on form instantiation, not on form submission + # populating the choices includes an API call and this takes time, so we only want to do it when necessary + if self.request.GET: + self.organisation_choices = ( + organisations_adapter.organisations_to_populate_select_field( + request=self.request, user_instance=None + ) + ) + else: + # this is a POST request - the form is being submitted + # we need to remove the selection from the add_employer field and use this to create the employer relationship + # but only if the form is valid + ods_code = self.data.get("add_employer") + # remove the add_employer field from the data dictionary + # as it is immutable we need to copy it first + self.data = self.data.copy() + self.data.pop("add_employer", None) + + if ods_code and self.is_valid(): + # the form is filled out correctly and the user has selected an employer + # we need to create the employer relationship - since this is a new user, + # this organisation will be the user's primary employer + pdu_object = organisations_adapter.get_single_pdu_from_ods_code( + ods_code + ) + pdu, created = PaediatricDiabetesUnit.objects.update_or_create( + ods_code=ods_code, pz_code=pdu_object["pz_code"] + ) + npda_user = self.instance + npda_user.save() + OrganisationEmployer.objects.update_or_create( + npda_user=npda_user, + paediatric_diabetes_unit=pdu, + is_primary_employer=True, + ) class NPDAUpdatePasswordForm(SetPasswordForm): diff --git a/project/npda/forms/patient_form.py b/project/npda/forms/patient_form.py index f79d8b29..bb8a7c81 100644 --- a/project/npda/forms/patient_form.py +++ b/project/npda/forms/patient_form.py @@ -1,10 +1,18 @@ -from django import forms +# python imports +from datetime import date + +# django imports +from django.apps import apps from django.core.exceptions import ValidationError +from django import forms + +# project imports from ..models import Patient from ...constants.styles.form_styles import * from ..general_functions import ( validate_postcode, - gp_practice_for_postcode + gp_practice_for_postcode, + retrieve_quarter_for_date, ) @@ -14,6 +22,18 @@ class DateInput(forms.DateInput): class PatientForm(forms.ModelForm): + quarter = forms.ChoiceField( + choices=[ + (1, 1), + (2, 2), + (3, 3), + (4, 4), + ], # Initially empty, will be populated dynamically + required=True, + widget=forms.Select(attrs={"class": SELECT}), + label="Audit Year Quarter", + ) + class Meta: model = Patient fields = [ @@ -27,6 +47,7 @@ class Meta: "death_date", "gp_practice_ods_code", "gp_practice_postcode", + "quarter", ] widgets = { "nhs_number": forms.TextInput( @@ -41,8 +62,23 @@ class Meta: "death_date": DateInput(), "gp_practice_ods_code": forms.TextInput(attrs={"class": TEXT_INPUT}), "gp_practice_postcode": forms.TextInput(attrs={"class": TEXT_INPUT}), + "quarter": forms.Select(), } + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + if self.instance.pk: + # this is a bound form, so we need to get the quarter from the submission related to the instance + Submission = apps.get_model("npda", "Submission") + self.initial["quarter"] = Submission.objects.get( + patients=self.instance + ).quarter + else: + # this is an unbound form, so we need to set the quarter to current quarter + self.initial["quarter"] = retrieve_quarter_for_date( + date_instance=date.today() + ) + def clean_postcode(self): if not self.cleaned_data["postcode"]: raise ValidationError("This field is required") @@ -97,27 +133,27 @@ def clean(self): ] } ) - + if gp_practice_ods_code is None and gp_practice_postcode is None: - raise ValidationError({ - "gp_practice_ods_code": [ - "GP Practice ODS code and GP Practice postcode cannot both be empty. At least one must be supplied." - ] - }) - + raise ValidationError( + { + "gp_practice_ods_code": [ + "GP Practice ODS code and GP Practice postcode cannot both be empty. At least one must be supplied." + ] + } + ) + if not gp_practice_ods_code and gp_practice_postcode: try: ods_code = gp_practice_for_postcode(gp_practice_postcode) if not ods_code: - raise ValidationError("Could not find GP practice with that postcode") + raise ValidationError( + "Could not find GP practice with that postcode" + ) else: cleaned_data["gp_practice_ods_code"] = ods_code except Exception as error: - raise ValidationError({ - "gp_practice_postcode": [ - error - ] - }) + raise ValidationError({"gp_practice_postcode": [error]}) return cleaned_data diff --git a/project/npda/general_functions/__init__.py b/project/npda/general_functions/__init__.py index ba3bb35c..7ad8fc45 100644 --- a/project/npda/general_functions/__init__.py +++ b/project/npda/general_functions/__init__.py @@ -4,6 +4,7 @@ from .group_for_group import * from .index_multiple_deprivation import * from .nhs_ods_requests import * +from .organisations_adapter import * from .pdus import * from .rcpch_nhs_organisations import * from .time_elapsed import * @@ -12,4 +13,4 @@ from .visit_categories import * from .model_utils import * from .session import * -from .view_preference import * \ No newline at end of file +from .view_preference import * diff --git a/project/npda/general_functions/model_utils.py b/project/npda/general_functions/model_utils.py index b2f56922..37442c13 100644 --- a/project/npda/general_functions/model_utils.py +++ b/project/npda/general_functions/model_utils.py @@ -30,4 +30,4 @@ def print_instance_field_attrs(instance): except AttributeError: fields_dict[field_name] = "