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 %}
+
+{% 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 %}
{% 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 %}
+
+
+
+ {% translate 'Domain key' %} |
+ {% translate 'Domain name' %} |
+ {% translate 'Pseudonym' %} |
+
+
+
+ {% for domain in domains %}
+
+ {{ domain|display:'key' }}
+ | {{ domain|display:'name' }}
+ |
+
+ {% translate 'get pseudonym' %}
+
+ |
+
+ {% endfor %}
+
+
+{% 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