Skip to content
Snippets Groups Projects
Commit 96e47449 authored by Piotr Gawron's avatar Piotr Gawron
Browse files

Merge branch 'feature/improve_management_of_visits_from_subject_list' into 'master'

Feature/improve management of visits from subject list

Closes #273

See merge request NCER-PD/scheduling-system!188
parents 3c954f6d 8b74f5a6
No related branches found
No related tags found
1 merge request!188Feature/improve management of visits from subject list
Pipeline #8111 passed
Showing
with 273 additions and 42 deletions
import logging
from django.urls import reverse
from django.db.models import Count, Case, When, Min, Max
from django.db.models import Q
from django.http import JsonResponse
......@@ -322,7 +323,6 @@ def types(request):
"types": data
})
def serialize_subject(study_subject):
location = location_to_str(study_subject.default_location)
flying_team = flying_team_to_str(study_subject.flying_team)
......@@ -353,10 +353,19 @@ def serialize_subject(study_subject):
status = "SHOULD_BE_IN_PROGRESS"
else:
status = "UPCOMING"
appointment_types = ['{} ({})'.format(at.code, at.description) for at in visit.appointment_types.all()]
if len(appointment_types) == 0:
appointment_types = ['No appointment types set.']
serialized_visits.append({
"status": status,
"appointment_types": appointment_types,
"edit_visit_url": reverse('web.views.visit_details', args=(visit.id,)),
"add_appointment_url": reverse('web.views.appointment_add', args=(visit.id,)),
"datetime_start": serialize_date(visit.datetime_begin),
"datetime_end": serialize_date(visit.datetime_end),
"is_finished": visit.is_finished
})
contact_reminder = serialize_datetime(study_subject.datetime_contact_reminder)
contact_attempts = ContactAttempt.objects.filter(subject=study_subject).order_by("-datetime_when")
......
......@@ -60,6 +60,14 @@ class VisitDetailForm(ModelForm):
appointment_types = forms.ModelMultipleChoiceField(required=False, widget=forms.CheckboxSelectMultiple,
queryset=AppointmentType.objects.all())
def __init__(self, *args, **kwargs):
super(VisitDetailForm, self).__init__(*args, **kwargs)
instance = getattr(self, 'instance', None)
if instance.is_finished: #set form as readonly
for key in self.fields.keys():
self.fields[key].widget.attrs['readonly'] = True
class Meta:
model = Visit
exclude = ['is_finished', 'visit_number']
......
......@@ -3,6 +3,9 @@ import logging
from django.forms import ModelForm, ValidationError
from web.models import Study, StudyNotificationParameters, StudyColumns, StudySubject
import datetime
from dateutil.relativedelta import relativedelta
logger = logging.getLogger(__name__)
......@@ -11,17 +14,35 @@ class StudyEditForm(ModelForm):
def __init__(self, *args, **kwargs):
super(StudyEditForm, self).__init__(*args, **kwargs)
def clean_nd_number_study_subject_regex(self):
nd_number_study_subject_regex = self.cleaned_data.get(
'nd_number_study_subject_regex')
def clean(self):
cleaned_data = super(StudyEditForm, self).clean()
#check regex
nd_number_study_subject_regex = cleaned_data.get('nd_number_study_subject_regex')
instance = getattr(self, 'instance', None)
if StudySubject.check_nd_number_regex(nd_number_study_subject_regex, instance) == False:
raise ValidationError(
'Please enter a valid nd_number_study_subject_regex regex.')
return nd_number_study_subject_regex
if nd_number_study_subject_regex is None or StudySubject.check_nd_number_regex(nd_number_study_subject_regex, instance) == False:
self.add_error('nd_number_study_subject_regex', 'Please enter a valid nd_number_study_subject_regex regex.')
#check default_visit_duration_in_months
visit_duration_in_months = cleaned_data.get('default_visit_duration_in_months')
control_follow_up = cleaned_data.get('default_delta_time_for_control_follow_up')
patient_follow_up = cleaned_data.get('default_delta_time_for_patient_follow_up')
units = cleaned_data.get('default_delta_time_for_follow_up_units')
if None not in [visit_duration_in_months, control_follow_up, patient_follow_up, units]:
t = datetime.datetime.today()
visit_duration = relativedelta(months=int(visit_duration_in_months))
control_delta = relativedelta(**{units: control_follow_up})
patient_delta = relativedelta(**{units: patient_follow_up})
#relative time delta has no __cmp__ method, so we add them to a datetime
min_delta_time = min((t + control_delta), (t + patient_delta))
if (t+visit_duration) > min_delta_time:
self.add_error('default_visit_duration_in_months', 'Please enter a valid "duration of the visits". It must be shorter than the time difference between patient and control visits.')
return cleaned_data
class Meta:
model = Study
......
......@@ -4,6 +4,7 @@ from django.core.management.base import BaseCommand
from ...models import Appointment, Location, AppointmentType, AppointmentTypeLink
def get_easter_monday(easter_sunday):
return next_weekday(easter_sunday, 0)
......
# -*- coding: utf-8 -*-
# Generated by Django 1.11.5 on 2018-11-13 10:05
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('web', '0131_study_default_voucher_expiration_in_months'),
]
operations = [
migrations.AddField(
model_name='study',
name='default_visit_duration_in_months',
field=models.IntegerField(default=3, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Default duration of the visits in months'),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.5 on 2018-11-13 15:50
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('web', '0132_study_default_visit_duration_in_months'),
]
operations = [
migrations.AddField(
model_name='study',
name='default_delta_time_for_control_follow_up',
field=models.IntegerField(default=4, help_text=b'Time difference between visits used to automatically create follow up visits', validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Time difference between control visits'),
),
migrations.AddField(
model_name='study',
name='default_delta_time_for_follow_up_units',
field=models.CharField(choices=[(b'days', b'Days'), (b'years', b'Years')], default=b'years', help_text=b'Units for the number of days between visits for both patients and controls', max_length=10, verbose_name=b'Units for the follow up incrementals'),
),
migrations.AddField(
model_name='study',
name='default_delta_time_for_patient_follow_up',
field=models.IntegerField(default=1, help_text=b'Time difference between visits used to automatically create follow up visits', validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Time difference between patient visits'),
),
migrations.AlterField(
model_name='study',
name='default_visit_duration_in_months',
field=models.IntegerField(default=6, help_text=b'Duration of the visit, this is, the time interval, in months, when the appointments may take place', validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Duration of the visits in months'),
),
migrations.AlterField(
model_name='study',
name='default_voucher_expiration_in_months',
field=models.IntegerField(default=3, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Duration of the vouchers in months'),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.5 on 2018-12-11 15:42
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('web', '0132_workerstudyrole_permissions'),
('web', '0133_auto_20181113_1550'),
]
operations = [
]
......@@ -6,6 +6,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator
import re
FOLLOW_UP_INCREMENT_IN_YEARS = 'years'
FOLLOW_UP_INCREMENT_IN_DAYS = 'days'
FOLLOW_UP_INCREMENT_UNIT_CHOICE = {
FOLLOW_UP_INCREMENT_IN_YEARS: 'Years',
FOLLOW_UP_INCREMENT_IN_DAYS: 'Days'
}
class Study(models.Model):
......@@ -39,11 +45,40 @@ class Study(models.Model):
)
default_voucher_expiration_in_months = models.IntegerField(
verbose_name='Default duration of the vouchers in months',
verbose_name='Duration of the vouchers in months',
default=3,
validators=[MinValueValidator(1)]
)
default_visit_duration_in_months = models.IntegerField(
verbose_name='Duration of the visits in months',
help_text='Duration of the visit, this is, the time interval, in months, when the appointments may take place',
default=6,
validators=[MinValueValidator(1)]
)
default_delta_time_for_patient_follow_up = models.IntegerField(
verbose_name='Time difference between patient visits',
help_text='Time difference between visits used to automatically create follow up visits',
default=1,
validators=[MinValueValidator(1)]
)
default_delta_time_for_control_follow_up = models.IntegerField(
verbose_name='Time difference between control visits',
help_text='Time difference between visits used to automatically create follow up visits',
default=4,
validators=[MinValueValidator(1)]
)
default_delta_time_for_follow_up_units = models.CharField(max_length=10,
choices=FOLLOW_UP_INCREMENT_UNIT_CHOICE.items(),
verbose_name='Units for the follow up incrementals',
help_text='Units for the number of days between visits for both patients and controls',
default=FOLLOW_UP_INCREMENT_IN_YEARS,
blank=False
)
def check_nd_number(self, nd_number):
regex = re.compile(self.nd_number_study_subject_regex)
return regex.match(nd_number) is not None
......
# coding=utf-8
import datetime
from dateutil.relativedelta import relativedelta
from django.db import models
from django.db.models.signals import post_save
......@@ -7,7 +8,10 @@ from django.dispatch import receiver
from django.db import transaction
from web.models.constants import BOOL_CHOICES, SUBJECT_TYPE_CHOICES_CONTROL
from web.models import Study
import logging
logger = logging.getLogger(__name__)
class Visit(models.Model):
......@@ -69,18 +73,21 @@ class Visit(models.Model):
follow_up_number = Visit.objects.filter(
subject=self.subject).count() + 1
delta_days = 365
study = self.subject.study
if self.subject.type == SUBJECT_TYPE_CHOICES_CONTROL:
delta_days = 365 * 3 + 366
args = {study.default_delta_time_for_follow_up_units: study.default_delta_time_for_control_follow_up}
else:
args = {study.default_delta_time_for_follow_up_units: study.default_delta_time_for_patient_follow_up}
time_to_next_visit = relativedelta(**args) * (follow_up_number - 1) #calculated from first visit
time_to_next_visit = datetime.timedelta(
days=delta_days * (follow_up_number - 1))
logger.warn('new visit: {} {} {}'.format(args, relativedelta(**args), time_to_next_visit))
Visit.objects.create(
subject=self.subject,
datetime_begin=visit_started + time_to_next_visit,
datetime_end=visit_started + time_to_next_visit +
datetime.timedelta(days=93)
datetime_end=visit_started + time_to_next_visit + relativedelta(months=study.default_visit_duration_in_months)
)
@receiver(post_save, sender=Visit)
......
......@@ -185,27 +185,56 @@ function create_visit_row(visit) {
var text = "---";
if (visit !== undefined && visit !== null) {
if (visit.status === "DONE") {
color = "green";
text = "OK";
color = "#6bff5a";
text = `<span title="Visit is finished, all appointments done.">OK</span>`;
} else if (visit.status === "MISSED") {
color = "pink";
text = "MISSED";
text = `<span title="Visit is finished, some appointments were not carried out.">MISSED</span>`;
} else if (visit.status === "UPCOMING") {
color = "#00ffff";
text = "UPCOMING";
text = `<span title="Visit has not started yet.">UPCOMING</span>`;
} else if (visit.status === "EXCEEDED") {
color = "orange";
text = "EXCEEDED";
text = `<span title="Visit is over and no appointments were set.">EXCEEDED</span>`;
} else if (visit.status === "SHOULD_BE_IN_PROGRESS") {
color = "orange";
text = "IN PROGRESS (NO APPOINTMENTS)";
text = `<span title="Visit has started but no appointments have been set yet.">IN PROGRESS (NO APPOINTMENTS)</span>`;
} else if (visit.status === "IN_PROGRESS") {
color = "lightgreen";
text = "IN PROGRESS";
text = `<span title="Appointments are taking place.">IN PROGRESS</span>`;
}
text += "<br/>" + visit.datetime_start + " - " + visit.datetime_end;
var start_date = moment(visit.datetime_start);
var end_date = moment(visit.datetime_end);
text += `<br/>
<span data-html="true" title="From: ${start_date.format('ddd Do MMMM YYYY')} </br> To: ${end_date.format('ddd Do MMMM YYYY')}">
From: ${start_date.format('D MMM. YYYY')}
</span>
<br/>
<span data-html="true" title="From: ${start_date.format('ddd Do MMMM YYYY')} </br> To: ${end_date.format('ddd Do MMMM YYYY')}">
To: ${end_date.format('D MMM. YYYY')}
</span>`
text += `<br/><span data-html="true" title="Visit details<br/>Appointment Types:<br/><div class='appointment_type_list'>${visit.appointment_types.join('<br/>')}</div>">
<a href="${visit.edit_visit_url}"><i class="fa fa-list" aria-hidden="true"></i></a>
</span>`;
if(!visit.is_finished){
text += `<span title="Add new appointment to visit"><a href="${visit.add_appointment_url}"><i class="fa fa-plus-square-o" aria-hidden="true"></i></a></span>`;
}else{
text += `<span title="Visit is marked as finished" ><i class="fa fa-check-circle" aria-hidden="true"></i></span>`;
}
}
return "<div style='background-color:" + color + "';width:100%;height:100%>" + text + "</div>";
return "<div class='visit_row' style='background-color:" + color + "';width:100%;height:100%>" + text + "</div>";
}
function createVisibilityCheckboxes(checkboxesElement, columns) {
......
function visit_dates_behaviour(startDateInput, endDateInput) {
function visit_dates_behaviour(startDateInput, endDateInput, default_visit_duration) {
$(startDateInput).change(function () {
var object = $(this);
try {
var date = new Date(object.val());
date.setMonth(date.getMonth() + 3);
$(endDateInput).val(date.toISOString().substring(0, 10));
date.setMonth(date.getMonth() + default_visit_duration);
if($(endDateInput).val() == ""){
$(endDateInput).val(date.toISOString().substring(0, 10));
}
} catch (err) {
//there was a problematic date to process
}
......
......@@ -13,7 +13,7 @@
background: orange;
{% endif %}{% endif %}{% endfor %}{% endfor %}
"></div>
<input {% if readonly %}disabled="disabled"{% endif %}
<input {% if field.field.widget.attrs.readonly %}disabled="disabled"{% endif %}
{% for option in field.value %}{% if option == pk|slugify or option == pk %}checked="checked"
{% endif %}{% endfor %} type="checkbox"
id="id_{{ field.name }}_{{ forloop.counter0 }}"
......
......@@ -3,6 +3,7 @@
{% block styles %}
{{ block.super }}
<script src="{% static 'AdminLTE/plugins/moment.js/moment.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'AdminLTE/plugins/datatables/dataTables.bootstrap.css' %}">
<style type="text/css">
.box-body {
......@@ -12,6 +13,23 @@
font-weight: bolder;
border-bottom: black 1px solid;
}
.visit_row{
font-size: 10pt;
max-width: 22ch;
min-width: 18ch;
text-align: center;
}
.visit_row > span{
padding-right: 2px;
padding-left: 2px;
}
.visit_row > span > a{
color: inherit;
}
.appointment_type_list{
margin-top: 10px;
text-align: left;
}
</style>
{% endblock styles %}
......@@ -72,7 +90,12 @@
columns: getColumns(data.columns, getSubjectEditUrl),
checkboxesElement: document.getElementById("visible-column-checkboxes"),
dom_settings: 'lrtip' // show table without search box
})
});
});
$('body').tooltip({
selector: '.visit_row > span[title]',
trigger: 'hover'
});
</script>
......
......@@ -70,7 +70,8 @@
<script src="{% static 'js/visit.js' %}"></script>
<script>
visit_dates_behaviour($("[name='datetime_begin']"),$("[name='datetime_end']"));
var default_visit_duration_in_months = parseInt("{{default_visit_duration}}");
visit_dates_behaviour($("[name='datetime_begin']"), $("[name='datetime_end']"), default_visit_duration_in_months);
</script>
{% include "includes/datepicker.js.html" %}
......
......@@ -91,10 +91,17 @@
</div>
<div>
{%if visFinished%}
<a href="{% url 'web.views.appointment_add' vid %}" class="btn btn-app" disabled>
<i class="fa fa-plus"></i>
Add new appointment
</a>
{% else %}
<a href="{% url 'web.views.appointment_add' vid %}" class="btn btn-app">
<i class="fa fa-plus"></i>
Add new appointment
</a>
{% endif %}
</div>
......@@ -221,7 +228,8 @@
<script src="{% static 'AdminLTE/plugins/datatables/dataTables.bootstrap.min.js' %}"></script>
<script src="{% static 'js/visit.js' %}"></script>
<script>
visit_dates_behaviour($("[name='datetime_begin']"), $("[name='datetime_end']"));
var default_visit_duration_in_months = parseInt("{{default_visit_duration}}");
visit_dates_behaviour($("[name='datetime_begin']"), $("[name='datetime_end']"), default_visit_duration_in_months);
</script>
{% include "includes/datepicker.js.html" %}
......
......@@ -18,16 +18,15 @@ class StudyFormTests(TestCase):
'nd_number_study_subject_regex').get_default()
form.cleaned_data = {
'nd_number_study_subject_regex': nd_number_study_subject_regex_default}
self.assertTrue(form.clean_nd_number_study_subject_regex()
== nd_number_study_subject_regex_default)
self.assertTrue(form.clean()['nd_number_study_subject_regex'] == nd_number_study_subject_regex_default)
# test wrong regex
form = StudyEditForm()
form.instance = get_test_study()
nd_number_study_subject_regex_default = r'^nd\d{5}$'
form.cleaned_data = {
'nd_number_study_subject_regex': nd_number_study_subject_regex_default}
self.assertRaises(
ValidationError, form.clean_nd_number_study_subject_regex)
self.assertFalse(form.is_valid())
def test_study_other_regex(self):
StudySubject.objects.all().delete()
......@@ -39,5 +38,5 @@ class StudyFormTests(TestCase):
nd_number_study_subject_regex_default = r'^nd\d{5}$'
form.cleaned_data = {
'nd_number_study_subject_regex': nd_number_study_subject_regex_default}
self.assertTrue(form.clean_nd_number_study_subject_regex()
self.assertTrue(form.clean()['nd_number_study_subject_regex']
== nd_number_study_subject_regex_default)
import datetime
from dateutil.relativedelta import relativedelta
from django.test import TestCase
from web.models import Visit
......@@ -137,7 +137,8 @@ class VisitModelTests(TestCase):
visit.mark_as_finished()
follow_up_visit = Visit.objects.filter(subject=subject).filter(visit_number=2)[0]
visit_number=2
follow_up_visit = Visit.objects.filter(subject=subject).filter(visit_number=visit_number)[0]
follow_up_visit.datetime_begin = visit.datetime_begin + datetime.timedelta(days=133)
follow_up_visit.datetime_end = visit.datetime_begin + datetime.timedelta(days=170)
follow_up_visit.save()
......@@ -147,10 +148,16 @@ class VisitModelTests(TestCase):
visit_count = Visit.objects.filter(subject=subject).count()
self.assertEquals(3, visit_count)
new_follow_up = Visit.objects.filter(subject=subject).filter(visit_number=3)[0]
visit_number=3
new_follow_up = Visit.objects.filter(subject=subject).filter(visit_number=visit_number)[0]
# check if follow up date is based on the first visit date
self.assertTrue(visit.datetime_begin + datetime.timedelta(days=365 * 2 - 1) < new_follow_up.datetime_begin)
study = visit.subject.study
args = {study.default_delta_time_for_follow_up_units: study.default_delta_time_for_patient_follow_up} #patient
time_to_next_visit = relativedelta(**args) * (visit_number - 1) #calculated from first visit
self.assertTrue(visit.datetime_begin + time_to_next_visit - datetime.timedelta(days=1) < new_follow_up.datetime_begin)
def test_visit_to_string(self):
visit = create_visit(create_study_subject())
......
......@@ -74,6 +74,7 @@ def visit_details(request, id):
languages.extend(study_subject.subject.languages.all())
return wrap_response(request, 'visits/details.html', {
'default_visit_duration' : study_subject.study.default_visit_duration_in_months,
'visit_form': visit_form,
'study_subject_form': study_subject_form,
'subject_form': subject_form,
......@@ -96,6 +97,7 @@ def visit_mark(request, id, as_what):
def visit_add(request, subject_id=-1):
if request.method == 'POST':
form = VisitAddForm(request.POST, request.FILES)
args = {'form': form}
if form.is_valid():
visit = form.save()
return redirect('web.views.visit_details', visit.id)
......@@ -105,5 +107,6 @@ def visit_add(request, subject_id=-1):
if len(subjects) > 0:
subject = subjects[0]
form = VisitAddForm(initial={'subject': subject})
args = {'form': form, 'default_visit_duration': subject.study.default_visit_duration_in_months}
return wrap_response(request, 'visits/add.html', {'form': form})
return wrap_response(request, 'visits/add.html', args)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment