diff --git a/castellum/execution/views.py b/castellum/execution/views.py index 264c9fcb30dc40b56214e39a149384d59c4811de..461716e7f06b73bf9541b7e9bbe95c897b7c7d40 100644 --- a/castellum/execution/views.py +++ b/castellum/execution/views.py @@ -48,6 +48,7 @@ from castellum.pseudonyms.forms import DomainForm from castellum.pseudonyms.forms import PseudonymForm from castellum.pseudonyms.helpers import get_pseudonym from castellum.pseudonyms.helpers import get_subject +from castellum.pseudonyms.models import Domain from castellum.recruitment.attribute_exporters import get_exporter from castellum.recruitment.mixins import BaseAttributesUpdateView from castellum.recruitment.mixins import ParticipationMixin @@ -168,7 +169,10 @@ class ParticipationDetailView(ParticipationDetailMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['domains'] = self.study.domains.all() + context['domains'] = [ + *self.study.domains.all(), + *self.study.general_domains.all(), + ] return context @@ -206,7 +210,11 @@ class ParticipationPseudonymView(ParticipationDetailMixin, View): ) def get(self, request, *args, **kwargs): - domain = get_object_or_404(self.study.domains, key=kwargs['domain']) + qs = Domain.objects.filter( + models.Q(pk__in=self.study.domains.all()) + | models.Q(pk__in=self.study.general_domains.all()) + ) + domain = get_object_or_404(qs, key=kwargs['domain']) pseudonym = get_pseudonym(self.subject, domain.key) monitoring_logger.info('Pseudonym access: domain {} by {}'.format( 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/0033_study_general_domains.py b/castellum/studies/migrations/0033_study_general_domains.py new file mode 100644 index 0000000000000000000000000000000000000000..4371c678c60c80b150e458b154affe55601dcee1 --- /dev/null +++ b/castellum/studies/migrations/0033_study_general_domains.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.5 on 2021-07-27 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pseudonyms', '0004_domain_name'), + ('studies', '0032_study_is_filter_trial'), + ] + + operations = [ + migrations.AddField( + model_name='study', + name='general_domains', + field=models.ManyToManyField(blank=True, 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 ce22f4dd644a506ead055e6c8323115c91695992..54932d29baafa794eb3303f54ee54a9f3800352c 100644 --- a/castellum/studies/models.py +++ b/castellum/studies/models.py @@ -130,6 +130,12 @@ class Study(models.Model): members = models.ManyToManyField(User, through='StudyMembership') domains = GenericRelation(Domain) + general_domains = models.ManyToManyField( + Domain, + verbose_name=_('General pseudonym domains'), + 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_domains_base.html b/castellum/studies/templates/studies/study_domains_base.html new file mode 100644 index 0000000000000000000000000000000000000000..629c32d57c713531eaf75136e68a28a3072f1b15 --- /dev/null +++ b/castellum/studies/templates/studies/study_domains_base.html @@ -0,0 +1,22 @@ +{% extends "studies/study_base.html" %} +{% load i18n auth bootstrap4 utils %} + +{% block container_class %}{% if general_domains_exist %}container-lg{% else %}container{% endif %}{% endblock %} + +{% block content_with_messages %} + {% if general_domains_exist %} +
+
+ +
+
+ {{ block.super }} +
+
+ {% else %} + {{ block.super }} + {% endif %} +{% endblock %} diff --git a/castellum/studies/templates/studies/study_domains_general.html b/castellum/studies/templates/studies/study_domains_general.html new file mode 100644 index 0000000000000000000000000000000000000000..26bb584b42b42b76f465c77cae91be7eebcc55e1 --- /dev/null +++ b/castellum/studies/templates/studies/study_domains_general.html @@ -0,0 +1,14 @@ +{% extends "studies/study_domains_base.html" %} +{% load i18n auth bootstrap4 utils %} + +{% block title %}{% translate "General domains" %} · {{ block.super }}{% endblock %} + +{% block content %} +
+ {% include 'utils/form_errors.html' with form=form %} + {% csrf_token %} +

{% translate 'General Domains allow you to access pseudonyms for data that is shared via central repositories. Please note these pseudonyms allow to identify subjects globally and therefore carry a high privacy risk.' %}

+ {% bootstrap_field form.general_domains %} + +
+{% endblock %} diff --git a/castellum/studies/templates/studies/study_pseudonym_domains.html b/castellum/studies/templates/studies/study_domains_study.html similarity index 97% rename from castellum/studies/templates/studies/study_pseudonym_domains.html rename to castellum/studies/templates/studies/study_domains_study.html index 24c0025aa5f7557af61d62db9fdf867f09d0f8f6..abdd2f94211abd80708b7d1dc484f78ae62e35e0 100644 --- a/castellum/studies/templates/studies/study_pseudonym_domains.html +++ b/castellum/studies/templates/studies/study_domains_study.html @@ -1,4 +1,4 @@ -{% extends "studies/study_base.html" %} +{% extends "studies/study_domains_base.html" %} {% load i18n auth bootstrap4 utils %} {% block title %}{% translate "Pseudonym domains" %} · {{ block.super }}{% endblock %} diff --git a/castellum/studies/urls/__init__.py b/castellum/studies/urls/__init__.py index 78ce50f23d564f88cf4c18f0f359281cb672d00c..cca8c6fbf995a2684b42883064059490b61b85a2 100644 --- a/castellum/studies/urls/__init__.py +++ b/castellum/studies/urls/__init__.py @@ -29,11 +29,12 @@ from ..views.studies import StudyCreateView from ..views.studies import StudyDeleteView from ..views.studies import StudyDetailView from ..views.studies import StudyDiffView +from ..views.studies import StudyDomainsGeneralView +from ..views.studies import StudyDomainsView from ..views.studies import StudyExportView from ..views.studies import StudyFinishRecruitmentView from ..views.studies import StudyImportView from ..views.studies import StudyIndexView -from ..views.studies import StudyPseudonymDomainsView from ..views.studies import StudyStartRecruitmentView from ..views.studies import StudyUpdateView from . import members as members_urls @@ -48,7 +49,8 @@ urlpatterns = [ path('/', StudyDetailView.as_view(), name='detail'), path('/update/', StudyUpdateView.as_view(), name='update'), path('/delete/', StudyDeleteView.as_view(), name='delete'), - path('/domains/', StudyPseudonymDomainsView.as_view(), name='domains'), + path('/domains/', StudyDomainsView.as_view(), name='domains'), + path('/domains/general', StudyDomainsGeneralView.as_view(), name='domains-general'), path('/start/', StudyStartRecruitmentView.as_view(), name='start'), path('/finish/', StudyFinishRecruitmentView.as_view(), name='finish'), path('/mail/', OneTimeInvitationMailRecruitmentView.as_view(), name='mail'), diff --git a/castellum/studies/views/studies.py b/castellum/studies/views/studies.py index 4533857db6ae379a289114ccd7056dfda63ad561..e75da6ed19ab3fa2304ed2ea0b88c429b125cb78 100644 --- a/castellum/studies/views/studies.py +++ b/castellum/studies/views/studies.py @@ -108,6 +108,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(), @@ -226,6 +227,8 @@ class StudyCreateView(PermissionRequiredMixin, CreateView): self.object.phone = self.duplicate.phone self.object.save() + self.object.general_domains.set(self.duplicate.general_domains.all()) + for session in self.duplicate.studysession_set.order_by('pk'): s = self.object.studysession_set.create( name=session.name, @@ -391,13 +394,22 @@ class StudyFinishRecruitmentView(StudyMixin, PermissionRequiredMixin, View): return redirect(get_next_url(request, reverse('studies:detail', args=[self.study.pk]))) -class StudyPseudonymDomainsView(StudyMixin, PermissionRequiredMixin, TemplateView): +class StudyDomainsMixin(StudyMixin, PermissionRequiredMixin): permission_required = 'studies.change_study' - template_name = 'studies/study_pseudonym_domains.html' is_onetime_invitation = False is_filter_trial = False 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 + + +class StudyDomainsView(StudyDomainsMixin, TemplateView): + template_name = 'studies/study_domains_study.html' + subtab = 'domains-study' + def post(self, request, *args, **kwargs): if self.request.POST.get('action') == 'add': self.study.domains.create(bits=settings.CASTELLUM_STUDY_DOMAIN_BITS) @@ -408,7 +420,22 @@ class StudyPseudonymDomainsView(StudyMixin, PermissionRequiredMixin, TemplateVie if form.is_valid(): form.save() - return redirect('studies:domains', self.study.pk) + return redirect(self.request.path) + + +class StudyDomainsGeneralView(StudyDomainsMixin, UpdateView): + model = Study + fields = ['general_domains'] + template_name = 'studies/study_domains_general.html' + subtab = 'domains-general' + + def get_success_url(self): + return self.request.path + + def form_valid(self, form): + form.instance.is_filter_trial = False + messages.success(self.request, _('Data has been saved.')) + return super().form_valid(form) 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..6253ae391914b0ae9da84ae53d4cf03749eaa0c3 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 has_pseudonym_in_general_domain %} +

+ {% translate "This subject may still have data in general domains." %} +

+ {% else %} +

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

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

{% 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,31 @@
{% endfor %} + {% if has_pseudonym_in_general_domain %} +
  • +
    +
    {% 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?" %}

    + {% if not has_participations and has_pseudonym_in_general_domain %} + + {% endif %}
    {% 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:' %}

      {% for participation in object.participation_set.all %} {% has_perm 'studies.view_study' user participation.study as can_view_study %} @@ -55,6 +55,16 @@
    {% 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..cc653013ab29311f7771e049ac049b5948e7b827 --- /dev/null +++ b/castellum/subjects/templates/subjects/subject_pseudonyms.html @@ -0,0 +1,29 @@ +{% extends "subjects/subject_base.html" %} +{% load i18n utils %} + +{% block title %}{% translate 'General pseudonyms' %} · {{ block.super }}{% endblock %} + +{% block content %} + + + + + + + + + + {% for domain in domains %} + + + + {% endfor %} + +
    {% translate 'Domain key' %}{% translate 'Domain name' %}{% translate 'Pseudonym' %}
    {{ domain|display:'key' }} + {{ domain|display:'name' }} + + + {% translate 'get pseudonym' %} + +
    +{% endblock %} diff --git a/castellum/subjects/urls.py b/castellum/subjects/urls.py index 4999de5c17b11cb581990fc606fb006fa4163346..74472dd9ad4d861e3353409cb28e754cbb65610b 100644 --- a/castellum/subjects/urls.py +++ b/castellum/subjects/urls.py @@ -41,6 +41,8 @@ from .views import ParticipationRecruitView from .views import SubjectDeleteView from .views import SubjectDetailView from .views import SubjectExportView +from .views import SubjectPseudonymsView +from .views import SubjectPseudonymView from .views import SubjectSearchView app_name = 'subjects' @@ -67,6 +69,8 @@ urlpatterns = [ ), path('maintenance/notes/', MaintenanceNotesView.as_view(), name='maintenance-notes'), path('/', SubjectDetailView.as_view(), name='detail'), + path('/pseudonyms/', SubjectPseudonymsView.as_view(), name='pseudonyms'), + path('/pseudonyms//', SubjectPseudonymView.as_view(), name='pseudonym'), 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 b9a60b3139f9cac76d41d524b0e00addfbf41814..dbfb520568f8fb74e89a6dfe2fa6ca36072ae29c 100644 --- a/castellum/subjects/views.py +++ b/castellum/subjects/views.py @@ -54,6 +54,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 @@ -209,6 +211,44 @@ 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['domains'] = Domain.objects.filter(object_id=None) + return context + + +class SubjectPseudonymView(SubjectMixin, PermissionRequiredMixin, View): + def get_object(self): + return get_object_or_404(Subject, slug=self.kwargs['slug']) + + def has_permission(self): + return ( + self.request.user.has_perm('subjects.export_subject') + or self.request.user.has_perm('subjects.delete_subject') + ) + + def get(self, request, *args, **kwargs): + domain = get_object_or_404(Domain, key=kwargs['domain'], object_id=None) + pseudonym = get_pseudonym_if_exists(self.subject, domain.key) + + monitoring_logger.info('Pseudonym access: domain {} by {}'.format( + domain.key, self.request.user.pk + )) + + return HttpResponse(pseudonym or '—') + + class SubjectDeleteView(SubjectMixin, PermissionRequiredMixin, DeleteView): model = Subject permission_required = 'subjects.delete_subject' @@ -226,6 +266,9 @@ 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['has_pseudonym_in_general_domain'] = ( + self.subject.pseudonym_set.filter(domain__object_id=None).exists() + ) return context def get_success_url(self): @@ -267,6 +310,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 ) diff --git a/docs/legacy_pseudonyms.md b/docs/pseudonyms.md similarity index 51% rename from docs/legacy_pseudonyms.md rename to docs/pseudonyms.md index 1bb05d89653eebbebf25089c647278c22094b685..43e793f218dbc46f295dc1ea882ae938e5306cc4 100644 --- a/docs/legacy_pseudonyms.md +++ b/docs/pseudonyms.md @@ -1,15 +1,32 @@ -# Legacy Pseudonyms +# Pseudonyms + +Castellum allows you to keep the subject's contact information in a +central place and use pseudonyms everywhere else. + +Subjects can have multiple pseudonyms. To know which is which, each +pseudonym is tied to a *pseudonym domain*. There can be only one +pseudonym for a subject in each domain. + +## General domains + +Most domains are linked to an object in Castellum, usually a study. But +there can also be so called *general domains* which are not linked to +anything in Castellum. These can be useful to provide pseudonyms for +external databases that are used accross studies, e.g. for blood samples +or IQ scores. + +General domains can be created in the admin UI by creating a domain that +has a blank `content_type` and `object_id`. Study coordinators can then +select which general domain they need to access in their study. + +## Legacy pseudonyms Castellum will automatically generate pseudonyms for subjects. However, when importing subjects from a different system, there may already be -existing pseudonyms that should not be lost. This document will describe +existing pseudonyms that should not be lost. This section will describe how those legacy pseudonyms can be imported into Castellum. -# Import data - -In Castellum, a subject can have multiple pseudonyms. To know which is -which, each pseudonym is tied to a *pseudonym domain*. There can be only -one pseudonym for a subject in each domain. +### Import data To import legacy pseudonyms you first need to create such a domain. After that you can create the pseudonyms themselves: @@ -29,7 +46,7 @@ After that you can create the pseudonyms themselves: pseudonym=legacy_pseudonym, ) -# Adapt validation +### Adapt validation Pseudonyms entered by users are validated. Since legacy pseudonyms will usually have a different format, you need to globally disable that