From 723156a9890b007863af5324f18e00f3dfbe67d3 Mon Sep 17 00:00:00 2001
From: Jorran de Wit <jorrandewit@outlook.com>
Date: Thu, 2 Nov 2017 12:59:54 +0100
Subject: [PATCH] Finish Affiliations/Institute construction

---
 affiliations/forms.py                         |  97 +++++++-
 affiliations/managers.py                      |  14 ++
 .../migrations/0007_auto_20171102_1256.py     |  19 ++
 affiliations/models.py                        |   4 +
 common/widgets.py                             |  96 ++++++++
 scipost/admin.py                              |   9 +-
 scipost/forms.py                              |  58 ++---
 scipost/migrations/0071_auto_20171102_0858.py |  23 ++
 .../0072_delete_affiliationobject.py          |  18 ++
 scipost/models.py                             |  14 --
 .../scipost/assets/config/preconfig.scss      |   3 +-
 scipost/static/scipost/assets/css/_form.scss  |  15 ++
 .../static/scipost/assets/css/_tables.scss    |   7 +
 scipost/static/scipost/formset.js             | 231 ++++++++++++++++++
 .../scipost/_private_info_as_table.html       |  32 ++-
 .../scipost/_public_info_as_table.html        |  25 +-
 .../templates/scipost/contributor_info.html   |  13 +-
 .../scipost/update_personal_data.html         | 134 +++++-----
 scipost/templates/tags/bootstrap/formset.html |  16 +-
 scipost/views.py                              |  18 +-
 20 files changed, 657 insertions(+), 189 deletions(-)
 create mode 100644 affiliations/managers.py
 create mode 100644 affiliations/migrations/0007_auto_20171102_1256.py
 create mode 100644 common/widgets.py
 create mode 100644 scipost/migrations/0071_auto_20171102_0858.py
 create mode 100644 scipost/migrations/0072_delete_affiliationobject.py
 create mode 100644 scipost/static/scipost/formset.js

diff --git a/affiliations/forms.py b/affiliations/forms.py
index dd8879a61..c475964a8 100644
--- a/affiliations/forms.py
+++ b/affiliations/forms.py
@@ -1,6 +1,99 @@
 from django import forms
+from django.forms import BaseModelFormSet, modelformset_factory
+# from django.db.models import F
 
-from .models import Institute
+from django_countries import countries
+from django_countries.fields import LazyTypedChoiceField
+from django_countries.widgets import CountrySelectWidget
+
+from common.widgets import DateWidget
+
+from .models import Affiliation, Institute
+
+
+class AffiliationForm(forms.ModelForm):
+    name = forms.CharField(label='* Affiliation', max_length=300)
+    country = LazyTypedChoiceField(
+        choices=countries, label='* Country', widget=CountrySelectWidget())
+
+    class Meta:
+        model = Affiliation
+        fields = (
+            'name',
+            'country',
+            'begin_date',
+            'end_date',
+        )
+        widgets = {
+            'begin_date': DateWidget(required=False),
+            'end_date': DateWidget(required=False)
+        }
+
+    class Media:
+        js = ('scipost/formset.js',)
+
+    def __init__(self, *args, **kwargs):
+        self.contributor = kwargs.pop('contributor')
+        affiliation = kwargs.get('instance')
+        if hasattr(affiliation, 'institute'):
+            institute = affiliation.institute
+            kwargs['initial'] = {
+                'name': institute.name,
+                'country': institute.country
+            }
+        super().__init__(*args, **kwargs)
+
+    def save(self, commit=True):
+        """
+        Save the Affiliation and Institute if neccessary.
+        """
+        affiliation = super().save(commit=False)
+        affiliation.contributor = self.contributor
+
+        if commit:
+            if hasattr(affiliation, 'institute') and affiliation.institute.affiliations.count() == 1:
+                # Just update if there are no other people using this Institute
+                institute = affiliation.institute
+                institute.name = self.cleaned_data['name']
+                institute.country = self.cleaned_data['country']
+                institute.save()
+            else:
+                institute, __ = Institute.objects.get_or_create(
+                    name=self.cleaned_data['name'],
+                    country=self.cleaned_data['country'])
+                affiliation.institute = institute
+            affiliation.save()
+        return affiliation
+
+
+class AffiliationsFormSet(BaseModelFormSet):
+    """
+    This formset helps update the Institutes for the Contributor at specific time periods.
+    """
+    def __init__(self, *args, **kwargs):
+        self.contributor = kwargs.pop('contributor')
+        super().__init__(*args, **kwargs)
+        self.queryset = self.contributor.affiliations.all()
+
+    def get_form_kwargs(self, *args, **kwargs):
+        kwargs = super().get_form_kwargs(*args, **kwargs)
+        kwargs['contributor'] = self.contributor
+        return kwargs
+
+    def save(self, commit=True):
+        self.deleted_objects = []
+
+        for form in self.forms:
+            form.save(commit)
+
+        # Delete Affiliations if needed
+        for form in self.deleted_forms:
+            self.deleted_objects.append(form.instance)
+            self.delete_existing(form.instance, commit=commit)
+
+
+AffiliationsFormset = modelformset_factory(Affiliation, form=AffiliationForm, can_delete=True,
+                                           formset=AffiliationsFormSet, extra=0)
 
 
 class InstituteMergeForm(forms.ModelForm):
@@ -17,6 +110,6 @@ class InstituteMergeForm(forms.ModelForm):
     def save(self, commit=True):
         old_institute = self.cleaned_data['institute']
         if commit:
-            old_institute.contributors.update(institute=self.instance)
+            Affiliation.objects.filter(institute=old_institute).update(institute=self.instance)
             old_institute.delete()
         return self.instance
diff --git a/affiliations/managers.py b/affiliations/managers.py
new file mode 100644
index 000000000..1cc7840ae
--- /dev/null
+++ b/affiliations/managers.py
@@ -0,0 +1,14 @@
+import datetime
+
+from django.db import models
+from django.db.models import Q
+
+
+class AffiliationQuerySet(models.QuerySet):
+    def active(self):
+        today = datetime.date.today()
+        return self.filter(
+            Q(begin_date__lte=today, end_date__isnull=True) |
+            Q(begin_date__isnull=True, end_date__gte=today) |
+            Q(begin_date__lte=today, end_date__gte=today) |
+            Q(begin_date__isnull=True, end_date__isnull=True))
diff --git a/affiliations/migrations/0007_auto_20171102_1256.py b/affiliations/migrations/0007_auto_20171102_1256.py
new file mode 100644
index 000000000..0f044ab10
--- /dev/null
+++ b/affiliations/migrations/0007_auto_20171102_1256.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-11-02 11:56
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('affiliations', '0006_auto_20171102_0843'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='affiliation',
+            options={'ordering': ['begin_date', 'end_date', 'institute']},
+        ),
+    ]
diff --git a/affiliations/models.py b/affiliations/models.py
index edbd131b7..fddbf443c 100644
--- a/affiliations/models.py
+++ b/affiliations/models.py
@@ -4,6 +4,7 @@ from django.urls import reverse
 from django_countries.fields import CountryField
 
 from .constants import INSTITUTE_TYPES, TYPE_UNIVERSITY
+from .managers import AffiliationQuerySet
 
 
 class Institute(models.Model):
@@ -36,8 +37,11 @@ class Affiliation(models.Model):
     begin_date = models.DateField(null=True, blank=True)
     end_date = models.DateField(null=True, blank=True)
 
+    objects = AffiliationQuerySet.as_manager()
+
     class Meta:
         default_related_name = 'affiliations'
+        ordering = ['begin_date', 'end_date', 'institute']
 
     def __str__(self):
         return '{contributor} ({institute})'.format(
diff --git a/common/widgets.py b/common/widgets.py
new file mode 100644
index 000000000..faa904bb9
--- /dev/null
+++ b/common/widgets.py
@@ -0,0 +1,96 @@
+# import calendar
+import datetime
+import re
+
+from django.forms.widgets import Widget, Select, NumberInput
+from django.utils.dates import MONTHS
+from django.utils.safestring import mark_safe
+
+__all__ = ('DateWidget',)
+
+RE_DATE = re.compile(r'(\d{4})-(\d\d?)-(\d\d?)$')
+
+
+class DateWidget(Widget):
+    """
+    A Widget that splits date input into two <select> boxes for month and year,
+    with 'day' defaulting to the first of the month.
+
+    Based on SelectDateWidget, in
+
+    django/trunk/django/forms/extras/widgets.py
+
+
+    """
+    none_value = (0, 'Month')
+    day_field = '%s_day'
+    month_field = '%s_month'
+    year_field = '%s_year'
+
+    def __init__(self, attrs=None, end=False, required=False):
+        self.attrs = attrs or {}
+        self.required = required
+        self.today = datetime.date.today()
+        self.round_to_end = end
+
+        # Month
+        self.month_choices = dict(MONTHS.items())
+        if not self.required:
+            self.month_choices[self.none_value[0]] = self.none_value[1]
+        self.month_choices = sorted(self.month_choices.items())
+
+    def sqeeze_form_group(self, html, width=4):
+        return '<div class="col-md-{width}">{html}</div>'.format(width=width, html=html)
+
+    def render(self, name, value, attrs=None):
+        try:
+            year_val, month_val, day_val = value.year, value.month, value.day
+        except AttributeError:
+            year_val = month_val = day_val = None
+            if isinstance(value, (str, bytes)):
+                match = RE_DATE.match(value)
+                if match:
+                    year_val, month_val, day_val = [int(v) for v in match.groups()]
+
+        output = []
+
+        if 'id' in self.attrs:
+            id_ = self.attrs['id']
+        else:
+            id_ = 'id_%s' % name
+
+        # Day input
+        local_attrs = self.build_attrs({'id': self.day_field % id_})
+        s = NumberInput(attrs={'class': 'form-control', 'placeholder': 'Day'})
+        select_html = s.render(self.day_field % name, day_val, local_attrs)
+        output.append(self.sqeeze_form_group(select_html))
+
+        # Month input
+        if hasattr(self, 'month_choices'):
+            local_attrs = self.build_attrs({'id': self.month_field % id_})
+            s = Select(choices=self.month_choices, attrs={'class': 'form-control'})
+            select_html = s.render(self.month_field % name, month_val, local_attrs)
+            output.append(self.sqeeze_form_group(select_html))
+
+        # Year input
+        local_attrs = self.build_attrs({'id': self.year_field % id_})
+        s = NumberInput(attrs={'class': 'form-control', 'placeholder': 'Year'})
+        select_html = s.render(self.year_field % name, year_val, local_attrs)
+        output.append(self.sqeeze_form_group(select_html))
+
+        output = '<div class="row mb-0">{0}</div>'.format(u'\n'.join(output))
+
+        return mark_safe(output)
+
+    @classmethod
+    def id_for_label(self, id_):
+        return '%s_month' % id_
+
+    def value_from_datadict(self, data, files, name):
+        y = data.get(self.year_field % name)
+        m = data.get(self.month_field % name)
+        d = data.get(self.day_field % name)
+
+        if m == "0":
+            return None
+        return '%s-%s-%s' % (y, m, d)
diff --git a/scipost/admin.py b/scipost/admin.py
index 8cfaffcf2..3b8059b73 100644
--- a/scipost/admin.py
+++ b/scipost/admin.py
@@ -6,7 +6,6 @@ from django.contrib.auth.models import User, Permission
 
 from scipost.models import Contributor, Remark,\
                            DraftInvitation,\
-                           AffiliationObject,\
                            RegistrationInvitation,\
                            AuthorshipClaim, PrecookedEmail,\
                            EditorialCollege, EditorialCollegeFellowship, UnavailabilityPeriod
@@ -177,13 +176,6 @@ class PrecookedEmailAdmin(admin.ModelAdmin):
 admin.site.register(PrecookedEmail, PrecookedEmailAdmin)
 
 
-class AffiliationObjectAdmin(admin.ModelAdmin):
-    search_fields = ['country', 'institution', 'subunit']
-
-
-admin.site.register(AffiliationObject, AffiliationObjectAdmin)
-
-
 class EditorialCollegeAdmin(admin.ModelAdmin):
     search_fields = ['discipline', 'member']
 
@@ -195,6 +187,7 @@ def college_fellow_is_active(fellow):
     '''Check if fellow is currently active.'''
     return fellow.is_active()
 
+
 class EditorialCollegeFellowshipAdminForm(forms.ModelForm):
     contributor = forms.ModelChoiceField(
         queryset=Contributor.objects.order_by('user__last_name'))
diff --git a/scipost/forms.py b/scipost/forms.py
index ed25e5e0b..6a175bb12 100644
--- a/scipost/forms.py
+++ b/scipost/forms.py
@@ -24,7 +24,7 @@ from .decorators import has_contributor
 from .models import Contributor, DraftInvitation, RegistrationInvitation,\
                     UnavailabilityPeriod, PrecookedEmail
 
-from affiliations.models import Institute
+from affiliations.models import Affiliation, Institute
 from common.forms import MonthYearWidget
 from partners.decorators import has_contact
 
@@ -58,12 +58,12 @@ class RegistrationForm(forms.Form):
                                widget=forms.TextInput(
                                     {'placeholder': 'Recommended. Get one at orcid.org'}))
     discipline = forms.ChoiceField(choices=SCIPOST_DISCIPLINES, label='* Main discipline')
-    # country_of_employment = LazyTypedChoiceField(
-    #     choices=countries, label='* Country of employment', initial='NL',
-    #     widget=CountrySelectWidget(layout=(
-    #         '{widget}<img class="country-select-flag" id="{flag_id}"'
-    #         ' style="margin: 6px 4px 0" src="{country.flag}">')))
-    # affiliation = forms.CharField(label='* Affiliation', max_length=300)
+    country_of_employment = LazyTypedChoiceField(
+        choices=countries, label='* Country of employment', initial='NL',
+        widget=CountrySelectWidget(layout=(
+            '{widget}<img class="country-select-flag" id="{flag_id}"'
+            ' style="margin: 6px 4px 0" src="{country.flag}">')))
+    affiliation = forms.CharField(label='* Affiliation', max_length=300)
     address = forms.CharField(
         label='Address', max_length=1000,
         widget=forms.TextInput({'placeholder': 'For postal correspondence'}),
@@ -116,19 +116,22 @@ class RegistrationForm(forms.Form):
             'password': self.cleaned_data['password'],
             'is_active': False
         })
-        # institute, __ = Institute.objects.get_or_create(
-        #     country=self.cleaned_data['country_of_employment'],
-        #     name=self.cleaned_data['affiliation'],
-        # )
+        institute, __ = Institute.objects.get_or_create(
+            country=self.cleaned_data['country_of_employment'],
+            name=self.cleaned_data['affiliation'],
+        )
         contributor, new = Contributor.objects.get_or_create(**{
             'user': user,
             'invitation_key': self.cleaned_data.get('invitation_key', ''),
             'title': self.cleaned_data['title'],
             'orcid_id': self.cleaned_data['orcid_id'],
             'address': self.cleaned_data['address'],
-            # 'affiliation': institute,
             'personalwebpage': self.cleaned_data['personalwebpage'],
         })
+        affiliation, __ = Affiliation.objects.get_or_create(
+            contributor=contributor,
+            institute=institute,
+        )
 
         if contributor.activation_key == '':
             # Seems redundant?
@@ -261,9 +264,6 @@ class UpdateUserDataForm(forms.ModelForm):
 
 
 class UpdatePersonalDataForm(forms.ModelForm):
-    # country_of_employment = LazyTypedChoiceField(choices=countries, widget=CountrySelectWidget())
-    # affiliation = forms.CharField(max_length=300)
-
     class Meta:
         model = Contributor
         fields = [
@@ -275,28 +275,6 @@ class UpdatePersonalDataForm(forms.ModelForm):
             'personalwebpage'
         ]
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        # self.fields['country_of_employment'].initial = self.instance.affiliation.country
-        # self.fields['affiliation'].initial = self.instance.affiliation.name
-
-    def save(self, commit=True):
-        contributor = super().save(commit)
-        # if commit:
-        #     if contributor.affiliation.contributors.count() == 1:
-        #         # Just update if there are no other people using this Affiliation
-        #         affiliation = contributor.affiliation
-        #         affiliation.name = self.cleaned_data['affiliation']
-        #         affiliation.country = self.cleaned_data['country_of_employment']
-        #         affiliation.save()
-        #     else:
-        #         affiliation, __ = Affiliation.objects.get_or_create(
-        #             name=self.cleaned_data['affiliation'],
-        #             country=self.cleaned_data['country_of_employment'])
-        #         contributor.affiliation = affiliation
-        #         contributor.save()
-        return contributor
-
     def sync_lists(self):
         """
         Pseudo U/S; do not remove
@@ -491,12 +469,6 @@ class SearchForm(HayStackSearchForm):
     start = forms.DateField(widget=MonthYearWidget(), required=False)  # Month
     end = forms.DateField(widget=MonthYearWidget(end=True), required=False)  # Month
 
-    # def __init__(self, *args, **kwargs):
-    #     super().__init__(*args, **kwargs)
-    #     models = self.fields['models'].choices
-    #     models = filter(lambda x: x[0] != 'sphinxdoc.document', models)
-    #     self.fields['models'].choices = models
-
     def search(self):
         sqs = super().search()
 
diff --git a/scipost/migrations/0071_auto_20171102_0858.py b/scipost/migrations/0071_auto_20171102_0858.py
new file mode 100644
index 000000000..2719f788a
--- /dev/null
+++ b/scipost/migrations/0071_auto_20171102_0858.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-11-02 07:58
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('scipost', '0070_remove_contributor_old_affiliation_fk'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='contributor',
+            name='old_affiliation',
+        ),
+        migrations.RemoveField(
+            model_name='contributor',
+            name='old_country_of_employment',
+        ),
+    ]
diff --git a/scipost/migrations/0072_delete_affiliationobject.py b/scipost/migrations/0072_delete_affiliationobject.py
new file mode 100644
index 000000000..3f82fd9db
--- /dev/null
+++ b/scipost/migrations/0072_delete_affiliationobject.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-11-02 11:56
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('scipost', '0071_auto_20171102_0858'),
+    ]
+
+    operations = [
+        migrations.DeleteModel(
+            name='AffiliationObject',
+        ),
+    ]
diff --git a/scipost/models.py b/scipost/models.py
index eec7395d1..4cd14cef3 100644
--- a/scipost/models.py
+++ b/scipost/models.py
@@ -61,10 +61,6 @@ class Contributor(models.Model):
         default=True,
         verbose_name="I accept to receive SciPost emails")
 
-    # U/S
-    old_country_of_employment = CountryField()
-    old_affiliation = models.CharField(max_length=300, verbose_name='affiliation')
-
     objects = ContributorManager()
 
     def __str__(self):
@@ -290,16 +286,6 @@ class PrecookedEmail(models.Model):
         return self.email_subject
 
 
-#######################
-# Affiliation Objects #
-#######################
-
-class AffiliationObject(models.Model):
-    country = CountryField()
-    institution = models.CharField(max_length=128)
-    subunit = models.CharField(max_length=128)
-
-
 ######################
 # Static info models #
 ######################
diff --git a/scipost/static/scipost/assets/config/preconfig.scss b/scipost/static/scipost/assets/config/preconfig.scss
index 7ddb10372..2712abe46 100644
--- a/scipost/static/scipost/assets/config/preconfig.scss
+++ b/scipost/static/scipost/assets/config/preconfig.scss
@@ -108,6 +108,7 @@ $font-family-base:       $font-family-sans-serif;
 
 $font-size-base: 0.8rem;
 $font-size-sm: 0.75rem;
+$font-size-lg: 1.0rem;
 
 $h1-font-size: 1.8em;
 $h2-font-size: 1.5em;
@@ -132,7 +133,7 @@ $nav-link-padding-y: 0.4rem;
 $input-border-radius: 0;
 $btn-border-radius: $base-border-radius;
 $btn-border-radius-sm: $base-border-radius;
-$btn-border-radius-lg: 0;
+$btn-border-radius-lg: $base-border-radius;
 
 
 // Block quote
diff --git a/scipost/static/scipost/assets/css/_form.scss b/scipost/static/scipost/assets/css/_form.scss
index 25694c465..bfd15ced6 100644
--- a/scipost/static/scipost/assets/css/_form.scss
+++ b/scipost/static/scipost/assets/css/_form.scss
@@ -121,3 +121,18 @@ input[type="file"] {
         }
     }
 }
+
+.formset-group {
+    .formset-form {
+        border-bottom: 1px solid #ddd;
+        padding: 1rem 0 0.5rem;
+
+        &.form-1 {
+            padding-top: 0;
+        }
+
+        &.to_be_deleted {
+            opacity: 0.2;
+        }
+    }
+}
diff --git a/scipost/static/scipost/assets/css/_tables.scss b/scipost/static/scipost/assets/css/_tables.scss
index 851ef9746..8873b93c3 100644
--- a/scipost/static/scipost/assets/css/_tables.scss
+++ b/scipost/static/scipost/assets/css/_tables.scss
@@ -21,3 +21,10 @@ table.submission td {
         min-width: 150px;
     }
 }
+
+table.contributor-info {
+    td {
+        padding: 0 0.5rem;
+        vertical-align: top;
+    }
+}
diff --git a/scipost/static/scipost/formset.js b/scipost/static/scipost/formset.js
new file mode 100644
index 000000000..e944b74a7
--- /dev/null
+++ b/scipost/static/scipost/formset.js
@@ -0,0 +1,231 @@
+/**
+ * Copyright (c) 2009, Stanislaus Madueke
+ * All rights reserved.
+ *
+ * Licensed under the New BSD License
+ * See: http://www.opensource.org/licenses/bsd-license.php
+ *
+ *
+ * Modified version specific for SciPost.org
+ */
+;(function($) {
+    $.fn.formset = function(opts)
+    {
+        var options = $.extend({}, $.fn.formset.defaults, opts),
+            flatExtraClasses = options.extraClasses.join(' '),
+            totalForms = $('#id_' + options.prefix + '-TOTAL_FORMS'),
+            maxForms = $('#id_' + options.prefix + '-MAX_NUM_FORMS'),
+            minForms = $('#id_' + options.prefix + '-MIN_NUM_FORMS'),
+            childElementSelector = 'input,select,textarea,label,div',
+            $$ = $(this),
+
+            applyExtraClasses = function(row, ndx) {
+                if (options.extraClasses) {
+                    row.removeClass(flatExtraClasses);
+                    row.addClass(options.extraClasses[ndx % options.extraClasses.length]);
+                }
+            },
+
+            updateElementIndex = function(elem, prefix, ndx) {
+                var idRegex = new RegExp(prefix + '-(\\d+|__prefix__)-'),
+                    replacement = prefix + '-' + ndx + '-';
+                if (elem.attr("for")) elem.attr("for", elem.attr("for").replace(idRegex, replacement));
+                if (elem.attr('id')) elem.attr('id', elem.attr('id').replace(idRegex, replacement));
+                if (elem.attr('name')) elem.attr('name', elem.attr('name').replace(idRegex, replacement));
+            },
+
+            hasChildElements = function(row) {
+                return row.find(childElementSelector).length > 0;
+            },
+
+            showAddButton = function() {
+                return maxForms.length == 0 ||   // For Django versions pre 1.2
+                    (maxForms.val() == '' || (maxForms.val() - totalForms.val() > 0));
+            },
+
+            /**
+            * Indicates whether delete link(s) can be displayed - when total forms > min forms
+            */
+            showDeleteLinks = function() {
+                return minForms.length == 0 ||   // For Django versions pre 1.7
+                    (minForms.val() == '' || (totalForms.val() - minForms.val() > 0));
+            },
+
+            insertDeleteLink = function(row) {
+                var delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.'),
+                    addCssSelector = $.trim(options.addCssClass).replace(/\s+/g, '.');
+
+                if (row.is('TR')) {
+                    // If the forms are laid out in table rows, insert
+                    // the remove button into the last table cell:
+                    row.children(':last').append('<a class="' + options.deleteCssClass +'" href="javascript:void(0)">' + options.deleteText + ' <i class="fa fa-trash" aria-hidden="true"></i></a>');
+                } else if (row.is('UL') || row.is('OL')) {
+                    // If they're laid out as an ordered/unordered list,
+                    // insert an <li> after the last list item:
+                    row.append('<li><a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText +' <i class="fa fa-trash" aria-hidden="true"></i></a></li>');
+                } else {
+                    // Otherwise, just insert the remove button as the
+                    // last child element of the form's container:
+                    row.append('<a class="' + options.deleteCssClass + '" href="javascript:void(0)">' + options.deleteText +' <i class="fa fa-trash" aria-hidden="true"></i></a>');
+                }
+                // Check if we're under the minimum number of forms - not to display delete link at rendering
+                if (!showDeleteLinks()){
+                    row.find('a.' + delCssSelector).hide();
+                }
+
+                row.find('a.' + delCssSelector).click(function() {
+                    var row = $(this).parents('.' + options.formCssClass),
+                        del = row.find('input:hidden[id $= "-DELETE"]'),
+                        buttonRow = row.siblings("a." + addCssSelector + ', .' + options.formCssClass + '-add'),
+                        forms;
+                    if (del.length) {
+                        // We're dealing with an inline formset.
+                        // Rather than remove this form from the DOM, we'll mark it as deleted
+                        // and hide it, then let Django handle the deleting:
+                        del.val('on');
+                        row.addClass('to_be_deleted');
+                        forms = $('.' + options.formCssClass).not(':hidden');
+                    } else {
+                        row.remove();
+                        // Update the TOTAL_FORMS count:
+                        forms = $('.' + options.formCssClass).not('.formset-custom-template');
+                        totalForms.val(forms.length);
+                    }
+                    for (var i=0, formCount=forms.length; i<formCount; i++) {
+                        // Apply `extraClasses` to form rows so they're nicely alternating:
+                        applyExtraClasses(forms.eq(i), i);
+                        if (!del.length) {
+                            // Also update names and IDs for all child controls (if this isn't
+                            // a delete-able inline formset) so they remain in sequence:
+                            forms.eq(i).find(childElementSelector).each(function() {
+                                updateElementIndex($(this), options.prefix, i);
+                            });
+                        }
+                    }
+                    // Check if we've reached the minimum number of forms - hide all delete link(s)
+                    if (!showDeleteLinks()){
+                        $('a.' + delCssSelector).each(function(){$(this).hide();});
+                    }
+                    // Check if we need to show the add button:
+                    if (buttonRow.is(':hidden') && showAddButton()) buttonRow.show();
+                    // If a post-delete callback was provided, call it with the deleted form:
+                    if (options.removed) options.removed(row);
+                    return false;
+                });
+            };
+
+        $$.each(function(i) {
+            var row = $(this),
+                del = row.find('input:checkbox[id $= "-DELETE"]');
+
+            if (del.length) {
+                // If you specify "can_delete = True" when creating an inline formset,
+                // Django adds a checkbox to each form in the formset.
+                // Replace the default checkbox with a hidden field:
+                if (del.is(':checked')) {
+                    // If an inline formset containing deleted forms fails validation, make sure
+                    // we keep the forms hidden (thanks for the bug report and suggested fix Mike)
+                    row.prepend('<input type="hidden" name="' + del.attr('name') +'" id="' + del.attr('id') +'" value="on" />');
+                    row.addClass('to_be_deleted');
+                } else {
+                    row.prepend('<input type="hidden" name="' + del.attr('name') +'" id="' + del.attr('id') +'" />');
+                }
+                // Remove the old Bootstap row of the form
+                del.parents('.form-group').remove();
+            }
+            if (hasChildElements(row)) {
+                row.addClass(options.formCssClass);
+                if (row.is(':visible')) {
+                    insertDeleteLink(row);
+                    applyExtraClasses(row, i);
+                }
+            }
+        });
+
+        if ($$.length) {
+            var hideAddButton = !showAddButton(),
+                addButton, template;
+            if (options.formTemplate) {
+                // If a form template was specified, we'll clone it to generate new form instances:
+                template = (options.formTemplate instanceof $) ? options.formTemplate : $(options.formTemplate);
+                template.removeAttr('id').addClass(options.formCssClass + ' formset-custom-template');
+                template.find(childElementSelector).each(function() {
+                    updateElementIndex($(this), options.prefix, '__prefix__');
+                });
+                insertDeleteLink(template);
+            } else {
+                // Otherwise, use the last form in the formset; this works much better if you've got
+                // extra (>= 1) forms (thnaks to justhamade for pointing this out):
+                template = $('.' + options.formCssClass + ':last').clone(true).removeAttr('id');
+                template.find('input:hidden[id $= "-DELETE"]').remove();
+                // Clear all cloned fields, except those the user wants to keep (thanks to brunogola for the suggestion):
+                template.find(childElementSelector).not(options.keepFieldValues).each(function() {
+                    var elem = $(this);
+                    // If this is a checkbox or radiobutton, uncheck it.
+                    // This fixes Issue 1, reported by Wilson.Andrew.J:
+                    if (elem.is('input:checkbox') || elem.is('input:radio')) {
+                        elem.attr('checked', false);
+                    } else {
+                        elem.val('');
+                    }
+                });
+            }
+            // FIXME: Perhaps using $.data would be a better idea?
+            options.formTemplate = template;
+
+            if ($$.is('TR')) {
+                // If forms are laid out as table rows, insert the
+                // "add" button in a new table row:
+                var numCols = $$.eq(0).children().length,   // This is a bit of an assumption :|
+                    buttonRow = $('<tr><td colspan="' + numCols + '"><a class="' + options.addCssClass + '" href="javascript:void(0)">' + options.addText + ' <i class="fa fa-plus" aria-hidden="true"></i></a></tr>')
+                                .addClass(options.formCssClass + '-add');
+                $$.parent().append(buttonRow);
+                if (hideAddButton) buttonRow.hide();
+                addButton = buttonRow.find('a');
+            } else {
+                // Otherwise, insert it immediately after the last form:
+                $$.filter(':last').after('<div class="form-group row pt-2"><div class="col-12"><a class="' + options.addCssClass + '" href="javascript:void(0)">' + options.addText + ' <i class="fa fa-plus" aria-hidden="true"></i></a></div></div>');
+                addButton = $$.filter(':last').next();
+                if (hideAddButton) addButton.hide();
+            }
+            addButton.click(function() {
+                var formCount = parseInt(totalForms.val()),
+                    row = options.formTemplate.clone(true).removeClass('formset-custom-template'),
+                    buttonRow = $($(this).parents('tr.' + options.formCssClass + '-add').get(0) || this),
+                    delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.');
+                applyExtraClasses(row, formCount);
+                row.insertBefore(buttonRow).show();
+                row.find(childElementSelector).each(function() {
+                    updateElementIndex($(this), options.prefix, formCount);
+                });
+                totalForms.val(formCount + 1);
+                // Check if we're above the minimum allowed number of forms -> show all delete link(s)
+                if (showDeleteLinks()){
+                    $('a.' + delCssSelector).each(function(){$(this).show();});
+                }
+                // Check if we've exceeded the maximum allowed number of forms:
+                if (!showAddButton()) buttonRow.hide();
+                // If a post-add callback was supplied, call it with the added form:
+                if (options.added) options.added(row);
+                return false;
+            });
+        }
+
+        return $$;
+    };
+
+    /* Setup plugin defaults */
+    $.fn.formset.defaults = {
+        prefix: 'form',                  // The form prefix for your django formset
+        formTemplate: null,              // The jQuery selection cloned to generate new form instances
+        addText: 'add another',          // Text for the add link
+        deleteText: 'remove',            // Text for the delete link
+        addCssClass: 'add-row',          // CSS class applied to the add link
+        deleteCssClass: 'delete-row',    // CSS class applied to the delete link
+        formCssClass: 'dynamic-form',    // CSS class applied to each form in a formset
+        extraClasses: [],                // Additional CSS classes, which will be applied to each form in turn
+        keepFieldValues: '',             // jQuery selector for fields whose values should be kept when the form is cloned
+        added: null,                     // Function called each time a new form is added
+        removed: null                    // Function called each time a form is deleted
+    };
+})(jQuery);
diff --git a/scipost/templates/scipost/_private_info_as_table.html b/scipost/templates/scipost/_private_info_as_table.html
index e8fc45a52..9eb68274e 100644
--- a/scipost/templates/scipost/_private_info_as_table.html
+++ b/scipost/templates/scipost/_private_info_as_table.html
@@ -1,13 +1,21 @@
-<table>
-    <tr><td>Title: </td><td>&nbsp;</td><td>{{ contributor.get_title_display }}</td></tr>
-    <tr><td>First name: </td><td>&nbsp;</td><td>{{ contributor.user.first_name }}</td></tr>
-    <tr><td>Last name: </td><td>&nbsp;</td><td>{{ contributor.user.last_name }}</td></tr>
-    <tr><td>Email: </td><td>&nbsp;</td><td>{{ contributor.user.email }}</td></tr>
-    <tr><td>ORCID id: </td><td>&nbsp;</td><td>{{ contributor.orcid_id }}</td></tr>
-    <tr><td>Country of employment: </td><td>&nbsp;</td>
-    <td>{{ contributor.affiliation.get_country_display }}</td></tr>
-    <tr><td>Affiliation: </td><td>&nbsp;</td><td>{{ contributor.affiliation.name }}</td></tr>
-    <tr><td>Address: </td><td>&nbsp;</td><td>{{ contributor.address }}</td></tr>
-    <tr><td>Personal web page: </td><td>&nbsp;</td><td>{{ contributor.personalwebpage }}</td></tr>
-    <tr><td>Accept SciPost emails: </td><td>&nbsp;</td><td>{{ contributor.accepts_SciPost_emails }}</td></tr>
+<table class="contributor-info">
+    <tr><td>Title:</td><td>{{ contributor.get_title_display }}</td></tr>
+    <tr><td>First name:</td><td>{{ contributor.user.first_name }}</td></tr>
+    <tr><td>Last name: </td><td>{{ contributor.user.last_name }}</td></tr>
+    <tr><td>Email: </td><td>{{ contributor.user.email }}</td></tr>
+    <tr><td>ORCID id: </td><td>{{ contributor.orcid_id }}</td></tr>
+    <tr>
+        <td>Affiliation(s):</td>
+        <td>
+            {% for affiliation in contributor.affiliations.active %}
+                {% if not forloop.first %}
+                    <br>
+                {% endif %}
+                {{ affiliation.institute }}
+            {% endfor %}
+        </td>
+    </tr>
+    <tr><td>Address: </td><td>{{ contributor.address }}</td></tr>
+    <tr><td>Personal web page: </td><td>{{ contributor.personalwebpage }}</td></tr>
+    <tr><td>Accept SciPost emails: </td><td>{{ contributor.accepts_SciPost_emails }}</td></tr>
 </table>
diff --git a/scipost/templates/scipost/_public_info_as_table.html b/scipost/templates/scipost/_public_info_as_table.html
index 0b141e6b2..6e80fb2c5 100644
--- a/scipost/templates/scipost/_public_info_as_table.html
+++ b/scipost/templates/scipost/_public_info_as_table.html
@@ -1,9 +1,18 @@
-<table>
-    <tr><td>Title: </td><td>&nbsp;</td><td>{{ contributor.get_title_display }}</td></tr>
-    <tr><td>First name: </td><td>&nbsp;</td><td>{{ contributor.user.first_name }}</td></tr>
-    <tr><td>Last name: </td><td>&nbsp;</td><td>{{ contributor.user.last_name }}</td></tr>
-    <tr><td>ORCID id: </td><td>&nbsp;</td><td>{{ contributor.orcid_id|default:'-' }}</td></tr>
-    <tr><td>Country of employment: </td><td>&nbsp;</td><td>{{ contributor.affiliation.get_country_display|default:'-'}}</td></tr>
-    <tr><td>Affiliation: </td><td>&nbsp;</td><td>{{ contributor.affiliation.name|default:'-' }}</td></tr>
-    <tr><td>Personal web page: </td><td>&nbsp;</td><td>{{ contributor.personalwebpage|default:'-' }}</td></tr>
+<table class="contributor-info">
+    <tr><td>Title: </td><td>{{ contributor.get_title_display }}</td></tr>
+    <tr><td>First name: </td><td>{{ contributor.user.first_name }}</td></tr>
+    <tr><td>Last name: </td><td>{{ contributor.user.last_name }}</td></tr>
+    <tr><td>ORCID id: </td><td>{{ contributor.orcid_id|default:'-' }}</td></tr>
+    <tr>
+        <td>Affiliation(s):</td>
+        <td>
+            {% for affiliation in contributor.affiliations.active %}
+                {% if not forloop.first %}
+                    <br>
+                {% endif %}
+                {{ affiliation.institute }}
+            {% endfor %}
+        </td>
+    </tr>
+    <tr><td>Personal web page: </td><td>{{ contributor.personalwebpage|default:'-' }}</td></tr>
 </table>
diff --git a/scipost/templates/scipost/contributor_info.html b/scipost/templates/scipost/contributor_info.html
index 7ee2c8988..26423a950 100644
--- a/scipost/templates/scipost/contributor_info.html
+++ b/scipost/templates/scipost/contributor_info.html
@@ -4,22 +4,13 @@
 
 {% block content %}
 
-<div class="row">
-    <div class="col-12">
-        <div class="card card-grey">
-            <div class="card-body">
-                <h1 class="card-title mb-0">Contributor info</h1>
-                <h3 class="card-subtitle text-muted">{{contributor.get_formal_display}}</h2>
-            </div>
-        </div>
-    </div>
-</div>
+<h1 class="highlight mb-4">Contributor info: {{ contributor.get_formal_display }}</h1>
 
 {% include "scipost/_public_info_as_table.html" with contributor=contributor %}
 
 <br>
 {% if contributor_publications %}
-    {# <hr>#}
+
     <div class="row">
         <div class="col-12">
             <h2 class="highlight">Publications <small><a href="javascript:;" class="ml-2" data-toggle="toggle" data-target="#mypublicationslist">View/hide publications</a></small></h2>
diff --git a/scipost/templates/scipost/update_personal_data.html b/scipost/templates/scipost/update_personal_data.html
index ab693489e..42c0a5b38 100644
--- a/scipost/templates/scipost/update_personal_data.html
+++ b/scipost/templates/scipost/update_personal_data.html
@@ -15,75 +15,28 @@
     <script>
 
     $(document).ready(function(){
+        $('select#id_discipline').on('change', function() {
+            var selection = $(this).val();
+            $("ul[id^='id_expertises_']").closest("li").hide();
 
-      switch ($('select#id_discipline').val()) {
-        case "physics":
-          $("#id_expertises_0").closest("li").show();
-          $("#id_expertises_1").closest("li").hide();
-          $("#id_expertises_2").closest("li").hide();
-          $("#id_expertises_3").closest("li").hide();
-          break;
-        case "astrophysics":
-          $("#id_expertises_0").closest("li").hide();
-          $("#id_expertises_1").closest("li").show();
-          $("#id_expertises_2").closest("li").hide();
-          $("#id_expertises_3").closest("li").hide();
-          break;
-        case "mathematics":
-          $("#id_expertises_0").closest("li").hide();
-          $("#id_expertises_1").closest("li").hide();
-          $("#id_expertises_2").closest("li").show();
-          $("#id_expertises_3").closest("li").hide();
-          break;
-        case "computerscience":
-          $("#id_expertises_0").closest("li").hide();
-          $("#id_expertises_1").closest("li").hide();
-          $("#id_expertises_2").closest("li").hide();
-          $("#id_expertises_3").closest("li").show();
-          break;
-        default:
-          $("#id_expertises_0").closest("li").show();
-          $("#id_expertises_1").closest("li").show();
-          $("#id_expertises_2").closest("li").show();
-          $("#id_expertises_3").closest("li").show();
-          break;
-      }
-
-      $('select#id_discipline').on('change', function() {
-        var selection = $(this).val();
-        switch(selection){
-        case "physics":
-          $("#id_expertises_0").closest("li").show();
-          $("#id_expertises_1").closest("li").hide();
-          $("#id_expertises_2").closest("li").hide();
-          $("#id_expertises_3").closest("li").hide();
-          break;
-        case "astrophysics":
-          $("#id_expertises_0").closest("li").hide();
-          $("#id_expertises_1").closest("li").show();
-          $("#id_expertises_2").closest("li").hide();
-          $("#id_expertises_3").closest("li").hide();
-          break;
-        case "mathematics":
-          $("#id_expertises_0").closest("li").hide();
-          $("#id_expertises_1").closest("li").hide();
-          $("#id_expertises_2").closest("li").show();
-          $("#id_expertises_3").closest("li").hide();
-          break;
-        case "computerscience":
-          $("#id_expertises_0").closest("li").hide();
-          $("#id_expertises_1").closest("li").hide();
-          $("#id_expertises_2").closest("li").hide();
-          $("#id_expertises_3").closest("li").show();
-          break;
-        default:
-          $("#id_expertises_0").closest("li").show();
-          $("#id_expertises_1").closest("li").show();
-          $("#id_expertises_2").closest("li").show();
-          $("#id_expertises_3").closest("li").show();
-          break;
-        }
-      });
+            switch(selection){
+                case "physics":
+                    $("#id_expertises_0").closest("li").show();
+                break;
+                case "astrophysics":
+                    $("#id_expertises_1").closest("li").show();
+                break;
+                case "mathematics":
+                    $("#id_expertises_2").closest("li").show();
+                break;
+                case "computerscience":
+                    $("#id_expertises_3").closest("li").show();
+                break;
+                default:
+                    $("ul[id^='id_expertises_']").closest("li").show();
+                break;
+            }
+        }).trigger('change');
 
     });
 
@@ -91,18 +44,43 @@
 {% endif %}
 
 
-<div class="row justify-content-center">
-    <div class="col-lg-10">
-        <h1 class="highlight">Update your personal data</h1>
-        <form action="{% url 'scipost:update_personal_data' %}" method="post">
-            {% csrf_token %}
-            {{user_form|bootstrap}}
+
+<form action="{% url 'scipost:update_personal_data' %}" method="post">
+    {% csrf_token %}
+    <div class="row justify-content-center">
+        <div class="col-lg-6">
+            <h1 class="mb-3">Update your personal data</h1>
+            {{ user_form|bootstrap }}
             {% if cont_form %}
-            	{{cont_form|bootstrap}}
+            	{{ cont_form|bootstrap }}
             {% endif %}
-            <input type="submit" class="btn btn-primary" value="Update" />
-        </form>
+        </div>
+        {% if institute_formset %}
+            <div class="col-lg-6">
+                <div id="institutes" class="formset-group">
+                    <h1 class="mb-3">Your Institutes</h1>
+                    {{ institute_formset.media }}
+                    {{ institute_formset|bootstrap }}
+                </div>
+                <div class="formset-form form-empty" style="display: none;">
+                    {{ institute_formset.empty_form|bootstrap }}
+                </div>
+            </div>
+        {% endif %}
+    </div>
+
+    <div class="text-center">
+        <input type="submit" class="btn btn-primary btn-lg px-3" value="Save changes" />
     </div>
-</div>
+</form>
 
+<script type="text/javascript">
+    $(function() {
+        $('form #institutes > .formset-form').formset({
+            addText: 'add new Institute',
+            deleteText: 'remove Institute',
+            formTemplate: 'form .form-empty',
+        })
+    })
+</script>
 {% endblock content %}
diff --git a/scipost/templates/tags/bootstrap/formset.html b/scipost/templates/tags/bootstrap/formset.html
index eb5722f81..0f323b43a 100644
--- a/scipost/templates/tags/bootstrap/formset.html
+++ b/scipost/templates/tags/bootstrap/formset.html
@@ -1,11 +1,13 @@
 {{ formset.management_form }}
 
 {% for form in formset %}
-  {% if classes.label == 'sr-only' %}
-      <div class="form-inline">
-      {% include "tags/bootstrap/form.html" with form=form %}
-      </div>
-  {%else%}
-      {% include "tags/bootstrap/form.html" with form=form %}
-  {% endif %}
+    <div class="formset-form form-{{ forloop.counter }}">
+      {% if classes.label == 'sr-only' %}
+          <div class="form-inline">
+            {% include "tags/bootstrap/form.html" with form=form formset=formset %}
+          </div>
+      {% else %}
+          {% include "tags/bootstrap/form.html" with form=form formset=formset %}
+      {% endif %}
+    </div>
 {% endfor %}
diff --git a/scipost/views.py b/scipost/views.py
index 9b222806a..e122018ec 100644
--- a/scipost/views.py
+++ b/scipost/views.py
@@ -35,6 +35,7 @@ from .forms import AuthenticationForm, DraftInvitationForm, UnavailabilityPeriod
                    EmailGroupMembersForm, EmailParticularForm, SendPrecookedEmailForm
 from .utils import Utils, EMAIL_FOOTER, SCIPOST_SUMMARY_FOOTER, SCIPOST_SUMMARY_FOOTER_HTML
 
+from affiliations.forms import AffiliationsFormset
 from commentaries.models import Commentary
 from comments.models import Comment
 from journals.models import Publication, Journal
@@ -880,7 +881,7 @@ def _update_personal_data_user_only(request):
     if user_form.is_valid():
         user_form.save()
         messages.success(request, 'Your personal data has been updated.')
-        return redirect(reverse('partners:dashboard'))
+        return redirect(reverse('scipost:update_personal_data'))
     context = {
         'user_form': user_form
     }
@@ -891,19 +892,26 @@ def _update_personal_data_contributor(request):
     contributor = Contributor.objects.get(user=request.user)
     user_form = UpdateUserDataForm(request.POST or None, instance=request.user)
     cont_form = UpdatePersonalDataForm(request.POST or None, instance=contributor)
-    if user_form.is_valid() and cont_form.is_valid():
+    institute_formset = AffiliationsFormset(request.POST or None, contributor=contributor)
+    if user_form.is_valid() and cont_form.is_valid() and institute_formset.is_valid():
         user_form.save()
         cont_form.save()
         cont_form.sync_lists()
+        institute_formset.save()
         if 'orcid_id' in cont_form.changed_data:
             cont_form.propagate_orcid()
         messages.success(request, 'Your personal data has been updated.')
-        return redirect(reverse('scipost:personal_page'))
+        return redirect(reverse('scipost:update_personal_data'))
     else:
         user_form = UpdateUserDataForm(instance=contributor.user)
         cont_form = UpdatePersonalDataForm(instance=contributor)
-    return render(request, 'scipost/update_personal_data.html',
-                  {'user_form': user_form, 'cont_form': cont_form})
+
+    context = {
+        'user_form': user_form,
+        'cont_form': cont_form,
+        'institute_formset': institute_formset,
+    }
+    return render(request, 'scipost/update_personal_data.html', context)
 
 
 @login_required
-- 
GitLab