diff --git a/castellum/execution/views.py b/castellum/execution/views.py index 7113d8c8854960d152cae3318b914d549353a139..3c7c6dff1d7877e3a06effb77e0e875cb45da835 100644 --- a/castellum/execution/views.py +++ b/castellum/execution/views.py @@ -171,6 +171,8 @@ class ParticipationDetailView(ParticipationDetailMixin, DetailView): context = super().get_context_data(**kwargs) context['pseudonyms'] = [ (d, get_pseudonym(self.subject, d.key)) for d in self.study.domains.all() + ] + [ + (d, get_pseudonym(self.subject, d.key)) for d in self.study.general_domains.all() ] return context diff --git a/castellum/pseudonyms/helpers.py b/castellum/pseudonyms/helpers.py index c302526c68b111026be77b5d5b2bc66903332016..ef12f455d00dc06e6532c1a2d942882f9c89b72c 100644 --- a/castellum/pseudonyms/helpers.py +++ b/castellum/pseudonyms/helpers.py @@ -48,6 +48,14 @@ def get_pseudonym(subject, target_domain_key): return target.pseudonym +def get_pseudonym_if_exists(subject, target_domain_key): + # You should use ``get_pseudonym()`` in most cases as it does not + # leak whether a pseudony exists. + target_domain = Domain.objects.get(key=target_domain_key) + target = Pseudonym.objects.filter(subject=subject, domain=target_domain).first() + return target.pseudonym if target else None + + def delete_pseudonym(domain_key, pseudonym): p = Pseudonym.objects.get(domain__key=domain_key, pseudonym=pseudonym) p.subject = None diff --git a/castellum/studies/migrations/0032_study_general_domains.py b/castellum/studies/migrations/0032_study_general_domains.py new file mode 100644 index 0000000000000000000000000000000000000000..0dd6c966defb2ece8fe76eb8ac7b98cb00e9e6fb --- /dev/null +++ b/castellum/studies/migrations/0032_study_general_domains.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.5 on 2021-07-06 09:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pseudonyms', '0004_domain_name'), + ('studies', '0031_study_snapshot'), + ] + + operations = [ + migrations.AddField( + model_name='study', + name='general_domains', + field=models.ManyToManyField(blank=True, help_text='These are domains that are shared among studies, e.g. for central data repositories.', limit_choices_to={'object_id': None}, to='pseudonyms.Domain', verbose_name='General pseudonym domains'), + ), + ] diff --git a/castellum/studies/models.py b/castellum/studies/models.py index e9d31b8a43651515687d903d47826185e807a7ad..ddc4dc27a5e29560cf2961f29cd463f2935675e2 100644 --- a/castellum/studies/models.py +++ b/castellum/studies/models.py @@ -130,6 +130,15 @@ class Study(models.Model): members = models.ManyToManyField(User, through='StudyMembership') domains = GenericRelation(Domain) + general_domains = models.ManyToManyField( + Domain, + verbose_name=_('General pseudonym domains'), + help_text=_( + 'These are domains that are shared among studies, e.g. for central data repositories.' + ), + blank=True, + limit_choices_to={'object_id': None}, + ) advanced_filtering = models.BooleanField( _('Advanced filtering'), help_text=_( diff --git a/castellum/studies/templates/studies/study_diff.html b/castellum/studies/templates/studies/study_diff.html index 4753e012953a6b611a43874f7915138941311150..3826b4c3f9a2dc0febb108b949162660299f076f 100644 --- a/castellum/studies/templates/studies/study_diff.html +++ b/castellum/studies/templates/studies/study_diff.html @@ -55,6 +55,9 @@
{% translate 'Domains' %}
{% diff_get study 'domains' as items %}{% diff_list items %}
+
{{ study|verbose_name:'general_domains' }}
+
{% diff_get study 'general_domains' as items %}{% diff_list items %}
+
{{ study|verbose_name:'exportable_attributes' }}
{% diff_get study 'exportable_attributes' as items %}{% diff_list items %}
diff --git a/castellum/studies/templates/studies/study_pseudonym_domains.html b/castellum/studies/templates/studies/study_pseudonym_domains.html index 24c0025aa5f7557af61d62db9fdf867f09d0f8f6..5d275fb31f22be01e2ca5f2e799585aa59f1f111 100644 --- a/castellum/studies/templates/studies/study_pseudonym_domains.html +++ b/castellum/studies/templates/studies/study_pseudonym_domains.html @@ -31,4 +31,13 @@ + + {% if general_domains_exist %} +
+ {% include 'utils/form_errors.html' with form=form %} + {% csrf_token %} + {% bootstrap_field form.general_domains %} + +
+ {% endif %} {% endblock %} diff --git a/castellum/studies/views/studies.py b/castellum/studies/views/studies.py index c4a2fc69b299688ffd626313281e936b1376a7c3..e54a3898ab0bc5db8f39b913bce27f5cac358d57 100644 --- a/castellum/studies/views/studies.py +++ b/castellum/studies/views/studies.py @@ -43,7 +43,6 @@ from django.views.generic import DeleteView from django.views.generic import DetailView from django.views.generic import FormView from django.views.generic import ListView -from django.views.generic import TemplateView from django.views.generic import UpdateView from castellum.castellum_auth.mixins import PermissionRequiredMixin @@ -108,6 +107,7 @@ def take_snapshot(study): study, *study.studymembership_set.all(), *study.domains.all(), + *study.general_domains.all(), *study.studytag_set.all(), *study.studysession_set.all(), *study.subjectfiltergroup_set.all(), @@ -368,22 +368,34 @@ class StudyFinishRecruitmentView(StudyMixin, PermissionRequiredMixin, View): return redirect(get_next_url(request, reverse('studies:detail', args=[self.study.pk]))) -class StudyPseudonymDomainsView(StudyMixin, PermissionRequiredMixin, TemplateView): +class StudyPseudonymDomainsView(StudyMixin, PermissionRequiredMixin, UpdateView): + model = Study + fields = ['general_domains'] permission_required = 'studies.change_study' template_name = 'studies/study_pseudonym_domains.html' tab = 'domains' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['general_domains_exist'] = Domain.objects.filter(object_id=None).exists() + return context + + def get_success_url(self): + return reverse('studies:domains', args=[self.study.pk]) + def post(self, request, *args, **kwargs): - if self.request.POST.get('action') == 'add': + if 'action' not in self.request.POST: + return super().post(request, *args, **kwargs) + elif self.request.POST['action'] == 'add': self.study.domains.create(bits=settings.CASTELLUM_STUDY_DOMAIN_BITS) - elif self.request.POST.get('action') == 'name': + elif self.request.POST['action'] == 'name': domain = get_object_or_404(self.study.domains.all(), key=self.request.POST['domain']) form_cls = modelform_factory(Domain, fields=['name']) form = form_cls(data=self.request.POST, instance=domain) if form.is_valid(): form.save() - return redirect('studies:domains', self.study.pk) + return redirect(self.get_success_url()) class StudyImportView(PermissionRequiredMixin, FormView): diff --git a/castellum/subjects/templates/subjects/subject_confirm_delete.html b/castellum/subjects/templates/subjects/subject_confirm_delete.html index 08e11fe8fd81736f9be14f0e53cc289806b07d5c..94a77e2fadefe468931a687d5c6862430fd06902 100644 --- a/castellum/subjects/templates/subjects/subject_confirm_delete.html +++ b/castellum/subjects/templates/subjects/subject_confirm_delete.html @@ -5,19 +5,35 @@ {% block content %}
+ {% csrf_token %} + {% if is_last_guardian %}

{% translate "This subject is the only guardian of another subject. Deletion means that this subject's ward becomes unreachable!" %}

{% endif %} + {% if has_participations %}

{% translate "This subject cannot be deleted because there still is data about them in studies." %}

+ {% elif subject.pseudonym_set.exists %} +

+ {% translate "This subject does still have data outside of Castellum." %} +

+ {% else %} +

+ {% translate "This subject does not have any data in studies that would block deletion." %} +

+ {% endif %} + {% if has_participations or general_domains_exist %}
-

{% translate "Please delete all of these study participations:" %}

+ {% if has_participations %} +

{% translate "Please delete all of these study participations:" %}

+ {% endif %} +
    {% for participation in object.participation_set.all %} {% has_perm 'studies.view_study' user participation.study as can_view_study %} @@ -44,20 +60,25 @@
{% endfor %} + {% if general_domains_exist %} +
  • +
    +
    {% translate "General pseudonym domains" %}
    +
    + +
  • + {% endif %}
    - {% else %} - {% csrf_token %} -

    - {% translate "This subject does not have any data in studies that would block deletion." %} -

    -

    {% translate "Are you sure you want to permanently delete this subject and all related data?" %}

    {% endif %} +

    {% translate "Are you sure you want to permanently delete this subject and all related data?" %}

    {% translate 'Cancel' %} - +
    {% endblock %} diff --git a/castellum/subjects/templates/subjects/subject_export.html b/castellum/subjects/templates/subjects/subject_export.html index 681640ee2611bbf2df077d3a5df8c61973312207..965022f5b89801a853de3516a881d2675bb9ba68 100644 --- a/castellum/subjects/templates/subjects/subject_export.html +++ b/castellum/subjects/templates/subjects/subject_export.html @@ -28,10 +28,10 @@ {% endif %} - {% if has_participations %} + {% if has_participations or general_domains_exist %}
    -

    {% translate 'Remember to also gather information from the following study participations:' %}

    +

    {% translate 'Remember to also gather information from the following external sources:' %}

    {% endfor %} + {% if general_domains_exist %} +
  • +
    +
    {% translate "General pseudonym domains" %}
    +
    + +
  • + {% endif %}
    diff --git a/castellum/subjects/templates/subjects/subject_pseudonyms.html b/castellum/subjects/templates/subjects/subject_pseudonyms.html new file mode 100644 index 0000000000000000000000000000000000000000..ede77b9a264bd1aa165404806bc100c17055f6e3 --- /dev/null +++ b/castellum/subjects/templates/subjects/subject_pseudonyms.html @@ -0,0 +1,25 @@ +{% extends "subjects/subject_base.html" %} +{% load i18n utils %} + +{% block title %}{% translate 'General pseudonyms' %} · {{ block.super }}{% endblock %} + +{% block content %} + + + + + + + + + + {% for domain, pseudonym in pseudonyms %} + + + {% endfor %} + +
    {% translate 'Domain key' %}{% translate 'Domain name' %}{% translate 'Pseudonym' %}
    {{ domain|display:'key' }} + {{ domain|display:'name' }} + {{ pseudonym|default:'—' }} +
    +{% endblock %} diff --git a/castellum/subjects/urls.py b/castellum/subjects/urls.py index 4999de5c17b11cb581990fc606fb006fa4163346..d6d0b9021e691c1d17b505c969c8220d0761042d 100644 --- a/castellum/subjects/urls.py +++ b/castellum/subjects/urls.py @@ -41,6 +41,7 @@ from .views import ParticipationRecruitView from .views import SubjectDeleteView from .views import SubjectDetailView from .views import SubjectExportView +from .views import SubjectPseudonymsView from .views import SubjectSearchView app_name = 'subjects' @@ -67,6 +68,7 @@ urlpatterns = [ ), path('maintenance/notes/', MaintenanceNotesView.as_view(), name='maintenance-notes'), path('/', SubjectDetailView.as_view(), name='detail'), + path('/pseudonyms/', SubjectPseudonymsView.as_view(), name='pseudonyms'), path('/delete/', SubjectDeleteView.as_view(), name='delete'), path('/export/', SubjectExportView.as_view(), name='export'), path('/contact/', ContactUpdateView.as_view(), name='contact'), diff --git a/castellum/subjects/views.py b/castellum/subjects/views.py index 413e6006f4c135a80b6ca921f40f82a7d29258fd..92f142766a4fb0d835d16592789734b05637a2a7 100644 --- a/castellum/subjects/views.py +++ b/castellum/subjects/views.py @@ -52,6 +52,8 @@ from castellum.contacts.forms import SearchForm from castellum.contacts.mixins import BaseContactUpdateView from castellum.contacts.models import Address from castellum.contacts.models import Contact +from castellum.pseudonyms.helpers import get_pseudonym_if_exists +from castellum.pseudonyms.models import Domain from castellum.recruitment import filter_queries from castellum.recruitment.mixins import BaseAttributesUpdateView from castellum.recruitment.models import AttributeDescription @@ -206,6 +208,26 @@ class SubjectDetailView(SubjectMixin, PermissionRequiredMixin, DetailView): return context +class SubjectPseudonymsView(SubjectMixin, PermissionRequiredMixin, DetailView): + model = Subject + template_name = 'subjects/subject_pseudonyms.html' + tab = 'detail' + + def has_permission(self): + return ( + self.request.user.has_perm('subjects.export_subject') + or self.request.user.has_perm('subjects.delete_subject') + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['pseudonyms'] = [ + (d, get_pseudonym_if_exists(self.subject, d.key)) + for d in Domain.objects.filter(object_id=None) + ] + return context + + class SubjectDeleteView(SubjectMixin, PermissionRequiredMixin, DeleteView): model = Subject permission_required = 'subjects.delete_subject' @@ -223,6 +245,7 @@ class SubjectDeleteView(SubjectMixin, PermissionRequiredMixin, DeleteView): context = super().get_context_data(**kwargs) context['has_participations'] = self.object.participation_set.exists() context['is_last_guardian'] = self.is_last_guardian() + context['general_domains_exist'] = Domain.objects.filter(object_id=None).exists() return context def get_success_url(self): @@ -264,6 +287,7 @@ class SubjectExportView(SubjectMixin, PermissionRequiredMixin, DetailView): )) context['has_participations'] = self.object.participation_set.exists() + context['general_domains_exist'] = Domain.objects.filter(object_id=None).exists() context['appointments'] = Appointment.objects.filter( participation__subject=self.object )