diff --git a/CHANGELOG b/CHANGELOG index deb5ceede55b54c2c9d43e2e69bebf2074972d97..69dc0ae83c02b671bdb40797d2ecfdf4851c89c2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ smasch (1.0.0~alpha.1-0) unstable; urgency=low + * improvement: added views to delete StudySubject and Subject (#354) * improvement: study subject can be configured to contain custom fields (#339) * small improvement: django command for creating admin in application (#347) diff --git a/smash/web/admin.py b/smash/web/admin.py index dceea27fc8d9814aacd3d8e90a8c0e5868d1b4a9..e1250f3ee86d1a97a1803fff5b48c124101dba3f 100644 --- a/smash/web/admin.py +++ b/smash/web/admin.py @@ -3,7 +3,7 @@ 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, \ - Voucher, VoucherType, Provenance + Voucher, VoucherType, Provenance, Subject #good tutorial #https://developer.mozilla.org/en-US/docs/Learn/Server-side/Django/Admin_site @@ -159,6 +159,7 @@ class ProvenanceAdmin(admin.ModelAdmin): # Register your models here. admin.site.register(StudySubject) +admin.site.register(Subject) admin.site.register(Visit, VisitAdmin) admin.site.register(Item) admin.site.register(Room) diff --git a/smash/web/templates/subjects/confirm_delete.html b/smash/web/templates/subjects/confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..0cceb3e9550e58058c7106870efa24b68cc24cc9 --- /dev/null +++ b/smash/web/templates/subjects/confirm_delete.html @@ -0,0 +1,89 @@ +{% extends "_base.html" %} +{% load static %} +{% load filters %} + +{% block styles %} + {{ block.super }} + <link rel="stylesheet" href="{% static 'AdminLTE/plugins/awesomplete/awesomplete.css' %}"/> + <style> + table, th, td { + border: 1px solid black; + border-collapse: collapse; + padding: 5px; + } + </style> +{% endblock styles %} + +{% block ui_active_tab %}'subjects'{% endblock ui_active_tab %} +{% block page_header %}Delete subject{% endblock page_header %} +{% block page_description %}{% endblock page_description %} + +{% block title %}{{ block.super }} - Delete subject{% endblock %} + +{% block breadcrumb %} + {% include "subjects/breadcrumb.html" %} +{% endblock breadcrumb %} + +{% block maincontent %} + + {% block content %} + <div class="row"> + <div class="col-md-12"> + <div class="box box-success"> + <div class="box-header with-border"> + <h3 class="box-title">Confirm deletion</h3> + </div> + + <form action="" method="post" class="form-horizontal">{% csrf_token %} + <div class="box-body"> + <p>Are you sure you want to delete subject "{{ object }}"?</p> + <p>Other related elements will be removed too</p> + + <h4>Deletion Summary</h4> + <table> + <tr> + <th>Name</th> + <th>Amount</th> + </tr> + {% for model_name, object_count in model_count %} + <tr> + <td>{{ model_name|capfirst }}</td> + <td>{{ object_count }}</td> + </tr> + {% endfor %} + </table> + <h4>Deletion Tree</h4> + <p> + <ul> + {{ deletable_objects|unordered_list }} + </ul> + </p> + </div><!-- /.box-body --> + <div class="box-footer"> + <div class="col-sm-6"> + <button type="submit" class="btn btn-block btn-danger">Delete</button> + </div> + <div class="col-sm-6"> + <a href="{% url 'web.views.subjects' %}" + class="btn btn-block btn-default">Cancel</a> + </div> + </div><!-- /.box-footer --> + </form> + </div> + + </div> + </div> + + {% endblock %} + + +{% endblock maincontent %} + +{% block scripts %} + {{ block.super }} + + <script src="{% static 'AdminLTE/plugins/awesomplete/awesomplete.min.js' %}"></script> + +{% endblock scripts %} + + diff --git a/smash/web/templates/subjects/confirm_delete_study_subject.html b/smash/web/templates/subjects/confirm_delete_study_subject.html new file mode 100644 index 0000000000000000000000000000000000000000..927457de84334a0818ddffc007357dd32fc38375 --- /dev/null +++ b/smash/web/templates/subjects/confirm_delete_study_subject.html @@ -0,0 +1,89 @@ +{% extends "_base.html" %} +{% load static %} +{% load filters %} + +{% block styles %} + {{ block.super }} + <link rel="stylesheet" href="{% static 'AdminLTE/plugins/awesomplete/awesomplete.css' %}"/> + <style> + table, th, td { + border: 1px solid black; + border-collapse: collapse; + padding: 5px; + } + </style> +{% endblock styles %} + +{% block ui_active_tab %}'subjects'{% endblock ui_active_tab %} +{% block page_header %}Delete study subject{% endblock page_header %} +{% block page_description %}{% endblock page_description %} + +{% block title %}{{ block.super }} - Delete study subject{% endblock %} + +{% block breadcrumb %} + {% include "subjects/breadcrumb.html" %} +{% endblock breadcrumb %} + +{% block maincontent %} + + {% block content %} + <div class="row"> + <div class="col-md-12"> + <div class="box box-success"> + <div class="box-header with-border"> + <h3 class="box-title">Confirm deletion</h3> + </div> + + <form action="" method="post" class="form-horizontal">{% csrf_token %} + <div class="box-body"> + <p>Are you sure you want to delete study subject "{{ object }}" from study "{{object.study}}"?</p> + <p>Other related elements will be removed too</p> + + <h4>Deletion Summary</h4> + <table> + <tr> + <th>Name</th> + <th>Amount</th> + </tr> + {% for model_name, object_count in model_count %} + <tr> + <td>{{ model_name|capfirst }}</td> + <td>{{ object_count }}</td> + </tr> + {% endfor %} + </table> + <h4>Deletion Tree</h4> + <p> + <ul> + {{ deletable_objects|unordered_list }} + </ul> + </p> + </div><!-- /.box-body --> + <div class="box-footer"> + <div class="col-sm-6"> + <button type="submit" class="btn btn-block btn-danger">Delete</button> + </div> + <div class="col-sm-6"> + <a href="{% url 'web.views.subjects' %}" + class="btn btn-block btn-default">Cancel</a> + </div> + </div><!-- /.box-footer --> + </form> + </div> + + </div> + </div> + + {% endblock %} + + +{% endblock maincontent %} + +{% block scripts %} + {{ block.super }} + + <script src="{% static 'AdminLTE/plugins/awesomplete/awesomplete.min.js' %}"></script> + +{% endblock scripts %} + + diff --git a/smash/web/templates/subjects/edit.html b/smash/web/templates/subjects/edit.html index 90400f575318339ac11927a1729b48bf8195b211..ad411c46a79cf3f44606a27bbd30781fa9f63bb8 100644 --- a/smash/web/templates/subjects/edit.html +++ b/smash/web/templates/subjects/edit.html @@ -61,9 +61,21 @@ {% endif %} </div> {% endfor %} - </div> + {% if "delete_subject" in permissions %} + <div class="col-md-9"></div> + <div class="col-md-2"> + <a href="{% url 'web.views.subject_delete' study_subject.subject.id %}" + class="btn btn-block btn-danger">Delete Subject</a> + </div> + <div class="col-md-1"></div> + {% endif %} </div><!-- /.box-body --> + + <div class="box-footer"> + + </div> + <div class="box-header with-border"> <h3>Subject's study details</h3> </div> @@ -84,11 +96,30 @@ {% endif %} </div> {% endfor %} - - </div> </div><!-- /.box-body --> + {% if "delete_studysubject" in permissions and n_studies > 1 %} + <div class="box-footer"> + <div class="col-sm-3"> + <button type="submit" class="btn btn-block btn-success">Save</button> + </div> + <div class="col-sm-3"> + <button id="save-and-continue" type="button" class="btn btn-block btn-success">Save and + Continue + </button> + </div> + <div class="col-sm-3"> + <a href="{% url 'web.views.subjects' %}" class="btn btn-block btn-default" + onclick="history.back()">Cancel</a> + </div> + <div class="col-md-3"> + <a href="{% url 'web.views.study_subject_delete' study_subject.id %}" + class="btn btn-block btn-danger">Delete Study Subject</a> + </div> + </div><!-- /.box-footer --> + + {% else %} <div class="box-footer"> <div class="col-sm-4"> @@ -103,7 +134,9 @@ <a href="{% url 'web.views.subjects' %}" class="btn btn-block btn-default" onclick="history.back()">Cancel</a> </div> + </div><!-- /.box-footer --> + {% endif %} </form> </div><!-- /.box --> </div><!-- /.col-md-12 --> diff --git a/smash/web/tests/view/test_subjects.py b/smash/web/tests/view/test_subjects.py index c0fb25330b8b12e4b1fff4cb35f176a1d3ac2a4a..d3c8f314dce5721fd0a4740e49d81ec8575a6576 100644 --- a/smash/web/tests/view/test_subjects.py +++ b/smash/web/tests/view/test_subjects.py @@ -6,7 +6,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from web.forms import SubjectAddForm, SubjectEditForm, StudySubjectAddForm, StudySubjectEditForm -from web.models import MailTemplate, StudySubject, StudyColumns, Visit, Provenance +from web.models import MailTemplate, StudySubject, StudyColumns, Visit, Provenance, Subject from web.models.constants import SEX_CHOICES_MALE, SUBJECT_TYPE_CHOICES_CONTROL, SUBJECT_TYPE_CHOICES_PATIENT, \ COUNTRY_AFGHANISTAN_ID, COUNTRY_OTHER_ID, MAIL_TEMPLATE_CONTEXT_SUBJECT, CUSTOM_FIELD_TYPE_FILE from web.models.custom_data import CustomStudySubjectField @@ -140,6 +140,30 @@ class SubjectsViewTests(LoggedInWithWorkerTestCase): self.assertEqual(response.status_code, 302) self.assertTrue("edit" in response.url) + def test_delete_subject(self): + self.login_as_super() + study_subject = create_study_subject() + subject = study_subject.subject + self.assertEqual(1, Subject.objects.filter(id=subject.id).count()) + self.assertEqual(1, StudySubject.objects.filter(id=study_subject.id).count()) + url = reverse('web.views.subject_delete', kwargs={'pk': subject.id}) + response = self.client.post(url) + self.assertEqual(response.status_code, 302) + self.assertEqual(0, Subject.objects.filter(id=subject.id).count()) + self.assertEqual(0, StudySubject.objects.filter(id=study_subject.id).count()) + + def test_delete_study_subject(self): + self.login_as_super() + study_subject = create_study_subject() + subject = study_subject.subject + self.assertEqual(1, Subject.objects.filter(id=subject.id).count()) + self.assertEqual(1, StudySubject.objects.filter(id=study_subject.id).count()) + url = reverse('web.views.study_subject_delete', kwargs={'pk': study_subject.id}) + response = self.client.post(url) + self.assertEqual(response.status_code, 302) + self.assertEqual(1, Subject.objects.filter(id=subject.id).count()) + self.assertEqual(0, StudySubject.objects.filter(id=study_subject.id).count()) + def create_edit_form_data_for_study_subject(self, instance: StudySubject = None): if instance is None: instance = self.study_subject diff --git a/smash/web/urls.py b/smash/web/urls.py index 7e5e12395e56808092604bb4e9d2284849f652d9..ce04e3b0b6fbcbf1101339161785ebafa384b049 100644 --- a/smash/web/urls.py +++ b/smash/web/urls.py @@ -90,6 +90,11 @@ urlpatterns = [ url(r'^subjects/subject_visit_details/(?P<id>\d+)$', views.subject.subject_visit_details, name='web.views.subject_visit_details'), url(r'^subjects/edit/(?P<id>\d+)$', views.subject.subject_edit, name='web.views.subject_edit'), + url(r'^subjects/(?P<pk>\d+)/delete$', views.subject.SubjectDeleteView.as_view(), + name='web.views.subject_delete'), + + url(r'^studysubjects/(?P<pk>\d+)/delete$', views.subject.StudySubjectDeleteView.as_view(), + name='web.views.study_subject_delete'), ######################### # RED CAP NOTIFICATIONS # diff --git a/smash/web/utils.py b/smash/web/utils.py index 54666468190a5c15bf1562b333687f7fb192c510..170f1d13e0464b33aac56bd1fada890a69716bee 100644 --- a/smash/web/utils.py +++ b/smash/web/utils.py @@ -5,6 +5,28 @@ from datetime import timedelta from web.algorithm import VerhoeffAlgorithm, LuhnAlgorithm +from django.db import models +from django.contrib.admin.utils import NestedObjects +from django.utils.text import capfirst +from django.utils.encoding import force_text +from typing import List + +def get_deleted_objects(objs: List[models.Model]): + collector = NestedObjects(using='default') + collector.collect(objs) + # + def format_callback(obj): + opts = obj._meta + no_edit_link = '%s: %s' % (capfirst(opts.verbose_name), + force_text(obj)) + return no_edit_link + # + to_delete = collector.nested(format_callback) + protected = [format_callback(obj) for obj in collector.protected] + model_count = {model._meta.verbose_name_plural: len(objs) for model, objs in collector.model_objs.items()} + # + return to_delete, model_count, protected + def get_client_ip(request): x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: diff --git a/smash/web/views/subject.py b/smash/web/views/subject.py index 2b0ddce56b84551a39bbfca87ad2d4bcc067c5e3..8151f21cab8a581af671f62ddb2a48c9a52e99cc 100644 --- a/smash/web/views/subject.py +++ b/smash/web/views/subject.py @@ -1,12 +1,17 @@ # coding=utf-8 import logging +from web.utils import get_deleted_objects +from django.db.models.deletion import Collector 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 web.models.custom_data import CustomStudySubjectField from . import wrap_response +from django.views.generic import DeleteView +from . import WrappedView +from django.urls import reverse_lazy from ..forms import VisitDetailForm, SubjectAddForm, SubjectEditForm, StudySubjectAddForm, StudySubjectEditForm from ..models import StudySubject, MailTemplate, Worker, Study, Provenance, Subject from ..models.constants import GLOBAL_STUDY_ID, SUBJECT_TYPE_CHOICES, FILE_STORAGE @@ -54,6 +59,53 @@ def subject_add(request, study_id): return wrap_response(request, 'subjects/add.html', {'study_subject_form': study_subject_form, 'subject_form': subject_form}) +#delete subject (from all studies!) +class SubjectDeleteView(DeleteView, WrappedView): + model = Subject + success_url = reverse_lazy('web.views.subjects') + template_name = 'subjects/confirm_delete.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + deletable_objects, model_count, protected = get_deleted_objects([self.object]) + context['deletable_objects'] = deletable_objects + context['model_count'] = dict(model_count).items() + context['protected'] = protected + return context + + @PermissionDecorator('delete_subject', 'subject') + def delete(self, request, *args, **kwargs): + messages.success(request, "Subject deleted") + try: + return super(SubjectDeleteView, self).delete(request, *args, **kwargs) + except: + messages.add_message(request, messages.ERROR, 'There was a problem when deleting the subject. ' + 'Contact system administrator.') + return redirect('web.views.subjects') + +#delete subject from study +class StudySubjectDeleteView(DeleteView, WrappedView): + model = StudySubject + success_url = reverse_lazy('web.views.subjects') + template_name = 'subjects/confirm_delete_study_subject.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + deletable_objects, model_count, protected = get_deleted_objects([self.object]) + context['deletable_objects'] = deletable_objects + context['model_count'] = dict(model_count).items() + context['protected'] = protected + return context + + @PermissionDecorator('delete_studysubject', 'studysubject') + def delete(self, request, *args, **kwargs): + messages.success(request, "Study Subject deleted") + try: + return super(StudySubjectDeleteView, self).delete(request, *args, **kwargs) + except: + messages.add_message(request, messages.ERROR, 'There was a problem when deleting the Study Subject. ' + 'Contact system administrator.') + return redirect('web.views.subjects') def subject_no_visits(request): return subject_list(request, SUBJECT_LIST_NO_VISIT) @@ -150,6 +202,7 @@ def subject_edit(request, id): languages.extend(study_subject.subject.languages.all()) return wrap_response(request, 'subjects/edit.html', { + 'n_studies': Study.objects.count(), 'study_subject_form': study_subject_form, 'subject_form': subject_form, 'study_subject': study_subject,