diff --git a/config/settings/base.py b/config/settings/base.py index 76bda7ef..31061968 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -73,6 +73,7 @@ 'bootstrap3', 'simple_history', 'adminsortable', + 'phonenumber_field', ] LOCAL_APPS = [ @@ -318,6 +319,11 @@ # ----------------------------------------------------------------------------- CRISPY_TEMPLATE_PACK = 'bootstrap3' + +# django-phonenumber-field +PHONENUMBER_DB_FORMAT = 'E164' +PHONENUMBER_DEFAULT_REGION = 'US' + # osler # ----------------------------------------------------------------------------- diff --git a/local.yml b/local.yml index ec5f1f79..0b8ad8a6 100644 --- a/local.yml +++ b/local.yml @@ -14,7 +14,7 @@ services: depends_on: - postgres volumes: - - .:/app + - .:/app:delegated env_file: - ./.envs/.local/.django - ./.envs/.local/.postgres @@ -31,8 +31,8 @@ services: image: osler_production_postgres container_name: postgres volumes: - - local_postgres_data:/var/lib/postgresql/data - - local_postgres_data_backups:/backups + - local_postgres_data:/var/lib/postgresql/data:cached + - local_postgres_data_backups:/backups:cached env_file: - ./.envs/.local/.postgres diff --git a/osler/core/forms.py b/osler/core/forms.py index 9aaf8cb5..f097c1bd 100644 --- a/osler/core/forms.py +++ b/osler/core/forms.py @@ -1,15 +1,18 @@ '''Forms for the Oser core components.''' - from django.forms import ( Form, CharField, ModelForm, EmailField, CheckboxSelectMultiple, - ModelMultipleChoiceField, CheckboxInput, TextInput) -from django.contrib.auth.forms import AuthenticationForm + ModelMultipleChoiceField, CheckboxInput, TextInput +) +from django.contrib.auth.forms import AuthenticationForm from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.models import Group, Permission +from phonenumber_field.formfields import PhoneNumberField +from phonenumber_field.widgets import PhoneNumberInternationalFallbackWidget + from crispy_forms.helper import FormHelper from crispy_forms.layout import Submit, Field, Layout, Row, Column from crispy_forms.bootstrap import InlineCheckboxes @@ -23,8 +26,6 @@ class CustomCheckbox(Field): template = 'core/custom_checkbox.html' -# pylint: disable=I0011,E1305 - class DuplicatePatientForm(Form): first_name = CharField(label=_('First Name')) @@ -37,10 +38,32 @@ def __init__(self, *args, **kwargs): self.helper.add_input(Submit('submit', _('Submit'))) +class PatientPhoneNumberForm(ModelForm): + + class Meta: + model = models.PatientPhoneNumber + fields = ['phone_number', 'description', 'patient'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # self.fields['patient'].widget = HiddenInput() + + self.helper = FormHelper(self) + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-lg-2' + self.helper.field_class = 'col-lg-4' + self.helper.add_input(Submit('submit', 'Submit')) + + class PatientForm(ModelForm): class Meta(object): model = models.Patient - exclude = ['demographics'] + exclude = ( + ['demographics', 'phone'] + + [f'alternate_phone_{i}' for i in range(1,5)] + + [f'alternate_phone_{i}_owner' for i in range(1,5)] + ) if not settings.OSLER_DISPLAY_CASE_MANAGERS: exclude.append('case_managers') widgets = { @@ -56,6 +79,16 @@ class Meta(object): .order_by("last_name") ) + phone = PhoneNumberField( + widget=PhoneNumberInternationalFallbackWidget, + required=False + ) + + description = CharField( + label='Phone Label', + required=False + ) + def __init__(self, *args, **kwargs): super(PatientForm, self).__init__(*args, **kwargs) @@ -64,32 +97,22 @@ def __init__(self, *args, **kwargs): self.helper.form_class = 'form-horizontal' self.helper.label_class = 'col-lg-2' self.helper.field_class = 'col-lg-8' - self.fields['phone'].widget.attrs['autofocus'] = True + self.fields['middle_name'].widget.attrs['autofocus'] = True self.helper['languages'].wrap(InlineCheckboxes) self.helper['ethnicities'].wrap(InlineCheckboxes) self.helper.add_input(Submit('submit', _('Submit'))) self.fields['address'].widget.attrs = {'placeholder': settings.OSLER_DEFAULT_ADDRESS} + def clean(self): cleaned_data = super(ModelForm, self).clean() - N_ALTS = 5 - - alt_phones = ["alternate_phone_" + str(i) for i in range(1, N_ALTS)] - alt_owners = [phone + "_owner" for phone in alt_phones] - - for (alt_phone, alt_owner) in zip(alt_phones, alt_owners): - - if cleaned_data.get(alt_owner) and not cleaned_data.get(alt_phone): - self.add_error( - alt_phone, - _("An Alternate Phone is required if a Alternate Phone Owner is specified")) - - if cleaned_data.get(alt_phone) and not cleaned_data.get(alt_owner): - self.add_error( - alt_owner, - _("An Alternate Phone Owner is required if a Alternate Phone is specified")) + if cleaned_data.get('description') and not cleaned_data.get('phone'): + self.add_error( + description, + _("Phone number is required if a description is provided.") + ) class AbstractActionItemForm(ModelForm): @@ -135,6 +158,7 @@ class Meta(object): 'gender', 'groups' ] + widgets = {'phone': PhoneNumberInternationalFallbackWidget} def __init__(self, *args, **kwargs): super(UserInitForm, self).__init__(*args, **kwargs) diff --git a/osler/core/migrations/0009_patientphonenumber.py b/osler/core/migrations/0009_patientphonenumber.py new file mode 100644 index 00000000..7c1d1fa0 --- /dev/null +++ b/osler/core/migrations/0009_patientphonenumber.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.2 on 2021-01-17 04:58 + +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_verbose_name_20210118_1256'), + ] + + operations = [ + migrations.CreateModel( + name='PatientPhoneNumber', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)), + ('description', models.CharField(blank=True, max_length=256)), + ('patient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phone_number_set', to='core.patient')), + ], + options={ + 'unique_together': {('patient', 'description'), ('patient', 'phone_number')}, + }, + ), + ] diff --git a/osler/core/models.py b/osler/core/models.py index 6b45748e..42d8cabe 100644 --- a/osler/core/models.py +++ b/osler/core/models.py @@ -12,6 +12,7 @@ from simple_history.models import HistoricalRecords from adminsortable.models import SortableMixin +from phonenumber_field.modelfields import PhoneNumberField from osler.core import validators from osler.core import utils @@ -39,6 +40,35 @@ def short_name(self): return self.name[0] +class PatientPhoneNumber(models.Model): + """A phone number for a patient. + + Tracks a "description" for each phone number, which is a free text + field intended to be used for something like "cell" or "home". + """ + + class Meta: + unique_together = ( + ('patient', 'description'), + ('patient', 'phone_number'), + ) + + patient = models.ForeignKey( + 'patient', + related_name='phone_number_set', + on_delete=models.CASCADE + ) + + # phone number field from django-phonenumber-field + phone_number = PhoneNumberField() + + description = models.CharField( + max_length=256, blank=True) + + def __str__(self): + return "%s (%s)" % (self.phone_number, self.description) + + class ContactMethod(models.Model): '''Simple text-contiaining class for storing the method of contacting a patient for followup followed up with (i.e. phone, email, etc.)''' @@ -358,13 +388,11 @@ def last_seen(self): return now().date() def all_phones(self): - '''Returns a list of tuples of the form (phone, owner) of all the - phones associated with this patient.''' + """Returns a list of tuples of the form (phone, owner) of all the + phones associated with this patient.""" - phones = [(self.phone, '')] - phones.extend([(getattr(self, 'alternate_phone_'+str(i)), - getattr(self, 'alternate_phone_'+str(i)+'_owner')) - for i in range(1, 5)]) + phones = [(p.phone_number, p.description) + for p in self.phone_number_set.all()] return phones diff --git a/osler/core/tests/factories.py b/osler/core/tests/factories.py index 1df1d238..6e2befb6 100644 --- a/osler/core/tests/factories.py +++ b/osler/core/tests/factories.py @@ -42,7 +42,7 @@ class LanguageFactory(DjangoModelFactory): class Meta: model = models.Language - name = factory.Iterator(["English", "German", "Spanish", "Klingon"]) + name = factory.Faker('language_name') class ContactMethodFactory(DjangoModelFactory): @@ -84,8 +84,8 @@ class Meta: first_name = "Juggie" last_name = "Brodeltein" middle_name = "Bayer" - phone = '+49 178 236 5288', - languages = factory.SubFactory(LanguageFactory), + phone = factory.Faker('phone_number') + languages = factory.SubFactory(LanguageFactory) gender = factory.SubFactory(GenderFactory) address = 'Schulstrasse 9' city = 'Munich' @@ -132,6 +132,20 @@ def case_managers(self, create, extracted, **kwargs): self.case_managers.add(manager) +class PatientPhoneNumberFactory(DjangoModelFactory): + + class Meta: + model = models.PatientPhoneNumber + + # frustratingly, PhoneNumberField doesn't support very varied input + # formats, like those produced by faker. We should probably fix this. + # phone_number = factory.Faker('phone_number') + phone_number = '425 243 9115' + + patient = factory.SubFactory(PatientFactory) + description = factory.Faker('text', max_nb_chars=15) + + class DocumentTypeFactory(DjangoModelFactory): class Meta: diff --git a/osler/core/tests/test_forms.py b/osler/core/tests/test_forms.py index f729cc6b..b308bb91 100644 --- a/osler/core/tests/test_forms.py +++ b/osler/core/tests/test_forms.py @@ -57,6 +57,14 @@ def setUp(self): self.valid_pt_dict = factory.build( dict, FACTORY_CLASS=factories.PatientFactory) + print(self.valid_pt_dict) + + def phone_description_only_when_phone_provided(self): + + del self.valid_pt_dict['phone'] + form = forms.PatientForm(data=self.valid_pt_dict) + assert len(form['description'].errors) > 0 + def test_form_casemanager_options(self): """PatientForm only offers valid case managers as options. @@ -105,26 +113,3 @@ def test_form_casemanager_options(self): form_data['case_managers'] = [pvds[2].pk] form = forms.PatientForm(data=form_data) assert len(form['case_managers'].errors) == 0 - - def test_missing_alt_phone(self): - '''Missing the alternative phone w/o alt phone owner should fail.''' - form_data = self.valid_pt_dict - - form_data['alternate_phone_1_owner'] = "Jamal" - # omit 'alternate_phone', should get an error - - form = forms.PatientForm(data=form_data) - - # and expect an error to be on the empty altphone field - self.assertNotEqual(len(form['alternate_phone_1'].errors), 0) - - def test_missing_alt_phone_owner(self): - '''Missing the alt phone owner w/o alt phone should fail.''' - form_data = self.valid_pt_dict - - form_data['alternate_phone_1'] = "4258612322" - # omit 'alternate_phone', should get an error - - form = forms.PatientForm(data=form_data) - # we expect errors on the empty alternate_phone_1_owner field - self.assertNotEqual(len(form['alternate_phone_1_owner'].errors), 0) diff --git a/osler/core/tests/test_views.py b/osler/core/tests/test_views.py index 76c78a6d..c7fb55f5 100644 --- a/osler/core/tests/test_views.py +++ b/osler/core/tests/test_views.py @@ -1,6 +1,7 @@ import datetime import json import os +import factory from django.test import TestCase from django.urls import reverse @@ -315,6 +316,8 @@ def test_can_intake_pt(self): new_pt = models.Patient.objects.last() for param in submitted_pt: + # these are fk relationships, shouldn't be checked this way + if param == 'phone' or param == 'description': continue try: self.assertEqual(str(submitted_pt[param]), str(getattr(new_pt, param))) @@ -323,9 +326,38 @@ def test_can_intake_pt(self): getattr(new_pt, param).all()): self.assertEqual(x, y) + assert new_pt.phone_number_set.count() == 1 + assert new_pt.phone_number_set.first().phone_number != '' + assert new_pt.phone_number_set.first().description == '' + # new patients should be marked as inactive assert not new_pt.get_status().is_active + def test_create_phone(self): + + from phonenumber_field.modelfields import PhoneNumber + + pt = factories.PatientFactory() + pt.save() + + pn_data = factory.build( + dict, FACTORY_CLASS=factories.PatientPhoneNumberFactory) + pn_data['patient'] = pt.pk + + url = reverse('core:patient-add-phone', + kwargs={'pk': pt.pk}) + response = self.client.post(url, pn_data) + + assert response.status_code == 302 + assert reverse('core:patient-detail', args=(pt.id,)) in response.url + assert models.PatientPhoneNumber.objects.count() == 1 + + new_pn = models.PatientPhoneNumber.objects.first() + + assert new_pn.patient == pt + assert new_pn.description == pn_data['description'] + assert new_pn.phone_number == pn_data['phone_number'] + class ActionItemTest(TestCase): diff --git a/osler/core/urls.py b/osler/core/urls.py index 57ba5ac1..94b5a6f0 100644 --- a/osler/core/urls.py +++ b/osler/core/urls.py @@ -42,6 +42,10 @@ r'^patient/activate_home/(?P[0-9]+)$', views.patient_activate_home, name='patient-activate-home'), + re_path( + r'^patient/(?P[0-9]+)/add-phone$', + views.PhoneNumberCreate.as_view(), + name='patient-add-phone'), # USER MANAGEMENT re_path( diff --git a/osler/core/views.py b/osler/core/views.py index cc78240b..fae866e0 100644 --- a/osler/core/views.py +++ b/osler/core/views.py @@ -2,15 +2,16 @@ import collections import datetime -from django.conf import settings from django.apps import apps +from django.conf import settings +from django.db.models import Prefetch +from django.forms import modelformset_factory from django.shortcuts import get_object_or_404, render from django.http import HttpResponseRedirect, HttpResponseServerError from django.views.generic.edit import FormView, UpdateView from django.views.generic.list import ListView from django.urls import reverse from django.core.exceptions import ImproperlyConfigured -from django.db.models import Prefetch from django.utils.http import url_has_allowed_host_and_scheme from django.utils.timezone import now @@ -223,6 +224,19 @@ class PatientCreate(FormView): def form_valid(self, form): pt = form.save() pt.save() + + if form.cleaned_data['phone']: + if form.cleaned_data['description']: + kwargs = {'description': form.cleaned_data['description']} + else: + kwargs = {} + + core_models.PatientPhoneNumber.objects.create( + patient=pt, + phone_number=form.cleaned_data['phone'], + **kwargs + ) + return HttpResponseRedirect(reverse("demographics-create", args=(pt.id,))) @@ -230,6 +244,10 @@ def get_initial(self): initial = super(PatientCreate, self).get_initial() initial.update(utils.get_names_from_url_query_dict(self.request)) + # these have to be populated here rather than as defaults in the + # model or in the default parameters of the form fields because + # that code gets executed only once, which means it is not responsive + # to changes in the settings. initial['city'] = settings.OSLER_DEFAULT_CITY initial['state'] = settings.OSLER_DEFAULT_STATE initial['zip_code'] = settings.OSLER_DEFAULT_ZIP_CODE @@ -269,6 +287,37 @@ def form_valid(self, form): args=(pt.id,))) +class PhoneNumberCreate(FormView): + """Create a new phone number for a patient""" + template_name = 'core/form_submission.html' + form_class = forms.PatientPhoneNumberForm + + def get_initial(self): + initial = super().get_initial() + initial['patient'] = self.kwargs['pk'] + return initial + + def get_context_data(self, *args, **kwargs): + + context = super().get_context_data(*args, **kwargs) + context['note_type'] = 'Phone Number' + + if 'pk' in self.kwargs: + context['patient'] = core_models.Patient.objects. \ + get(pk=self.kwargs['pk']) + + return context + + def form_valid(self, form): + form.save() + return super().form_valid(form) + + def get_success_url(self): + pt = get_object_or_404(core_models.Patient, pk=self.kwargs['pk']) + + return reverse("core:patient-detail", args=(pt.id, )) + + def choose_role(request): RADIO_CHOICE_KEY = 'radio-roles' diff --git a/osler/templates/core/patient_detail.html b/osler/templates/core/patient_detail.html index 1c40469e..55ed2421 100644 --- a/osler/templates/core/patient_detail.html +++ b/osler/templates/core/patient_detail.html @@ -121,13 +121,19 @@

  Demographic Information

{% trans 'Phone Number' %} {% for phone, owner in patient.all_phones %} - {% if phone or owner %} + {% if phone or owner %} + + {{ owner | default:"Primary" }} + {{ phone }} + + {% endif %} + {% endfor %} - {{ owner | default:"Primary" }} - {{ phone }} + + + + - {% endif %} - {% endfor %} diff --git a/osler/users/migrations/0005_user_model_phone_phase_1.py b/osler/users/migrations/0005_user_model_phone_phase_1.py new file mode 100644 index 00000000..e92128e5 --- /dev/null +++ b/osler/users/migrations/0005_user_model_phone_phase_1.py @@ -0,0 +1,39 @@ +# Generated by Django 3.1.2 on 2021-01-17 05:00 + +from django.db import migrations +from phonenumber_field.modelfields import PhoneNumberField + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_auto_20201114_1258'), + ] + + operations = [ + # first, rename "phone" to "phone_old". We will retain this data + # and use the shell to copy the data into the new "phone" parameter + # which will be more structured + migrations.RenameField( + model_name='historicaluser', + old_name='phone', + new_name='phone_old', + ), + migrations.RenameField( + model_name='user', + old_name='phone', + new_name='phone_old', + ), + + # now create a new PhoneNumberField named phone that's just empty. + migrations.AddField( + model_name='historicaluser', + name='phone', + field=PhoneNumberField(blank=True, max_length=128, null=True, region=None), + ), + migrations.AddField( + model_name='user', + name='phone', + field=PhoneNumberField(blank=True, max_length=128, null=True, region=None), + ), + ] diff --git a/osler/users/models.py b/osler/users/models.py index 775375e7..5414b118 100644 --- a/osler/users/models.py +++ b/osler/users/models.py @@ -3,6 +3,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from phonenumber_field.modelfields import PhoneNumberField + from simple_history.models import HistoricalRecords from osler.core import validators @@ -16,13 +18,14 @@ class Meta: # more inclusive of name patterns around the world name = models.CharField("Preferred name", blank=True, max_length=255) - phone = models.CharField(max_length=40, null=True, blank=True) + phone = PhoneNumberField(null=True, blank=True) + phone_old = models.CharField(max_length=40, null=True, blank=True) languages = models.ManyToManyField( "core.Language", help_text="Specify here languages that are spoken at a " - "level sufficient to be used for medical " - "communication.") + "level sufficient to be used for medical " + "communication.") gender = models.ForeignKey("core.Gender", null=True, on_delete=models.PROTECT) history = HistoricalRecords() diff --git a/requirements/base.txt b/requirements/base.txt index 354fba96..328e70ef 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -16,7 +16,8 @@ django-crispy-forms==1.9.2 # https://github.com/django-crispy-forms/django-cris django-redis==4.12.1 # https://github.com/jazzband/django-redis django-bootstrap3==14.2.0 django-simple-history==2.12.0 -django-admin-sortable==2.2.3 #https://github.com/alsoicode/django-admin-sortable +django-admin-sortable==2.2.3 # https://github.com/alsoicode/django-admin-sortable +django-phonenumber-field[phonenumbers]==5.0.0 # https://github.com/stefanfoulis/django-phonenumber-field # Django REST Framework djangorestframework==3.12.1 # https://github.com/encode/django-rest-framework