diff --git a/castellum/contacts/admin.py b/castellum/contacts/admin.py
index 8f934495513d759f19bdc9f045125f50073d1170..be0886ca645f91cae9e5046837794ee1dc7d69f7 100644
--- a/castellum/contacts/admin.py
+++ b/castellum/contacts/admin.py
@@ -23,6 +23,7 @@ from django.contrib import admin
from .models import Address
from .models import Contact
+from .models import ContactCreationRequest
from .models import Street
@@ -60,4 +61,5 @@ class ContactAdmin(admin.ModelAdmin):
admin.site.register(Contact, ContactAdmin)
+admin.site.register(ContactCreationRequest)
admin.site.register(Street)
diff --git a/castellum/contacts/migrations/0010_contactcreationrequest.py b/castellum/contacts/migrations/0010_contactcreationrequest.py
new file mode 100644
index 0000000000000000000000000000000000000000..9e097bd86a0353e938f475ab3ff664a10db7ca02
--- /dev/null
+++ b/castellum/contacts/migrations/0010_contactcreationrequest.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.2.6 on 2021-08-16 15:46
+
+import castellum.utils.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contacts', '0009_phonetic_diacritics'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContactCreationRequest',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateField(auto_now=True, verbose_name='Updated at')),
+ ('subject_id', models.PositiveIntegerField(default=None, editable=False, unique=True)),
+ ('first_name', models.CharField(max_length=64, verbose_name='First name')),
+ ('last_name', models.CharField(max_length=64, verbose_name='Last name')),
+ ('title', models.CharField(blank=True, max_length=64, verbose_name='Title')),
+ ('gender', models.CharField(blank=True, choices=[('f', 'female'), ('m', 'male'), ('*', 'diverse')], max_length=1, verbose_name='Gender')),
+ ('date_of_birth', castellum.utils.fields.DateField(blank=True, null=True, verbose_name='Date of birth')),
+ ('email', models.EmailField(max_length=128, verbose_name='Email')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/castellum/contacts/models.py b/castellum/contacts/models.py
index be67c65fc791e9c31e7debbf6b941f7f9e625786..19fcf4dbc9626c658655c48be6ec975b96739b27 100644
--- a/castellum/contacts/models.py
+++ b/castellum/contacts/models.py
@@ -123,30 +123,48 @@ class ContactQuerySet(models.QuerySet):
return q
-class Contact(TimeStampedModel):
+class BaseContact(TimeStampedModel):
GENDER = [
("f", _("female")),
("m", _("male")),
("*", _("diverse")),
]
- CONTACT_METHODS = [
- ("phone", _("phone")),
- ("email", _("email")),
- ("postal", _("postal")),
- ]
subject_id = models.PositiveIntegerField(unique=True, editable=False, default=None)
first_name = models.CharField(_('First name'), max_length=64)
- first_name_phonetic = models.CharField(max_length=128, editable=False, default=None)
last_name = models.CharField(_('Last name'), max_length=64)
- last_name_phonetic = models.CharField(max_length=128, editable=False, default=None)
title = models.CharField(_('Title'), max_length=64, blank=True)
gender = models.CharField(_('Gender'), max_length=1, choices=GENDER, blank=True)
-
date_of_birth = DateField(_('Date of birth'), blank=True, null=True)
+ class Meta:
+ abstract = True
+
+ def __str__(self):
+ return self.full_name
+
+ @property
+ def full_name(self):
+ return " ".join(filter(None, [self.title, self.first_name, self.last_name]))
+
+ @property
+ def short_name(self):
+ # a bit simplistic, but should be sufficient
+ return '{}. {}'.format(self.first_name[0], self.last_name)
+
+
+class Contact(BaseContact):
+ CONTACT_METHODS = [
+ ("phone", _("phone")),
+ ("email", _("email")),
+ ("postal", _("postal")),
+ ]
+
+ first_name_phonetic = models.CharField(max_length=128, editable=False, default=None)
+ last_name_phonetic = models.CharField(max_length=128, editable=False, default=None)
+
email = models.EmailField(_('Email'), max_length=128, blank=True)
phone_number = PhoneNumberField(_('Phone number'), max_length=32, blank=True)
phone_number_alternative = PhoneNumberField(
@@ -168,9 +186,6 @@ class Contact(TimeStampedModel):
verbose_name = _('Contact data')
verbose_name_plural = _('Contact data')
- def __str__(self):
- return self.full_name
-
def get_address(self):
try:
return self.address
@@ -196,15 +211,6 @@ class Contact(TimeStampedModel):
[c for c in self.guardians.all() if not c.subject.blocked],
])
- @property
- def full_name(self):
- return " ".join(filter(None, [self.title, self.first_name, self.last_name]))
-
- @property
- def short_name(self):
- # a bit simplistic, but should be sufficient
- return '{}. {}'.format(self.first_name[0], self.last_name)
-
@cached_property
def is_guardian(self):
return self.id and self.guardian_of.exists()
@@ -270,3 +276,17 @@ class Street(models.Model):
def __str__(self):
return self.name
+
+
+class ContactCreationRequest(BaseContact):
+ email = models.EmailField(_('Email'), max_length=128)
+
+ def convert_to_real_contact(self):
+ return Contact.objects.create(
+ first_name=self.first_name,
+ last_name=self.last_name,
+ title=self.title,
+ gender=self.gender,
+ date_of_birth=self.date_of_birth,
+ email=self.email,
+ )
diff --git a/castellum/subjects/admin.py b/castellum/subjects/admin.py
index f17d37aea67121d8edc0585da95897cebc44875a..3770504156b2bd3331518d558ff9d22174e504da 100644
--- a/castellum/subjects/admin.py
+++ b/castellum/subjects/admin.py
@@ -33,6 +33,7 @@ class ConsentDocumentAdmin(admin.ModelAdmin):
admin.site.register(models.Subject, SubjectAdmin)
+admin.site.register(models.SubjectCreationRequest)
admin.site.register(models.SubjectNote)
admin.site.register(models.Consent)
admin.site.register(models.ConsentDocument, ConsentDocumentAdmin)
diff --git a/castellum/subjects/management/commands/fetch_subject_creation_requests.py b/castellum/subjects/management/commands/fetch_subject_creation_requests.py
new file mode 100644
index 0000000000000000000000000000000000000000..034dce50b9d103c6e71c0958bb17b35fafcea9c2
--- /dev/null
+++ b/castellum/subjects/management/commands/fetch_subject_creation_requests.py
@@ -0,0 +1,117 @@
+# (c) 2018-2021
+# MPIB ,
+# MPI-CBS ,
+# MPIP
+#
+# This file is part of Castellum.
+#
+# Castellum is free software; you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Castellum is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with Castellum. If not, see
+# .
+
+"""
+Depending on the service that is used to gather SubjectCreationRequests
+this script may not be flexible enough. In that case it can still serve
+as an example.
+"""
+
+from django.core.management.base import BaseCommand
+from django.forms import modelform_factory
+
+import requests
+
+from castellum.contacts.models import ContactCreationRequest
+from castellum.subjects.models import SubjectCreationRequest
+
+SubjectForm = modelform_factory(SubjectCreationRequest, fields=['external_id', 'source'])
+ContactForm = modelform_factory(ContactCreationRequest, fields=[
+ 'first_name', 'last_name', 'title', 'gender', 'date_of_birth', 'email'
+])
+
+
+def import_creationrequest(data):
+ subject_form = SubjectForm(data)
+ contact_form = ContactForm(data)
+
+ subject_valid = subject_form.is_valid()
+ contact_valid = contact_form.is_valid()
+
+ if subject_valid and contact_valid:
+ subjectcreationrequest = subject_form.save()
+ contact_form.instance.subject_id = subjectcreationrequest.pk
+ contact_form.save()
+ return subjectcreationrequest
+ else:
+ raise ValueError({**subject_form.errors, **contact_form.errors})
+
+
+def do_import(url, token):
+ r = requests.get(url, headers={'Authorization': 'token ' + token})
+ r.raise_for_status()
+
+ success = []
+ errors = []
+
+ for item in r.json():
+ try:
+ success.append(import_creationrequest(item))
+ except Exception as e:
+ errors.append(e)
+
+ return success, errors
+
+
+def do_cleanup_remote(url_template, token):
+ success = []
+ errors = []
+
+ for subject in SubjectCreationRequest.objects.exclude(external_id=''):
+ url = url_template.format(subject.external_id)
+ r = requests.delete(url, headers={'Authorization': 'token ' + token})
+ if r.status_code in [201, 404]:
+ subject.external_id = ''
+ subject.save()
+ success.append(subject)
+ else:
+ errors.append(r)
+
+ return success, errors
+
+
+class Command(BaseCommand):
+ help = 'Fetch SubjectCreationRequests from an external service.'
+
+ def add_arguments(self, parser):
+ parser.add_argument('url', help='URL to a list of subject creation requests (JSON)')
+ parser.add_argument('--token', help='Authentication token')
+ parser.add_argument('--delete-url', help=(
+ 'URL that will delete a subject creation request on DELETE. '
+ 'Use `{}` as a placeholder for the ID.'
+ ))
+
+ def handle(self, *args, **options):
+ success, errors = do_import()
+ if options['verbosity'] > 0:
+ self.stdout.write('Created {} subject creation requests'.format(len(success)))
+ if options['verbosity'] > 1:
+ for error in errors:
+ self.stderr.write(str(error))
+
+ success, errors = do_cleanup_remote()
+ if options['verbosity'] > 0:
+ self.stdout.write(
+ 'Cleaned up {} subject creation requests from remote service'.format(len(success))
+ )
+ if options['verbosity'] > 1:
+ for error in errors:
+ self.stderr.write(str(error))
diff --git a/castellum/subjects/migrations/0026_subjectcreationrequest.py b/castellum/subjects/migrations/0026_subjectcreationrequest.py
new file mode 100644
index 0000000000000000000000000000000000000000..241f275fa0e843b76d96e8fed46ad86ef9c6fc4d
--- /dev/null
+++ b/castellum/subjects/migrations/0026_subjectcreationrequest.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.2.7 on 2021-09-21 11:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('subjects', '0025_rm_availability'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SubjectCreationRequest',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateField(auto_now_add=True, verbose_name='Created at')),
+ ('updated_at', models.DateField(auto_now=True, verbose_name='Updated at')),
+ ('external_id', models.IntegerField(blank=True, null=True, unique=True, verbose_name='External ID')),
+ ('source', models.CharField(blank=True, max_length=128, verbose_name='Data source')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/castellum/subjects/models.py b/castellum/subjects/models.py
index 896dadfea72937bb9e317a5985ddb0efd5e22cf0..4d0d49ca256a1615de3fe0379ada45cf16d8191e 100644
--- a/castellum/subjects/models.py
+++ b/castellum/subjects/models.py
@@ -247,3 +247,26 @@ class ExportAnswer(models.Model):
def __str__(self):
return str(self.created_at)
+
+
+class SubjectCreationRequest(TimeStampedModel):
+ external_id = models.IntegerField(_('External ID'), blank=True, null=True, unique=True)
+ source = models.CharField(_('Data source'), max_length=128, blank=True)
+
+ @cached_property
+ def contact(self):
+ from castellum.contacts.models import ContactCreationRequest
+ return ContactCreationRequest.objects.get(subject_id=self.pk)
+
+ def delete(self):
+ self.contact.delete()
+ return super().delete()
+
+ def convert_to_real_subject(self):
+ contact = self.contact.convert_to_real_contact()
+ subject = contact.subject
+ subject.source = self.source
+ subject.save()
+ # CASTELLUM_DATE_OF_BIRTH_ATTRIBUTE_ID is synced on contact save
+ contact.save()
+ return subject
diff --git a/castellum/subjects/templates/subjects/maintenance_base.html b/castellum/subjects/templates/subjects/maintenance_base.html
index 286378706bd62ebd4120567bfd1303b5b1daf42c..8e76aab09a2d7f1bca7313670d3356ed9396a9f5 100644
--- a/castellum/subjects/templates/subjects/maintenance_base.html
+++ b/castellum/subjects/templates/subjects/maintenance_base.html
@@ -28,6 +28,9 @@
{% translate 'Reliability' %}
+
+ {% translate 'Subject creation requests' %}
+
{% translate 'Notes' %}
diff --git a/castellum/subjects/templates/subjects/maintenance_subjectcreationrequest.html b/castellum/subjects/templates/subjects/maintenance_subjectcreationrequest.html
new file mode 100644
index 0000000000000000000000000000000000000000..213642432252998368087b61b89236376620cd65
--- /dev/null
+++ b/castellum/subjects/templates/subjects/maintenance_subjectcreationrequest.html
@@ -0,0 +1,34 @@
+{% extends "subjects/maintenance_base.html" %}
+{% load i18n bootstrap4 auth %}
+
+{% block title %}{% translate 'Subject creation requests' %} · {{ block.super }}{% endblock %}
+
+{% block content %}
+ {% translate 'The following people would like to be added to the database:' %}
+
+
+
+ {% if is_paginated %}
+
+ {% bootstrap_pagination page_obj url=request.get_full_path %}
+
+ {% endif %}
+{% endblock %}
diff --git a/castellum/subjects/templates/subjects/subject_search.html b/castellum/subjects/templates/subjects/subject_search.html
index f9497b9efbe78d06858ee6b88a533c28ccb0c066..40884d202213e458430ca3f424287b753d167666 100644
--- a/castellum/subjects/templates/subjects/subject_search.html
+++ b/castellum/subjects/templates/subjects/subject_search.html
@@ -130,17 +130,22 @@
{{ error }}
{% endfor %}
{% csrf_token %}
-
- {% translate 'First name' %}
- {{ form.cleaned_data.first_name }}
+ {% if subjectcreationrequest %}
+ {% blocktranslate with name=subjectcreationrequest.contact.full_name %}Information will be taken from subject creation request "{{ name }}".{% endblocktranslate %}
+ {% else %}
+
+ {% translate 'First name' %}
+ {{ form.cleaned_data.first_name }}
- {% translate 'Last name' %}
- {{ form.cleaned_data.last_name }}
-
+ {% translate 'Last name' %}
+ {{ form.cleaned_data.last_name }}
+
+ {% endif %}
{% bootstrap_field privacy_level_form.privacy_level %}
{{ form.search.as_hidden }}
{% translate "Create new subject" %}
+
{% endif %}
diff --git a/castellum/subjects/templates/subjects/subjectdraft_confirm_delete.html b/castellum/subjects/templates/subjects/subjectdraft_confirm_delete.html
new file mode 100644
index 0000000000000000000000000000000000000000..94ba40fbdf2b90516d9c12a35e555c79507e7f53
--- /dev/null
+++ b/castellum/subjects/templates/subjects/subjectdraft_confirm_delete.html
@@ -0,0 +1,15 @@
+{% extends "subjects/maintenance_base.html" %}
+{% load static i18n %}
+
+{% block title %}{% translate "Discard subject creation request" %} · {{ block.super }}{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/castellum/subjects/urls.py b/castellum/subjects/urls.py
index 74472dd9ad4d861e3353409cb28e754cbb65610b..67725bbdf42e67fdf5ff61b64cc89d8623ab9a1f 100644
--- a/castellum/subjects/urls.py
+++ b/castellum/subjects/urls.py
@@ -33,6 +33,8 @@ from .views import MaintenanceContactView
from .views import MaintenanceDuplicatesView
from .views import MaintenanceNotesView
from .views import MaintenanceReliabilityView
+from .views import MaintenanceSubjectCreationRequestDeleteView
+from .views import MaintenanceSubjectCreationRequestView
from .views import MaintenanceWaitingView
from .views import ParticipationAddView
from .views import ParticipationDeleteView
@@ -67,6 +69,16 @@ urlpatterns = [
MaintenanceReliabilityView.as_view(),
name='maintenance-reliability',
),
+ path(
+ 'maintenance/subjectcreationrequest/',
+ MaintenanceSubjectCreationRequestView.as_view(),
+ name='maintenance-subjectcreationrequest',
+ ),
+ path(
+ 'maintenance/subjectcreationrequest//delete/',
+ MaintenanceSubjectCreationRequestDeleteView.as_view(),
+ name='maintenance-subjectcreationrequest-delete',
+ ),
path('maintenance/notes/', MaintenanceNotesView.as_view(), name='maintenance-notes'),
path('/', SubjectDetailView.as_view(), name='detail'),
path('/pseudonyms/', SubjectPseudonymsView.as_view(), name='pseudonyms'),
diff --git a/castellum/subjects/views.py b/castellum/subjects/views.py
index 5854bf2904f41e7268b5cadc6ab5bf8478c06130..b2e46a3f36d185c433d6595e1bc45c45651e9726 100644
--- a/castellum/subjects/views.py
+++ b/castellum/subjects/views.py
@@ -70,6 +70,7 @@ from .mixins import SubjectMixin
from .models import Consent
from .models import ExportAnswer
from .models import Subject
+from .models import SubjectCreationRequest
monitoring_logger = logging.getLogger('monitoring.subjects')
@@ -79,6 +80,12 @@ class SubjectSearchView(LoginRequiredMixin, FormView):
template_name = 'subjects/subject_search.html'
form_class = SearchForm
+ @cached_property
+ def subjectcreationrequest(self):
+ if 'from-request' in self.request.GET:
+ pk = self.request.GET['from-request']
+ return get_object_or_404(SubjectCreationRequest, pk=pk)
+
def get_matches(self, search):
user = self.request.user
contacts = Contact.objects.fuzzy_filter(search)
@@ -132,6 +139,8 @@ class SubjectSearchView(LoginRequiredMixin, FormView):
).values('subject_id')
).count()
+ context['subjectcreationrequest'] = self.subjectcreationrequest
+
form = kwargs.get('form')
if form and form.is_valid():
context['matches'] = self.get_matches(form.cleaned_data['search'])
@@ -159,19 +168,25 @@ class SubjectSearchView(LoginRequiredMixin, FormView):
if form.is_valid() and privacy_level_form.is_valid():
if form.cleaned_data.get('last_name'):
- contact = Contact.objects.create(
- first_name=form.cleaned_data['first_name'],
- last_name=form.cleaned_data['last_name'],
- phone_number=form.cleaned_data['phone_number'],
- email=form.cleaned_data.get('email'),
- )
- contact.subject.privacy_level = privacy_level_form.cleaned_data['privacy_level']
- contact.subject.save()
+ if 'from-request' in self.request.GET:
+ subject = self.subjectcreationrequest.convert_to_real_subject()
+ self.subjectcreationrequest.delete()
+ else:
+ contact = Contact.objects.create(
+ first_name=form.cleaned_data['first_name'],
+ last_name=form.cleaned_data['last_name'],
+ phone_number=form.cleaned_data['phone_number'],
+ email=form.cleaned_data.get('email'),
+ )
+ subject = contact.subject
+
+ subject.privacy_level = privacy_level_form.cleaned_data['privacy_level']
+ subject.save()
monitoring_logger.info('SubjectData update: {} by {}'.format(
- contact.subject_id, self.request.user.pk
+ subject.id, self.request.user.pk
))
- return redirect('subjects:detail', slug=contact.subject.slug)
+ return redirect('subjects:detail', slug=subject.slug)
else:
messages.error(request, _(
'Both first and last name must be provided in order to create a subject!'
@@ -702,6 +717,23 @@ class MaintenanceReliabilityView(BaseMaintenanceView):
)
+class MaintenanceSubjectCreationRequestView(PermissionRequiredMixin, ListView):
+ model = SubjectCreationRequest
+ paginate_by = 20
+ permission_required = 'subjects.change_subject'
+ template_name = 'subjects/maintenance_subjectcreationrequest.html'
+ tab = 'subjectcreationrequest'
+
+
+class MaintenanceSubjectCreationRequestDeleteView(PermissionRequiredMixin, DeleteView):
+ model = SubjectCreationRequest
+ permission_required = 'subjects.change_subject'
+ tab = 'subjectcreationrequest'
+
+ def get_success_url(self):
+ return reverse('subjects:maintenance-subjectcreationrequest')
+
+
class MaintenanceNotesView(BaseMaintenanceView):
template_name = 'subjects/maintenance_notes.html'
tab = 'notes'
diff --git a/tests/subjects/views/test_subject_export.py b/tests/subjects/views/test_subject_export.py
index b078dd9626e654366726bbbd20c5e95964867e62..0a429c49515db894e4f019b3bc71785398d619d3 100644
--- a/tests/subjects/views/test_subject_export.py
+++ b/tests/subjects/views/test_subject_export.py
@@ -23,6 +23,7 @@ EXCLUDED = [
'contacts.Contact.id',
'contacts.Contact.last_name_phonetic',
'contacts.Contact.subject_id',
+ 'contacts.ContactCreationRequest.',
'contacts.Street.',
'contenttypes.',
'geofilters.Geolocation.',
@@ -54,6 +55,7 @@ EXCLUDED = [
'subjects.Subject.to_be_deleted_notified',
'subjects.Subject.pseudonym',
'subjects.Subject.slug',
+ 'subjects.SubjectCreationRequest.',
# already covered by other fields (e.g. backrefs)
'subjects.SubjectNote.',
diff --git a/tests/subjects/views/test_subject_search_view.py b/tests/subjects/views/test_subject_search_view.py
index 52542517552df20c80e9c6c4afe5218f9eef6674..9b060f38567361bdbbebcd9693dcabc34d2c8bf5 100644
--- a/tests/subjects/views/test_subject_search_view.py
+++ b/tests/subjects/views/test_subject_search_view.py
@@ -2,7 +2,9 @@ import pytest
from model_bakery import baker
from castellum.contacts.models import Contact
+from castellum.contacts.models import ContactCreationRequest
from castellum.recruitment.models import Participation
+from castellum.subjects.models import SubjectCreationRequest
@pytest.mark.smoketest
@@ -149,3 +151,20 @@ def test_create_no_search(client, user):
client.force_login(user)
response = client.post('/subjects/', {'privacy_level': 0})
assert response.status_code == 200
+
+
+def test_create_from_subjectcreationrequest(client, user):
+ subject_request = baker.make(SubjectCreationRequest)
+ contact_request = baker.make(ContactCreationRequest, subject_id=subject_request.pk)
+
+ client.force_login(user)
+ response = client.post('/subjects/?from-request={}'.format(subject_request.pk), {
+ 'search': '{} {}'.format(contact_request.first_name, contact_request.last_name),
+ 'privacy_level': 0,
+ })
+
+ assert response.status_code == 302
+ assert not SubjectCreationRequest.objects.exists()
+ assert not ContactCreationRequest.objects.exists()
+ assert Contact.objects.exists()
+ assert Contact.objects.get().first_name == contact_request.first_name