diff --git a/requirements.txt b/requirements.txt index 13d8015dcc58f1a0eb81bc182e761622c7ec2c9c..49a543a076f30f5d39b61889939d1ecfedebdedc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,6 +33,7 @@ mockito==1.2.2 nexmo==2.3.0 numpy==1.19.2 pandas==1.1.3 +django-datatables-view==1.19.1 phonenumberslite==8.9.14 Pillow==3.4.2 psycopg2==2.8.6 diff --git a/smash/web/admin.py b/smash/web/admin.py index 6fce54fc40b50ea3a75960f36c6b53114360a0f8..dceea27fc8d9814aacd3d8e90a8c0e5868d1b4a9 100644 --- a/smash/web/admin.py +++ b/smash/web/admin.py @@ -1,16 +1,165 @@ from django.contrib import admin +from django.utils.html import format_html from .models import StudySubject, Item, Room, AppointmentType, Language, Location, Worker, FlyingTeam, Availability, \ - Holiday, Visit, Appointment, StudyColumns, StudySubjectList, StudyVisitList, VisitColumns, SubjectColumns + Holiday, Visit, Appointment, StudyColumns, StudySubjectList, StudyVisitList, VisitColumns, SubjectColumns, \ + Voucher, VoucherType, Provenance +#good tutorial +#https://developer.mozilla.org/en-US/docs/Learn/Server-side/Django/Admin_site class LanguageAdmin(admin.ModelAdmin): + list_per_page = 20 list_display = ('name', 'image_img') +class VisitAdmin(admin.ModelAdmin): + list_per_page = 20 + list_display = ('change_button', 'id', 'get_first_name', 'get_last_name', 'visit_number', 'get_start_date', 'get_end_date', 'get_status') + ordering = ('subject__subject__first_name', 'subject__subject__last_name', 'datetime_begin', 'datetime_end', 'visit_number') + list_filter = ('is_finished', 'visit_number', 'datetime_begin', 'datetime_end', 'appointment_types') + search_fields = ('subject__subject__first_name', 'subject__subject__last_name') + + def get_first_name(self, obj): + return obj.subject.subject.first_name + + def get_last_name(self, obj): + return obj.subject.subject.last_name + + def get_start_date(self, obj): + return obj.datetime_begin.strftime('%Y-%m-%d') + + def get_end_date(self, obj): + return obj.datetime_end.strftime('%Y-%m-%d') + + def get_status(self, obj): + return '✓' if obj.is_finished else '' + + def change_button(self, obj): + return format_html('<a class="changelink" href="/admin/web/visit/{}/change/">Change</a>', obj.id) + change_button.short_description = '' + + get_first_name.short_description = 'Subject First Name' + get_first_name.admin_order_field = 'subject__subject__first_name' + + get_last_name.short_description = 'Subject Last Name' + get_last_name.admin_order_field = 'subject__subject__last_name' + + get_start_date.short_description = 'Start Date' + get_start_date.admin_order_field = 'datetime_begin' + + get_end_date.short_description = 'End Date' + get_end_date.admin_order_field = 'datetime_end' + + get_status.short_description = 'Visit Finished' + get_status.admin_order_field = 'is_finished' + +class GeneralAppointmentFilter(admin.SimpleListFilter): + title = 'appointment type' + # Parameter for the filter that will be used in the URL query. + parameter_name = 'visit__isnull' + + def lookups(self, request, model_admin): + return ( + ('True', 'General Appointment'), + ('False', 'Visit Appointment'), + ) + + def queryset(self, request, queryset): + if self.value() == 'False': + return queryset.filter(visit__isnull=False) + if self.value() == 'True': + return queryset.filter(visit__isnull=True) + +class AppointmentAdmin(admin.ModelAdmin): + list_per_page = 20 + list_display = ('change_button', 'id', 'get_datetime_when', 'get_length', 'location', 'status', 'get_first_name', 'get_last_name', 'get_visit_number', 'get_start_date', 'get_end_date', 'get_status', 'is_general_appointment') + ordering = ('datetime_when', 'length', 'location', 'visit__subject__subject__first_name', 'status', 'visit__subject__subject__last_name', 'visit__datetime_begin', 'visit__datetime_end', 'visit__visit_number') + list_filter = ('datetime_when', 'length', 'location', 'visit__is_finished', 'visit__visit_number', 'visit__datetime_begin', 'visit__datetime_end', 'appointment_types', 'status', GeneralAppointmentFilter) + search_fields = ('visit__subject__subject__first_name', 'visit__subject__subject__last_name') + + def get_first_name(self, obj): + if obj.visit is not None: + return obj.visit.subject.subject.first_name + return None + + def get_last_name(self, obj): + if obj.visit is not None: + return obj.visit.subject.subject.last_name + return None + + def get_datetime_when(self, obj): + return obj.datetime_when.strftime('%Y-%m-%d %H:%M') + + def get_length(self, obj): + return obj.length + + def get_start_date(self, obj): + if obj.visit is not None: + return obj.visit.datetime_begin.strftime('%Y-%m-%d') + return None + + def get_end_date(self, obj): + if obj.visit is not None: + return obj.visit.datetime_end.strftime('%Y-%m-%d') + return None + + def get_status(self, obj): + if obj.visit is not None: + return '✓' if obj.visit.is_finished else '' + return None + + def get_visit_number(self, obj): + if obj.visit is not None: + return obj.visit.visit_number + return None + + def is_general_appointment(self, obj): + return '✓' if obj.visit is None else '' + + def change_button(self, obj): + return format_html('<a class="changelink" href="/admin/web/appointment/{}/change/">Change</a>', obj.id) + change_button.short_description = '' + + get_first_name.short_description = 'Subject First Name' + get_first_name.admin_order_field = 'visit__subject__subject__first_name' + + get_datetime_when.short_description = 'Appointment Date' + get_datetime_when.admin_order_field = 'datetime_when' + + get_length.short_description = 'Appointment Duration (mins)' + get_length.admin_order_field = 'length' + + get_start_date.short_description = 'Visit Start Date' + get_start_date.admin_order_field = 'visit__datetime_begin' + + get_start_date.short_description = 'Visit Start Date' + get_start_date.admin_order_field = 'visit__datetime_begin' + + get_end_date.short_description = 'Visit End Date' + get_end_date.admin_order_field = 'visit__datetime_end' + + get_status.short_description = 'Visit Finished' + get_status.admin_order_field = 'visit__is_finished' + + get_visit_number.short_description = 'Visit Number' + get_visit_number.admin_order_field = 'visit__visit_number' + + is_general_appointment.short_description = 'General Appointment' + +class ProvenanceAdmin(admin.ModelAdmin): + list_per_page = 20 + list_display = ('change_button', 'id', 'modified_table', 'modified_table_id', 'modification_date', 'modification_author', 'modified_field', 'previous_value', 'new_value', 'modification_description') + list_filter = ('modified_table', 'modification_date', 'modification_author', 'modified_field') + ordering = ('modified_table', 'modification_date', 'modification_author', 'modified_field') + + def change_button(self, obj): + return format_html('<a class="changelink" href="/admin/web/provenance/{}/change/">Change</a>', obj.id) + + change_button.short_description = '' # Register your models here. admin.site.register(StudySubject) -admin.site.register(Visit) +admin.site.register(Visit, VisitAdmin) admin.site.register(Item) admin.site.register(Room) admin.site.register(AppointmentType) @@ -20,9 +169,12 @@ admin.site.register(Worker) admin.site.register(FlyingTeam) admin.site.register(Availability) admin.site.register(Holiday) -admin.site.register(Appointment) +admin.site.register(Appointment, AppointmentAdmin) admin.site.register(SubjectColumns) admin.site.register(StudyColumns) admin.site.register(StudySubjectList) admin.site.register(StudyVisitList) admin.site.register(VisitColumns) +admin.site.register(Voucher) +admin.site.register(VoucherType) +admin.site.register(Provenance, ProvenanceAdmin) \ No newline at end of file diff --git a/smash/web/api_urls.py b/smash/web/api_urls.py index 22f3d57d57fc202b1e9cf0a6a780e15265c5602b..7dae498a26d5e610164cf4a544429a2f9dde03a7 100644 --- a/smash/web/api_urls.py +++ b/smash/web/api_urls.py @@ -16,7 +16,7 @@ Including another URLconf from django.conf.urls import url from web.api_views import worker, location, subject, appointment_type, appointment, configuration, daily_planning, \ - redcap, flying_team, visit, voucher, voucher_type + redcap, flying_team, visit, voucher, voucher_type, provenance urlpatterns = [ # appointments @@ -35,6 +35,8 @@ urlpatterns = [ # subjects data url(r'^cities$', subject.cities, name='web.api.cities'), url(r'^referrals$', subject.referrals, name='web.api.referrals'), + url(r'^export_log$', provenance.ExportLog.as_view(), name='web.api.export_log'), + url(r'^provenances$', provenance.CompleteLog.as_view(), name='web.api.provenances'), url(r'^subjects/(?P<type>[A-z]+)$', subject.subjects, name='web.api.subjects'), url(r'^subjects:columns/(?P<subject_list_type>[A-z]+)$', subject.get_subject_columns, name='web.api.subjects.columns'), diff --git a/smash/web/api_views/provenance.py b/smash/web/api_views/provenance.py new file mode 100644 index 0000000000000000000000000000000000000000..6fe1407dd1203360b50a2546c9118f7255978f66 --- /dev/null +++ b/smash/web/api_views/provenance.py @@ -0,0 +1,86 @@ +from ..models import Provenance, Worker +from django_datatables_view.base_datatable_view import BaseDatatableView + +class ExportLog(BaseDatatableView): + model = Provenance + columns = [ + 'modification_date', + 'modification_author', + 'modification_description', + 'request_path', + 'request_ip_addr' + ] + + # define column names that will be used in sorting + # order is important and should be same as order of columns + # displayed by datatables. For non sortable columns use empty + # value like '' + order_columns = [ + 'modification_date', + 'modification_author', + 'modification_description', + 'request_path', + 'request_ip_addr' + ] + + # set max limit of records returned, this is used to protect our site if someone tries to attack our site + # and make it return huge amount of data + max_display_length = 10 + + def filter_queryset(self, qs): + # simple example: + search = self.request.GET.get('search[value]', None) + if search != '' and search is not None: + inner_qs = Worker.objects.filter(first_name__icontains=search) | Worker.objects.filter(last_name__icontains=search) + qs = qs.filter(modification_author__in=inner_qs) | qs.filter(modification_description__icontains=search) | qs.filter(request_path__icontains=search) | qs.filter(request_ip_addr__icontains=search) + + qs = qs & qs.filter(modified_table__isnull=True, + previous_value__isnull=True, + new_value__isnull=True, + modified_field='', + request_path__startswith='/export/') + + return qs + +class CompleteLog(BaseDatatableView): + model = Provenance + columns = ['modified_table', + 'modified_table_id', + 'modification_date', + 'modification_author', + 'modified_field', + 'previous_value', + 'new_value', + 'modification_description', + 'request_path', + 'request_ip_addr' + ] + + # define column names that will be used in sorting + # order is important and should be same as order of columns + # displayed by datatables. For non sortable columns use empty + # value like '' + order_columns = ['modified_table', + 'modified_table_id', + 'modification_date', + 'modification_author', + 'modified_field', + 'previous_value', + 'new_value', + 'modification_description', + 'request_path', + 'request_ip_addr' + ] + + # set max limit of records returned, this is used to protect our site if someone tries to attack our site + # and make it return huge amount of data + max_display_length = 10 + + def filter_queryset(self, qs): + # simple example: + search = self.request.GET.get('search[value]', None) + if search != '' and search is not None: + inner_qs = Worker.objects.filter(first_name__icontains=search) | Worker.objects.filter(last_name__icontains=search) + qs = qs.filter(modification_author__in=inner_qs) | qs.filter(modification_description__icontains=search) | qs.filter(request_path__icontains=search) | qs.filter(request_ip_addr__icontains=search) + + return qs \ No newline at end of file diff --git a/smash/web/management/commands/superworker.py b/smash/web/management/commands/superworker.py new file mode 100644 index 0000000000000000000000000000000000000000..f34e7ebed76934e620438bbd509031a07fc28d6d --- /dev/null +++ b/smash/web/management/commands/superworker.py @@ -0,0 +1,49 @@ +from getpass import getpass +from django.core.management.base import BaseCommand +from django.db import IntegrityError +from ...models import Worker, User, Location, Language, WorkerStudyRole +from web.models.worker_study_role import ROLE_CHOICES_TECHNICIAN +from web.models.constants import GLOBAL_STUDY_ID + +class Command(BaseCommand): + help = 'creates super worker (superuser + worker)' + + def add_arguments(self, parser): + parser.add_argument('-u', '--username', type=str, required=True) + parser.add_argument('-e', '--email', type=str, required=True) + parser.add_argument('-f', '--first-name', type=str, required=True) + parser.add_argument('-l', '--last-name', type=str, required=True) + + def handle(self, *args, **kwargs): + first_name = kwargs['first_name'] + last_name = kwargs['last_name'] + email = kwargs['email'] + username = kwargs['username'] + + password = getpass() + user = None + try: + user = User.objects.create(username=username, email=email) + user.is_superuser = True + user.is_staff = True + user.is_admin = True + user.set_password(password) + user.save() + except IntegrityError: + self.stderr.write('User already exists') + return + + try: + worker = Worker.objects.create(first_name=first_name, last_name=last_name, email=email, user=user) + locations = Location.objects.all() + worker.locations.set(locations) + languages = Language.objects.all() + worker.languages.set(languages) + worker.save() + workerStudyRole, _ = WorkerStudyRole.objects.update_or_create(worker=worker, + study_id=GLOBAL_STUDY_ID, name=ROLE_CHOICES_TECHNICIAN) + except IntegrityError: + self.stderr.write('Worker already exists') + return + + self.stderr.write(f'Superworker {username} created') \ No newline at end of file diff --git a/smash/web/migrations/0173_auto_20201105_1142.py b/smash/web/migrations/0173_auto_20201105_1142.py new file mode 100644 index 0000000000000000000000000000000000000000..9332a188532a7e0eee15b256a6b1fb9021ef9679 --- /dev/null +++ b/smash/web/migrations/0173_auto_20201105_1142.py @@ -0,0 +1,26 @@ +# Generated by Django 2.0.13 on 2020-11-05 11:42 + +import django.core.files.storage +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0172_auto_20200525_1246'), + ] + + operations = [ + migrations.AlterField( + model_name='provenance', + name='modified_table', + field=models.CharField(max_length=1024, null=True, verbose_name='Modified table'), + ), + migrations.AlterField( + model_name='provenance', + name='modified_table_id', + field=models.IntegerField(default=0, null=True, verbose_name='Modified table row'), + ) + ] diff --git a/smash/web/migrations/0174_auto_20201105_1157.py b/smash/web/migrations/0174_auto_20201105_1157.py new file mode 100644 index 0000000000000000000000000000000000000000..b6f2abbbf6138a0280c07dbb1af099d65c7b58d1 --- /dev/null +++ b/smash/web/migrations/0174_auto_20201105_1157.py @@ -0,0 +1,21 @@ +# Generated by Django 2.0.13 on 2020-11-05 11:57 + +import django.core.files.storage +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0173_auto_20201105_1142'), + ] + + operations = [ + migrations.AddField( + model_name='provenance', + name='request_path', + field=models.CharField(blank=True, max_length=20480, null=True, verbose_name='Request Path'), + ) + ] diff --git a/smash/web/migrations/0175_auto_20201109_1404.py b/smash/web/migrations/0175_auto_20201109_1404.py new file mode 100644 index 0000000000000000000000000000000000000000..005e1a94345f34d32671ffcfbbf7837cd7f2b63d --- /dev/null +++ b/smash/web/migrations/0175_auto_20201109_1404.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2020-11-09 14:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0174_auto_20201105_1157'), + ] + + operations = [ + migrations.AddField( + model_name='provenance', + name='request_ip_addr', + field=models.GenericIPAddressField(null=True, verbose_name='Request IP Address'), + ) + ] diff --git a/smash/web/models/appointment.py b/smash/web/models/appointment.py index 55440a0a4ca530830b073ab8839fa4c764b69f50..d20214f26a02c4f49df9525db8e84af8d8d78473 100644 --- a/smash/web/models/appointment.py +++ b/smash/web/models/appointment.py @@ -77,6 +77,14 @@ class Appointment(models.Model): default=False ) + def __str__(self): + start = self.datetime_when.strftime('%Y-%m-%d %H:%M') + if self.visit is not None: + subject = self.visit.subject.subject + return f'#{self.visit.visit_number:02} | {start} ({self.length} min) | {subject.first_name} {subject.last_name} | {self.status}' + else: + return f'{start} ({self.length} min) | {self.status}' + def mark_as_finished(self): self.status = Appointment.APPOINTMENT_STATUS_FINISHED self.save() diff --git a/smash/web/models/provenance.py b/smash/web/models/provenance.py index 30a84fd9d6ccbae778c2f0291be52fdb3cffde82..b30ac078d90d91a667299142cce488684cbbf656 100644 --- a/smash/web/models/provenance.py +++ b/smash/web/models/provenance.py @@ -10,10 +10,10 @@ class Provenance(models.Model): modified_table = models.CharField(max_length=1024, verbose_name='Modified table', - blank=False, null=False + blank=False, null=True ) - modified_table_id = models.IntegerField(default=0, verbose_name='Modified table row', blank=False, null=False) + modified_table_id = models.IntegerField(default=0, verbose_name='Modified table row', blank=False, null=True) modification_date = models.DateTimeField( verbose_name='Modified on', @@ -42,4 +42,11 @@ class Provenance(models.Model): modification_description = models.CharField(max_length=20480, verbose_name='Description', blank=False, null=False - ) \ No newline at end of file + ) + + request_path = models.CharField(max_length=20480, + verbose_name='Request Path', + blank=True, null=True + ) + + request_ip_addr = models.GenericIPAddressField(verbose_name='Request IP Address', null=True) \ No newline at end of file diff --git a/smash/web/models/visit.py b/smash/web/models/visit.py index fd67b459cb2a0841e1edeb25bd1ba44bb4789a34..5d7cec4eb4f236f8e87e8ce7dcae924920dbe358 100644 --- a/smash/web/models/visit.py +++ b/smash/web/models/visit.py @@ -47,11 +47,25 @@ class Visit(models.Model): default=1 ) + @property + def next_visit(self): + return Visit.objects.filter(subject=self.subject, visit_number=self.visit_number+1).order_by('datetime_begin','datetime_end').first() + + @property + def future_visits(self): + return Visit.objects.filter(subject=self.subject).filter(visit_number__gt=self.visit_number).order_by('datetime_begin','datetime_end') + def __unicode__(self): - return "%s %s" % (self.subject.subject.first_name, self.subject.subject.last_name) + start = self.datetime_begin.strftime('%Y-%m-%d') + end = self.datetime_end.strftime('%Y-%m-%d') + finished = '✓' if self.is_finished else '' + return f'#{self.visit_number:02} | {start} / {end} | {self.subject.subject.first_name} {self.subject.subject.last_name} | {finished}' def __str__(self): - return "%s %s" % (self.subject.subject.first_name, self.subject.subject.last_name) + start = self.datetime_begin.strftime('%Y-%m-%d') + end = self.datetime_end.strftime('%Y-%m-%d') + finished = '✓' if self.is_finished else '' + return f'#{self.visit_number:02} | {start} / {end} | {self.subject.subject.first_name} {self.subject.subject.last_name} | {finished}' def mark_as_finished(self): self.is_finished = True @@ -92,6 +106,32 @@ class Visit(models.Model): datetime_end=visit_started + time_to_next_visit + relativedelta(months=study.default_visit_duration_in_months) ) + def unfinish(self): + #if ValueError messages are changed, change test/view/test_visit.py + #check visit is indeed finished + if not self.is_finished: + raise ValueError('The visit is not finished.') + #check that there is only one future visit + future_visits = self.future_visits + if len(future_visits) > 1: + raise ValueError("Visit can't be unfinished. Only visits with one inmediate future visit (without appointments) can be unfinished.") + elif len(future_visits) == 1: + #check that the future visit has no appointments + #remove visit if it has no appointments + next_visit = future_visits[0] + if len(next_visit.appointment_set.all()) == 0: + next_visit.delete() + else: + raise ValueError("Visit can't be unfinished. The next visit has appointments.") + + else: + #this can happen when there is no auto follow up visit + pass + + self.is_finished = False + self.save() + return + @receiver(post_save, sender=Visit) def check_visit_number(sender, instance, created, **kwargs): # no other solution to ensure the visit_number is in cronological order than to sort the whole list if there are future visits diff --git a/smash/web/templates/export/index.html b/smash/web/templates/export/index.html index fe8d952d03f0a8fc4ede565a05b3351dde977459..4e99d70b88409d4c55f35182c106db6c0c3f3cc9 100644 --- a/smash/web/templates/export/index.html +++ b/smash/web/templates/export/index.html @@ -1,9 +1,11 @@ {% extends "_base.html" %} {% load static %} +{% load filters %} {% block styles %} {{ block.super }} <link rel="stylesheet" href="{% static 'css/export.css' %}"> + <link rel="stylesheet" href="{% static 'AdminLTE/plugins/datatables/dataTables.bootstrap.css' %}"> <style type="text/css"> ul.list_of_fields{ columns: 3; @@ -92,37 +94,108 @@ <li><a onclick="addFields(this, 'appointment_fields')" href="{% url 'web.views.export_to_csv' 'appointments' %}"><i class="fa fa-file-text-o"></i> CSV - Text based</a></li> </ul> + + <h3>Export Log</h3> + + <div class="box-body"> + <table id="table" class="table table-bordered table-striped"> + <thead> + <tr> + <th>Date</th> + <th>Author</th> + <th>Description</th> + <th>Request Path</th> + <th>Request IP</th> + </tr> + </thead> + <tbody> + + </tbody> + </table> + </div> + </div> <div class="box-body"> </div> -<script type="text/javascript"> - window.onload = function() { - $('.list_of_fields_header').tooltip(); - $('.list_of_fields_header').click(function(e){ - $(e.target).children('i.fa').toggleClass('fa-expand'); - $(e.target).children('i.fa').toggleClass('fa-compress'); - }); - }; - function addFields(e, cls){ - var fields = $(`.${cls} > li > input:checked`).map(function(i,e){ - return e.value; - }).toArray().join(','); - //remove parameters from previous calls if any - //attention: we are assuming the original url doesn't have any parameter - $(e).attr('href', function() { - return this.href.split('?')[0] + `?fields=${fields}`; - }); - } - function toggleCheckboxes(e){ - if($(e).text().toLowerCase() == 'uncheck all'){ - $(e).siblings('ul').find('input[type="checkbox"]').prop('checked', false); - $(e).text('Check All'); - }else if($(e).text().toLowerCase() == 'check all'){ - $(e).siblings('ul').find('input[type="checkbox"]').prop('checked', true); - $(e).text('Uncheck All'); - } - } -</script> {% endblock maincontent %} + +{% block scripts %} + {{ block.super }} + + <script src="{% static 'AdminLTE/plugins/datatables/jquery.dataTables.min.js' %}"></script> + <script src="{% static 'AdminLTE/plugins/datatables/dataTables.bootstrap.min.js' %}"></script> + + <script> + window.onload = function() { + $('.list_of_fields_header').tooltip(); + $('.list_of_fields_header').click(function(e){ + $(e.target).children('i.fa').toggleClass('fa-expand'); + $(e.target).children('i.fa').toggleClass('fa-compress'); + }); + + + + }; + function addFields(e, cls){ + var fields = $(`.${cls} > li > input:checked`).map(function(i,e){ + return e.value; + }).toArray().join(','); + //remove parameters from previous calls if any + //attention: we are assuming the original url doesn't have any parameter + $(e).attr('href', function() { + return this.href.split('?')[0] + `?fields=${fields}`; + }); + } + function toggleCheckboxes(e){ + if($(e).text().toLowerCase() == 'uncheck all'){ + $(e).siblings('ul').find('input[type="checkbox"]').prop('checked', false); + $(e).text('Check All'); + }else if($(e).text().toLowerCase() == 'check all'){ + $(e).siblings('ul').find('input[type="checkbox"]').prop('checked', true); + $(e).text('Uncheck All'); + } + } + + $(function () { + console.log("{% url 'web.api.provenances' %}") + var oTable = $('#table').DataTable({ + processing: true, + serverSide: true, + ordering: true, + autoWidth: false, + lengthChange: false, + columns: [ + { + data: 'modification_date', + orderable: true, + searchable: true + }, + { + data: 'modification_author', + orderable: true, + searchable: true, + }, + { + data: 'modification_description', + orderable: true, + searchable: true, + }, + { + data: 'request_path', + orderable: true, + searchable: true, + }, + { + data: 'request_ip_addr', + orderable: true, + searchable: true, + } + ], + order: [[ 0, "desc" ]], + ajax: "{% url 'web.api.export_log' %}" + }); + }); + </script> +{% endblock scripts %} \ No newline at end of file diff --git a/smash/web/templates/provenance/list.html b/smash/web/templates/provenance/list.html index 79eaacea0aa9857a30b74f11d83794036e7925b3..3405183742cf4e8bbce6e7aa5e21bb2a4f32420b 100644 --- a/smash/web/templates/provenance/list.html +++ b/smash/web/templates/provenance/list.html @@ -30,21 +30,11 @@ <th>Previous Value</th> <th>New Value</th> <th>Description</th> + <th>Request Path</th> + <th>Request IP</th> </tr> </thead> <tbody> - {% for provenance in provenances %} - <tr> - <td>{{ provenance.modified_table }}</td> - <td>{{ provenance.modified_table_id }}</td> - <td data-sort="{{ provenance.modification_date | timestamp }}">{{ provenance.modification_date }}</td> - <td>{{ provenance.modification_author }}</td> - <td>{{ provenance.modified_field }}</td> - <td>{{ provenance.previous_value }}</td> - <td>{{ provenance.new_value }}</td> - <td>{{ provenance.modification_description }}</td> - </tr> - {% endfor %} </tbody> </table> </div> @@ -59,12 +49,68 @@ <script> $(function () { $('#table').DataTable({ - "paging": true, - "lengthChange": false, - "searching": true, - "ordering": true, - "info": true, - "autoWidth": false + paging: true, + lengthChange: false, + searching: true, + processing: true, + serverSide: true, + ordering: true, + order: [[ 2, "desc" ]], + info: true, + autoWidth: false, + columns: [ + { + data: 'modified_table', + orderable: true, + searchable: true + }, + { + data: 'modified_table_id', + orderable: true, + searchable: true + }, + { + data: 'modification_date', + orderable: true, + searchable: true + }, + { + data: 'modification_author', + orderable: true, + searchable: true, + }, + { + data: 'modified_field', + orderable: true, + searchable: true, + }, + { + data: 'previous_value', + orderable: true, + searchable: true, + }, + { + data: 'new_value', + orderable: true, + searchable: true, + }, + { + data: 'modification_description', + orderable: true, + searchable: true, + }, + { + data: 'request_path', + orderable: true, + searchable: true, + }, + { + data: 'request_ip_addr', + orderable: true, + searchable: true, + } + ], + ajax: "{% url 'web.api.provenances' %}" }); }); </script> diff --git a/smash/web/templates/visits/details.html b/smash/web/templates/visits/details.html index 6935b7d64949765f1b0b35e436c4640ff5f8f88e..862333277715f58fd0d16eccdffbcec45dd2b7f9 100644 --- a/smash/web/templates/visits/details.html +++ b/smash/web/templates/visits/details.html @@ -7,6 +7,15 @@ <!-- DataTables --> <link rel="stylesheet" href="{% static 'AdminLTE/plugins/datatables/dataTables.bootstrap.css' %}"> + <style> + .tooltip-inner { + min-width: 150px; + } + div.disabled a { + cursor: not-allowed; + } + </style> + {% include "includes/datepicker.css.html" %} {% endblock styles %} @@ -60,11 +69,18 @@ <div class="col-md-6 form-group"> <label class="col-sm-4 control-label"> + {% if visFinished %} + <i title="Only visits with one inmediate future visit (without appointments) can be unfinished." class="fa fa-info-circle"></i> + {% endif %} Visit finished </label> + {% if visFinished %} + <div class="col-sm-7"> + {% else %} <div class="col-sm-8"> + {% endif %} {% if visFinished %} - <div class="btn btn-block">YES</div> + <div class="btn btn-block btn-success disabled">YES</div> {% else %} <div class="btn btn-block"> {% if canFinish %} @@ -76,6 +92,29 @@ </div> {% endif %} </div> + + {% if visFinished %} + <div class="col-sm-1"> + {% if "unfinish_visit" not in permissions %} + <div title="You don't have permissions to unfinish visits." class="btn btn-block btn-warning disabled"> + <i class="fa fa-undo"></i> + </div> + {% elif next_visit_appointments|length > 0 and visit.future_visits|length == 1 %} + <div title="This visit cannot be unfinished because next visit has appointments." class="btn btn-block btn-warning disabled"> + <i class="fa fa-undo"></i> + </div> + {% elif visit.future_visits|length > 1 %} + <div title="This visit cannot be unfinished because there are more than one future visits." class="btn btn-block btn-warning disabled"> + <i class="fa fa-undo"></i> + </div> + {% else %} + <a data-html="true" title="Unfinish this visit. </br> âš ï¸ This will delete the next visit." class="btn btn-block btn-warning" + href="{% url 'web.views.visit_unfinish' vid %}" onclick="return confirm('Are you sure you want to unfinish this visit? This will delete the next visit.')"> + <i class="fa fa-undo"></i> + </a> + {% endif %} + </div> + {% endif %} </div> </div><!-- /.box-body --> <div class="box-footer"> @@ -225,6 +264,7 @@ <script> var default_visit_duration_in_months = parseInt("{{default_visit_duration}}"); visit_dates_behaviour($("[name='datetime_begin']"), $("[name='datetime_end']"), default_visit_duration_in_months); + $('[title]').tooltip(); </script> {% include "includes/datepicker.js.html" %} diff --git a/smash/web/tests/models/test_visit.py b/smash/web/tests/models/test_visit.py index e02273f887262b86c5e88869333d60792219d5a5..48ba8358b7561ae6106eafb643c433ba0dff8154 100644 --- a/smash/web/tests/models/test_visit.py +++ b/smash/web/tests/models/test_visit.py @@ -107,6 +107,46 @@ class VisitModelTests(TestCase): visit_count = Visit.objects.filter(subject=subject).count() self.assertEqual(2, visit_count) + def test_future_visits(self): + subject = create_study_subject() + visit = create_visit(subject) + + visit.mark_as_finished() + + #assumes auto follow up visit + visit_count = Visit.objects.filter(subject=subject).count() + self.assertEqual(2, visit_count) + + for fv in visit.future_visits: + self.assertGreater(fv.visit_number, visit.visit_number) + + def test_next_visit(self): + subject = create_study_subject() + visit = create_visit(subject) + + visit.mark_as_finished() + + #assumes auto follow up visit + visit_count = Visit.objects.filter(subject=subject).count() + self.assertEqual(2, visit_count) + + nv = Visit.objects.filter(subject=subject).order_by('datetime_begin','datetime_end').last() + self.assertEqual(visit.next_visit.id, nv.id) + + def test_unfinish_visit(self): + subject = create_study_subject() + visit = create_visit(subject) + + visit.mark_as_finished() + + #assumes auto follow up visit + visit_count = Visit.objects.filter(subject=subject).count() + self.assertEqual(2, visit_count) + + visit.unfinish() + visit_count = Visit.objects.filter(subject=subject).count() + self.assertEqual(1, visit_count) + def test_mark_as_finished_2(self): study_subject = create_study_subject() visit = create_visit(study_subject) diff --git a/smash/web/tests/view/test_visit.py b/smash/web/tests/view/test_visit.py index 33e3df73ff0e523b3766f4f6ad61312be498261a..aa8108a9b695a9b7d660c4c97e924ae29719a5fe 100644 --- a/smash/web/tests/view/test_visit.py +++ b/smash/web/tests/view/test_visit.py @@ -10,6 +10,7 @@ from web.tests import LoggedInTestCase from web.tests.functions import create_study_subject, create_visit, create_appointment, create_appointment_type, \ create_language, get_resource_path, format_form_field from web.views.notifications import get_today_midnight_date +from django.contrib.messages import get_messages logger = logging.getLogger(__name__) @@ -100,6 +101,107 @@ class VisitViewTests(LoggedInTestCase): self.assertTrue(new_visit.is_finished) self.assertEqual(2, Visit.objects.count()) + def test_unfinish_visit_without_permissions(self): + visit = create_visit() + + self.assertFalse(visit.is_finished) + + response = self.client.get(reverse('web.views.visit_mark', args=[visit.id, "finished"])) + self.assertEqual(response.status_code, 302) + + new_visit = Visit.objects.get(id=visit.id) + self.assertTrue(new_visit.is_finished) + self.assertEqual(2, Visit.objects.count()) + + response = self.client.get(reverse('web.views.visit_unfinish', args=[visit.id]), follow=True) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), 'You are not authorized to view this page or perform this action. Request permissions to the system administrator.') + new_visit = Visit.objects.get(id=visit.id) + self.assertEqual(2, Visit.objects.count()) + self.assertTrue(new_visit.is_finished) + + def test_unfinish_visit_with_future_visit_without_appointments(self): + self.login_as_admin() + visit = create_visit() + + self.assertFalse(visit.is_finished) + + response = self.client.get(reverse('web.views.visit_mark', args=[visit.id, "finished"])) + self.assertEqual(response.status_code, 302) + + new_visit = Visit.objects.get(id=visit.id) + self.assertTrue(new_visit.is_finished) + self.assertEqual(2, Visit.objects.count()) + + response = self.client.get(reverse('web.views.visit_unfinish', args=[visit.id]), follow=True) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), 'Visit has been unfinished.') + new_visit = Visit.objects.get(id=visit.id) + self.assertEqual(1, Visit.objects.count()) + self.assertFalse(new_visit.is_finished) + + def test_unfinish_visit_with_future_visit_with_appointments(self): + self.login_as_admin() + visit = create_visit() + + self.assertFalse(visit.is_finished) + + response = self.client.get(reverse('web.views.visit_mark', args=[visit.id, "finished"])) + self.assertEqual(response.status_code, 302) + + new_visit = Visit.objects.get(id=visit.id) + self.assertTrue(new_visit.is_finished) + self.assertEqual(2, Visit.objects.count()) + + create_appointment(new_visit.next_visit) + + response = self.client.get(reverse('web.views.visit_unfinish', args=[visit.id]), follow=True) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "Visit can't be unfinished. The next visit has appointments.") + original_visit = Visit.objects.get(id=visit.id) + self.assertTrue(original_visit.is_finished) + + def test_unfinish_visit_with_two_future_visits(self): + self.login_as_admin() + visit = create_visit() + self.assertFalse(visit.is_finished) + response = self.client.get(reverse('web.views.visit_mark', args=[visit.id, "finished"])) + self.assertEqual(response.status_code, 302) + + new_visit = Visit.objects.get(id=visit.id) + self.assertTrue(new_visit.is_finished) + self.assertEqual(2, Visit.objects.count()) + + next_visit = new_visit.next_visit + response = self.client.get(reverse('web.views.visit_mark', args=[next_visit.id, "finished"])) + self.assertEqual(response.status_code, 302) + next_visit = Visit.objects.get(id=next_visit.id) + self.assertTrue(new_visit.is_finished) + self.assertEqual(3, Visit.objects.count()) + + #try to unfinish the original visit + response = self.client.get(reverse('web.views.visit_unfinish', args=[visit.id]), follow=True) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "Visit can't be unfinished. Only visits with one inmediate future visit (without appointments) can be unfinished.") + self.assertEqual(3, Visit.objects.count()) + original_visit = Visit.objects.get(id=visit.id) + self.assertTrue(original_visit.is_finished) + + def test_unfinish_visit_which_is_not_finished(self): + self.login_as_admin() + visit = create_visit() + + self.assertFalse(visit.is_finished) + + response = self.client.get(reverse('web.views.visit_unfinish', args=[visit.id]), follow=True) + messages = list(get_messages(response.wsgi_request)) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "The visit is not finished.") + def test_mark_as_finished_with_study_no_follow_up_rule(self): visit = create_visit() study = visit.subject.study diff --git a/smash/web/urls.py b/smash/web/urls.py index e8b61fa0aaf83e4b04cf9c3c1dc6dc908b829b10..651e74f0461c76cf28d7bfebc7d48d0ef8505ddd 100644 --- a/smash/web/urls.py +++ b/smash/web/urls.py @@ -69,6 +69,7 @@ urlpatterns = [ url(r'^visits/missing_appointments$', views.visit.visits_with_missing_appointments, name='web.views.visits_with_missing_appointments'), url(r'^visits/details/(?P<id>\d+)$', views.visit.visit_details, name='web.views.visit_details'), + url(r'^visits/unfinish/(?P<id>\d+)$', views.visit.visit_unfinish, name='web.views.visit_unfinish'), url(r'^visits/add$', views.visit.visit_add, name='web.views.visit_add'), url(r'^visits/add/(?P<subject_id>\d+)$', views.visit.visit_add, name='web.views.visit_add'), url(r'^visit/mark/(?P<id>\d+)/(?P<as_what>[A-z]+)$', views.visit.visit_mark, name='web.views.visit_mark'), diff --git a/smash/web/utils.py b/smash/web/utils.py index dd61c944e1cc6089a2e6bf32132fabdbe3dd032d..54666468190a5c15bf1562b333687f7fb192c510 100644 --- a/smash/web/utils.py +++ b/smash/web/utils.py @@ -5,6 +5,13 @@ from datetime import timedelta from web.algorithm import VerhoeffAlgorithm, LuhnAlgorithm +def get_client_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip def is_valid_social_security_number(number): if number is not None and number != '': diff --git a/smash/web/views/export.py b/smash/web/views/export.py index 82c1b68eb196a1f3fd8291f8c2ae2e54ee3de559..f6cd6b738cf9de931e7bf59acee72a5ddf1526d0 100644 --- a/smash/web/views/export.py +++ b/smash/web/views/export.py @@ -3,11 +3,11 @@ import csv import django_excel as excel from django.http import HttpResponse - +from ..utils import get_client_ip from .notifications import get_today_midnight_date from web.decorators import PermissionDecorator from . import e500_error, wrap_response -from ..models import Subject, StudySubject, Appointment, ConfigurationItem +from ..models import Subject, StudySubject, Appointment, ConfigurationItem, Worker, Provenance, Visit from web.models.constants import VISIT_SHOW_VISIT_NUMBER_FROM_ZERO from distutils.util import strtobool from web.templatetags.filters import display_visit_number @@ -32,6 +32,16 @@ def export_to_csv(request, data_type="subjects"): for row in data: writer.writerow([s.encode("utf-8") for s in row]) + worker = Worker.get_by_user(request.user) + ip = get_client_ip(request) + p = Provenance( + modification_author=worker, + modification_description=f'Export {data_type} to csv', + modified_field='', + request_path=request.path, + request_ip_addr=ip) + p.save() + return response @@ -49,6 +59,16 @@ def export_to_excel(request, data_type="subjects"): response = excel.make_response_from_array(data, 'xls', file_name=filename) response['Content-Disposition'] = 'attachment; filename="' + filename + '"' + worker = Worker.get_by_user(request.user) + ip = get_client_ip(request) + p = Provenance( + modification_author=worker, + modification_description=f'Export {data_type} to excel', + modified_field='', + request_path=request.path, + request_ip_addr=ip) + p.save() + return response diff --git a/smash/web/views/subject.py b/smash/web/views/subject.py index 1583412ae89e5cb740e9a6fc3aab8581fdf71ee1..6922008ba35fd2140b4f9444f4c07146f4807cb5 100644 --- a/smash/web/views/subject.py +++ b/smash/web/views/subject.py @@ -3,7 +3,7 @@ import logging from django.contrib import messages from django.shortcuts import redirect, get_object_or_404 - +from ..utils import get_client_ip from web.decorators import PermissionDecorator from . import wrap_response from ..forms import VisitDetailForm, SubjectAddForm, SubjectEditForm, StudySubjectAddForm, StudySubjectEditForm @@ -72,6 +72,7 @@ def subject_edit(request, id): was_resigned = study_subject.resigned old_type = study_subject.type endpoint_was_reached = study_subject.endpoint_reached + ip = get_client_ip(request) if request.method == 'POST': study_subject_form = StudySubjectEditForm(request.POST, request.FILES, instance=study_subject, was_resigned=was_resigned, prefix="study_subject", @@ -95,6 +96,8 @@ def subject_edit(request, id): modification_description = 'Worker "{}" changed study subject "{}" from "{}" to "{}"'.format(worker, study_subject.subject, old_value, new_value), modified_field = 'type', + request_path=request.path, + request_ip_addr=ip ) p.save() if subject_form.cleaned_data['dead'] and not was_dead: @@ -106,6 +109,8 @@ def subject_edit(request, id): new_value = True, modification_description = 'Worker "{}" marks subject "{}" as dead'.format(worker, study_subject.subject), modified_field = 'dead', + request_path=request.path, + request_ip_addr=ip ) study_subject.subject.mark_as_dead() p.save() @@ -118,6 +123,8 @@ def subject_edit(request, id): new_value = True, modification_description = 'Worker "{}" marks study subject "{}" as resigned from study "{}"'.format(worker, study_subject.nd_number, study_subject.study), modified_field = 'resigned', + request_path=request.path, + request_ip_addr=ip ) study_subject.mark_as_resigned() p.save() diff --git a/smash/web/views/visit.py b/smash/web/views/visit.py index d7d516cc616fe987d780c50d7d65bf34e6cda89d..6d60514fbe52ad5d4d9ff45acc062f7ce161b01d 100644 --- a/smash/web/views/visit.py +++ b/smash/web/views/visit.py @@ -1,6 +1,6 @@ # coding=utf-8 import logging - +from ..utils import get_client_ip from django.shortcuts import get_object_or_404, redirect from django.contrib import messages from .notifications import waiting_for_appointment @@ -10,6 +10,7 @@ from web.models.study_visit_list import VISIT_LIST_GENERIC, VISIT_LIST_MISSING_A from . import wrap_response from ..forms import VisitDetailForm, VisitAddForm, SubjectDetailForm, StudySubjectDetailForm from ..models import Visit, Appointment, StudySubject, MailTemplate, Worker, Provenance +from web.decorators import PermissionDecorator logger = logging.getLogger(__name__) @@ -77,6 +78,10 @@ def visit_details(request, id): languages.append(study_subject.subject.default_written_communication_language) languages.extend(study_subject.subject.languages.all()) + next_visit_appointments = [] + if displayed_visit.next_visit is not None: + next_visit_appointments = displayed_visit.next_visit.appointment_set.all() + return wrap_response(request, 'visits/details.html', { 'default_visit_duration' : study_subject.study.default_visit_duration_in_months, 'visit_form': visit_form, @@ -87,11 +92,24 @@ def visit_details(request, id): 'canFinish': can_finish, 'vid': visit_id, 'visit': displayed_visit, - 'mail_templates': MailTemplate.get_visit_mail_templates(languages)}) + 'mail_templates': MailTemplate.get_visit_mail_templates(languages), + 'next_visit': displayed_visit.next_visit, + 'next_visit_appointments': next_visit_appointments, + }) +@PermissionDecorator('delete_visit', 'visit') +def visit_unfinish(request, id): + visit = get_object_or_404(Visit, id=id) + try: + visit.unfinish() + messages.add_message(request, messages.SUCCESS, 'Visit has been unfinished.') + except ValueError as error: + messages.add_message(request, messages.ERROR, str(error)) + return redirect('web.views.visit_details', id=id) def visit_mark(request, id, as_what): visit = get_object_or_404(Visit, id=id) + ip = get_client_ip(request) if as_what == 'finished': worker = Worker.get_by_user(request.user) p = Provenance(modified_table = Visit._meta.db_table, @@ -101,6 +119,8 @@ def visit_mark(request, id, as_what): new_value = True, modification_description = 'Worker "{}" marked visit from "{}" as finished'.format(worker, visit.subject), modified_field = 'is_finished', + request_path=request.path, + request_ip_addr=ip ) visit.mark_as_finished() p.save()