From 89713a4fb7e61c5a10084a957e67646b437e3fe7 Mon Sep 17 00:00:00 2001
From: Jorran de Wit <jorrandewit@outlook.com>
Date: Tue, 27 Jun 2017 18:41:48 +0200
Subject: [PATCH] Lengthy commit, see comment

Main points added:
- File Attachments (Agreement only for now)
- Logo to Institution
- Extra ContactTypes
- Subtitle for Contact
- Contact can choose subtitle/kind during activation
- RequestContact
    - Including view/form to submit new request for Partners
    - Including processing view/form for the Admins
- More stuff....
---
 SciPost_v1/settings/local_jorran.py           |   1 +
 partners/admin.py                             |  15 ++-
 partners/constants.py                         |   9 ++
 partners/forms.py                             | 110 +++++++++++++++++-
 partners/managers.py                          |  23 +++-
 .../migrations/0023_contact_description.py    |  20 ++++
 partners/migrations/0024_contactrequest.py    |  31 +++++
 .../migrations/0025_partnersattachment.py     |  25 ++++
 .../migrations/0026_auto_20170627_1809.py     |  21 ++++
 partners/models.py                            |  53 ++++++++-
 .../partners/_contact_info_table.html         |  10 +-
 partners/templates/partners/_contact_li.html  |   5 +
 .../partners/agreements_details.html          |  30 +++--
 partners/templates/partners/dashboard.html    |  15 ++-
 partners/templates/partners/partner_edit.html |   1 +
 .../partners/partner_request_contact.html     |  32 +++++
 .../templates/partners/partners_detail.html   |  48 +++++---
 .../partners/process_contact_requests.html    |  59 ++++++++++
 partners/urls.py                              |   6 +
 partners/views.py                             |  95 ++++++++++++---
 .../commands/add_groups_and_permissions.py    |  13 ++-
 21 files changed, 561 insertions(+), 61 deletions(-)
 create mode 100644 partners/migrations/0023_contact_description.py
 create mode 100644 partners/migrations/0024_contactrequest.py
 create mode 100644 partners/migrations/0025_partnersattachment.py
 create mode 100644 partners/migrations/0026_auto_20170627_1809.py
 create mode 100644 partners/templates/partners/_contact_li.html
 create mode 100644 partners/templates/partners/partner_request_contact.html
 create mode 100644 partners/templates/partners/process_contact_requests.html

diff --git a/SciPost_v1/settings/local_jorran.py b/SciPost_v1/settings/local_jorran.py
index 1d47922ef..9740c928c 100644
--- a/SciPost_v1/settings/local_jorran.py
+++ b/SciPost_v1/settings/local_jorran.py
@@ -11,6 +11,7 @@ MIDDLEWARE_CLASSES += (
     'debug_toolbar.middleware.DebugToolbarMiddleware',
 )
 INTERNAL_IPS = ['127.0.0.1', '::1']
+DATABASES['default']['PORT'] = '5433'
 
 # Static and media
 STATIC_ROOT = '/Users/jorranwit/Develop/SciPost/scipost_v1/local_files/static/'
diff --git a/partners/admin.py b/partners/admin.py
index 533597a4c..ccb1ffbd1 100644
--- a/partners/admin.py
+++ b/partners/admin.py
@@ -2,7 +2,11 @@ from django.contrib import admin
 
 from .models import Contact, Partner, Consortium, Institution,\
                     ProspectivePartner, ProspectiveContact, ProspectivePartnerEvent,\
-                    MembershipAgreement
+                    MembershipAgreement, ContactRequest, PartnersAttachment
+
+
+class AttachmentInline(admin.TabularInline):
+    model = PartnersAttachment
 
 
 class ContactToPartnerInline(admin.TabularInline):
@@ -42,9 +46,16 @@ class PartnerAdmin(admin.ModelAdmin):
     )
 
 
+class MembershipAgreementAdmin(admin.ModelAdmin):
+    inlines = (
+        AttachmentInline,
+    )
+
+
 admin.site.register(Partner, PartnerAdmin)
 admin.site.register(Consortium)
 admin.site.register(Contact)
+admin.site.register(ContactRequest)
 admin.site.register(Institution)
 admin.site.register(ProspectivePartner, ProspectivePartnerAdmin)
-admin.site.register(MembershipAgreement)
+admin.site.register(MembershipAgreement, MembershipAgreementAdmin)
diff --git a/partners/constants.py b/partners/constants.py
index 3349c8aff..e3f6224cd 100644
--- a/partners/constants.py
+++ b/partners/constants.py
@@ -58,6 +58,15 @@ PARTNER_STATUS = (
     ('Inactive', 'Inactive'),
 )
 
+REQUEST_INITIATED = 'init'
+REQUEST_PROCESSED = 'proc'
+REQUEST_DECLINED = 'decl'
+REQUEST_STATUSES = (
+    (REQUEST_INITIATED, 'Request submitted by Contact'),
+    (REQUEST_PROCESSED, 'Processed'),
+    (REQUEST_DECLINED, 'Declined'),
+)
+
 
 CONSORTIUM_STATUS = (
     ('Prospective', 'Prospective'),
diff --git a/partners/forms.py b/partners/forms.py
index 96e898acb..b9d472fe0 100644
--- a/partners/forms.py
+++ b/partners/forms.py
@@ -10,9 +10,10 @@ from django_countries.widgets import CountrySelectWidget
 from django_countries.fields import LazyTypedChoiceField
 
 from .constants import PARTNER_KINDS, PROSPECTIVE_PARTNER_PROCESSED, CONTACT_TYPES,\
-                       PARTNER_STATUS_UPDATE
+                       PARTNER_STATUS_UPDATE, REQUEST_PROCESSED, REQUEST_DECLINED
 from .models import Partner, ProspectivePartner, ProspectiveContact, ProspectivePartnerEvent,\
-                    Institution, Contact, PartnerEvent, MembershipAgreement
+                    Institution, Contact, PartnerEvent, MembershipAgreement, ContactRequest,\
+                    PartnersAttachment
 
 from scipost.models import TITLE_CHOICES
 
@@ -22,7 +23,6 @@ class MembershipAgreementForm(forms.ModelForm):
         model = MembershipAgreement
         fields = (
             'partner',
-            # 'consortium',
             'status',
             'date_requested',
             'start_date',
@@ -56,10 +56,22 @@ class ActivationForm(forms.ModelForm):
         model = User
         fields = []
 
+    description = forms.CharField(max_length=256, label="Title", required=False,
+                                  widget=forms.TextInput(attrs={
+                                    'placeholder': 'E.g.: Legal Agent at Stanford University'}))
+    kind = forms.MultipleChoiceField(widget=forms.CheckboxSelectMultiple, label="Contact type",
+                                     choices=CONTACT_TYPES)
     password_new = forms.CharField(label='* Password', widget=forms.PasswordInput())
     password_verif = forms.CharField(label='* Verify password', widget=forms.PasswordInput(),
                                      help_text='Your password must contain at least 8 characters')
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        try:
+            self.fields['kind'].initial = self.instance.partner_contact.kind
+        except Contact.DoesNotExist:
+            pass
+
     def clean(self, *args, **kwargs):
         try:
             self.instance.partner_contact
@@ -89,6 +101,11 @@ class ActivationForm(forms.ModelForm):
         self.instance.set_password(self.cleaned_data['password_new'])
         self.instance.save()
 
+        # Set fields for Contact
+        self.instance.partner_contact.description = self.cleaned_data['description']
+        self.instance.partner_contact.kind = self.cleaned_data['kind']
+        self.instance.partner_contact.save()
+
         # Add permission groups to user
         group = Group.objects.get(name='Partners Accounts')
         self.instance.groups.add(group)
@@ -131,6 +148,53 @@ class PartnerForm(forms.ModelForm):
         self.fields['main_contact'].queryset = self.instance.contact_set.all()
 
 
+class RequestContactForm(forms.ModelForm):
+    class Meta:
+        model = ContactRequest
+        fields = (
+            'email',
+            'title',
+            'first_name',
+            'last_name',
+            'kind',
+        )
+
+
+class ProcessRequestContactForm(RequestContactForm):
+    decision = forms.ChoiceField(choices=((None, 'No decision'), ('accept', 'Accept'), ('decline', 'Decline')),
+                                 widget=forms.RadioSelect, label='Accept or Decline')
+
+    class Meta:
+        model = ContactRequest
+        fields = RequestContactForm.Meta.fields + ('partner',)
+
+    def process_request(self):
+        if self.cleaned_data['decision'] == 'accept':
+            self.instance.status = REQUEST_PROCESSED
+            self.instance.save()
+            contactForm = NewContactForm({
+                'title': self.cleaned_data['title'],
+                'email': self.cleaned_data['email'],
+                'first_name': self.cleaned_data['first_name'],
+                'last_name': self.cleaned_data['last_name'],
+                'kind': self.cleaned_data['kind'],
+            }, partner=self.cleaned_data['partner'])
+            contactForm.is_valid()
+            contactForm.save()
+        elif self.cleaned_data['decision'] == 'decline':
+            self.instance.status = REQUEST_DECLINED
+            self.instance.save()
+
+
+class RequestContactFormSet(forms.BaseModelFormSet):
+    def process_requests(self):
+        """
+        Process all requests if status is eithter accept or decline.
+        """
+        for form in self.forms:
+            form.process_request()
+
+
 class ContactForm(forms.ModelForm):
     """
     This Contact form is mainly used for editing Contact instances.
@@ -444,3 +508,43 @@ class MembershipQueryForm(forms.Form):
             '{widget}<img class="country-select-flag" id="{flag_id}"'
             ' style="margin: 6px 4px 0" src="{country.flag}">')))
     captcha = ReCaptchaField(attrs={'theme': 'clean'}, label='*Please verify to continue:')
+
+
+class PartnersAttachmentForm(forms.ModelForm):
+    class Meta:
+        model = PartnersAttachment
+        fields = (
+            'name',
+            'attachment',
+        )
+
+    def save(self, to_object, commit=True):
+        """
+        This custom save method will automatically assign the file to the object
+        given when its a valid instance type.
+        """
+        attachment = super().save(commit=False)
+
+        # Formset's might save an empty Instance
+        if not attachment.name or not attachment.attachment:
+            return None
+
+        if isinstance(to_object, MembershipAgreement):
+            attachment.agreement = to_object
+        else:
+            raise forms.ValidationError('You cannot save Attachment to this type of object.')
+        if commit:
+            attachment.save()
+        return attachment
+
+
+class PartnersAttachmentFormSet(forms.BaseModelFormSet):
+    def save(self, to_object, commit=True):
+        """
+        This custom save method will automatically assign the file to the object
+        given when its a valid instance type.
+        """
+        returns = []
+        for form in self.forms:
+            returns.append(form.save(to_object))
+        return returns
diff --git a/partners/managers.py b/partners/managers.py
index 243dbf81f..abd47528e 100644
--- a/partners/managers.py
+++ b/partners/managers.py
@@ -1,6 +1,11 @@
 from django.db import models
 
-from .constants import MEMBERSHIP_SUBMITTED, PROSPECTIVE_PARTNER_PROCESSED
+from .constants import MEMBERSHIP_SUBMITTED, PROSPECTIVE_PARTNER_PROCESSED, REQUEST_INITIATED
+
+
+class ContactRequestManager(models.Manager):
+    def awaiting_processing(self):
+        return self.filter(status=REQUEST_INITIATED)
 
 
 class ProspectivePartnerManager(models.Manager):
@@ -8,9 +13,25 @@ class ProspectivePartnerManager(models.Manager):
         return self.exclude(status=PROSPECTIVE_PARTNER_PROCESSED)
 
 
+class PartnerManager(models.Manager):
+    def my_partners(self, current_user):
+        """
+        Filter out my Partners if user is not a PartnerAdmin.
+        """
+        if current_user.has_perm('scipost.can_view_partners'):
+            return self.all()
+        return self.filter(contact=current_user.partner_contact)
+
+
 class MembershipAgreementManager(models.Manager):
     def submitted(self):
         return self.filter(status=MEMBERSHIP_SUBMITTED)
 
     def open_to_partner(self):
         return self.exclude(status=MEMBERSHIP_SUBMITTED)
+
+
+class PartnersAttachmentManager(models.Manager):
+    def my_attachments(self, current_user):
+        if current_user.has_perm('scipost.can_view_partners'):
+            return self.all()
diff --git a/partners/migrations/0023_contact_description.py b/partners/migrations/0023_contact_description.py
new file mode 100644
index 000000000..771cf824f
--- /dev/null
+++ b/partners/migrations/0023_contact_description.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.3 on 2017-06-27 06:24
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('partners', '0022_auto_20170626_2104'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='contact',
+            name='description',
+            field=models.CharField(blank=True, max_length=256),
+        ),
+    ]
diff --git a/partners/migrations/0024_contactrequest.py b/partners/migrations/0024_contactrequest.py
new file mode 100644
index 000000000..5d7d385a5
--- /dev/null
+++ b/partners/migrations/0024_contactrequest.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.3 on 2017-06-27 07:29
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import scipost.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('partners', '0023_contact_description'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ContactRequest',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('email', models.EmailField(max_length=254)),
+                ('kind', scipost.fields.ChoiceArrayField(base_field=models.CharField(choices=[('gen', 'General Contact'), ('tech', 'Technical Contact'), ('fin', 'Financial Contact'), ('leg', 'Legal Contact')], max_length=4), size=None)),
+                ('first_name', models.CharField(max_length=64)),
+                ('last_name', models.CharField(max_length=64)),
+                ('title', models.CharField(choices=[('PR', 'Prof.'), ('DR', 'Dr'), ('MR', 'Mr'), ('MRS', 'Mrs'), ('MS', 'Ms')], max_length=4)),
+                ('description', models.CharField(blank=True, max_length=256)),
+                ('status', models.CharField(choices=[('init', 'Request submitted by Contact'), ('proc', 'Processed'), ('decl', 'Declined')], default='init', max_length=4)),
+                ('partner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partners.Partner')),
+            ],
+        ),
+    ]
diff --git a/partners/migrations/0025_partnersattachment.py b/partners/migrations/0025_partnersattachment.py
new file mode 100644
index 000000000..2a99b4e6a
--- /dev/null
+++ b/partners/migrations/0025_partnersattachment.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.3 on 2017-06-27 16:08
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('partners', '0024_contactrequest'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PartnersAttachment',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('attachment', models.FileField(upload_to='UPLOADS/PARTNERS/ATTACHMENTS')),
+                ('name', models.CharField(max_length=128)),
+                ('agreement', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='partners.MembershipAgreement')),
+            ],
+        ),
+    ]
diff --git a/partners/migrations/0026_auto_20170627_1809.py b/partners/migrations/0026_auto_20170627_1809.py
new file mode 100644
index 000000000..c67c14a22
--- /dev/null
+++ b/partners/migrations/0026_auto_20170627_1809.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.3 on 2017-06-27 16:09
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('partners', '0025_partnersattachment'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='partnersattachment',
+            name='agreement',
+            field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='partners.MembershipAgreement'),
+        ),
+    ]
diff --git a/partners/models.py b/partners/models.py
index 65652eca6..3a2451424 100644
--- a/partners/models.py
+++ b/partners/models.py
@@ -22,9 +22,10 @@ from .constants import PROSPECTIVE_PARTNER_EVENT_EMAIL_SENT,\
                        PROSPECTIVE_PARTNER_UNINTERESTED,\
                        PROSPECTIVE_PARTNER_EVENT_PROMOTED,\
                        PROSPECTIVE_PARTNER_PROCESSED, CONTACT_TYPES,\
-                       PARTNER_INITIATED
+                       PARTNER_INITIATED, REQUEST_STATUSES, REQUEST_INITIATED
 
-from .managers import MembershipAgreementManager, ProspectivePartnerManager
+from .managers import MembershipAgreementManager, ProspectivePartnerManager, PartnerManager,\
+                      ContactRequestManager, PartnersAttachmentManager
 
 from scipost.constants import TITLE_CHOICES
 from scipost.fields import ChoiceArrayField
@@ -117,6 +118,27 @@ class Institution(models.Model):
         return '%s (%s)' % (self.name, self.get_kind_display())
 
 
+class ContactRequest(models.Model):
+    """
+    A ContactRequest request for a new Contact usually made by another Contact.
+    The requests are saved to this separate model to also be able to request new
+    Contact links if a Contact is already registered, but not linked to a specific Partner.
+    """
+    email = models.EmailField()
+    kind = ChoiceArrayField(models.CharField(max_length=4, choices=CONTACT_TYPES))
+    first_name = models.CharField(max_length=64)
+    last_name = models.CharField(max_length=64)
+    title = models.CharField(max_length=4, choices=TITLE_CHOICES)
+    description = models.CharField(max_length=256, blank=True)
+    partner = models.ForeignKey('partners.Partner', on_delete=models.CASCADE)
+    status = models.CharField(max_length=4, choices=REQUEST_STATUSES, default=REQUEST_INITIATED)
+
+    objects = ContactRequestManager()
+
+    def __str__(self):
+        return '%s %s %s' % (self.get_title_display(), self.first_name, self.last_name)
+
+
 class Contact(models.Model):
     """
     A Contact is a simple form of User which is meant
@@ -128,6 +150,7 @@ class Contact(models.Model):
                                 related_name='partner_contact')
     kind = ChoiceArrayField(models.CharField(max_length=4, choices=CONTACT_TYPES))
     title = models.CharField(max_length=4, choices=TITLE_CHOICES)
+    description = models.CharField(max_length=256, blank=True)
     partners = models.ManyToManyField('partners.Partner',
                                       help_text=('All Partners (+related Institutions)'
                                                  ' the Contact is related to.'))
@@ -189,6 +212,8 @@ class Partner(models.Model):
     main_contact = models.ForeignKey('partners.Contact', on_delete=models.SET_NULL,
                                      blank=True, null=True, related_name='partner_main_contact')
 
+    objects = PartnerManager()
+
     def __str__(self):
         if self.institution:
             return self.institution.acronym + ' (' + self.get_status_display() + ')'
@@ -197,6 +222,13 @@ class Partner(models.Model):
     def get_absolute_url(self):
         return reverse('partners:partner_view', args=(self.id,))
 
+    @property
+    def has_all_contacts(self):
+        """
+        Determine if Partner has all available Contact Types available.
+        """
+        raise NotImplemented
+
 
 class PartnerEvent(models.Model):
     partner = models.ForeignKey('partners.Partner', on_delete=models.CASCADE,
@@ -246,3 +278,20 @@ class MembershipAgreement(models.Model):
 
     def get_absolute_url(self):
         return reverse('partners:agreement_details', args=(self.id,))
+
+
+class PartnersAttachment(models.Model):
+    """
+    An Attachment which can (in the future) be related to a Partner, Contact, MembershipAgreement,
+    etc.
+    """
+    attachment = models.FileField(upload_to='UPLOADS/PARTNERS/ATTACHMENTS')
+    name = models.CharField(max_length=128)
+    agreement = models.ForeignKey('partners.MembershipAgreement', related_name='attachments',
+                                  blank=True)
+
+    objects = PartnersAttachmentManager()
+
+    def get_absolute_url(self):
+        if self.agreement:
+            return reverse('partners:agreement_attachments', args=(self.agreement.id, self.id))
diff --git a/partners/templates/partners/_contact_info_table.html b/partners/templates/partners/_contact_info_table.html
index e9adc3645..327605c92 100644
--- a/partners/templates/partners/_contact_info_table.html
+++ b/partners/templates/partners/_contact_info_table.html
@@ -1,6 +1,8 @@
 <table>
-    <tr><td>Title: </td><td>&nbsp;</td><td>{{ contact.get_title_display }}</td></tr>
-    <tr><td>First name: </td><td>&nbsp;</td><td>{{ contact.user.first_name }}</td></tr>
-    <tr><td>Last name: </td><td>&nbsp;</td><td>{{ contact.user.last_name }}</td></tr>
-    <tr><td>Email: </td><td>&nbsp;</td><td>{{ contact.user.email }}</td></tr>
+    <tbody>
+        <tr><td class="pr-4">Name: </td><td>&nbsp;</td><td>{{ contact.get_title_display }} {{ contact.user.first_name }} {{ contact.user.last_name }}</td></tr>
+        <tr><td class="pr-4">Description: </td><td>&nbsp;</td><td>{{ contact.description }}</td></tr>
+        <tr><td class="pr-4">Contact type: </td><td>&nbsp;</td><td>{{ contact.kind_display }}</td></tr>
+        <tr><td class="pr-4">Email: </td><td>&nbsp;</td><td>{{ contact.user.email }}</td></tr>
+    </tbody>
 </table>
diff --git a/partners/templates/partners/_contact_li.html b/partners/templates/partners/_contact_li.html
new file mode 100644
index 000000000..0d0885f6d
--- /dev/null
+++ b/partners/templates/partners/_contact_li.html
@@ -0,0 +1,5 @@
+<li>
+    <h4>{{ contact.get_title_display }} {{ contact.user.first_name }} {{ contact.user.last_name }} {% if not contact.user.is_active %}<span class="label label-sm label-warning">Inactive</span>{% endif %}</h4>
+    <div>({{ contact.kind_display }})</div>
+    <div class="mb-2"><a href="mailto:{{ contact.user.email }}">{{ contact.user.email }}</a></div>
+</li>
diff --git a/partners/templates/partners/agreements_details.html b/partners/templates/partners/agreements_details.html
index b7971b72d..e522ea8ab 100644
--- a/partners/templates/partners/agreements_details.html
+++ b/partners/templates/partners/agreements_details.html
@@ -21,17 +21,31 @@
     <div class="col-md-6">
         <h2>Membership Agreement</h2>
         {% include 'partners/_agreement_table.html' with agreement=agreement %}
+
+        <h3>Attachments</h3>
+        <ul>
+            {% for file in agreement.attachments.all %}
+                <li><a href="{{file.get_absolute_url}}" target="_blank">{{file.name}}</a></li>
+            {% empty %}
+                <li>No Attachments found.</li>
+            {% endfor %}
+        </ul>
     </div>
 
-    <div class="col-12">
-        <h2>Update Agreement</h2>
-      <form method="post">
-        {% csrf_token %}
-        {{ form|bootstrap }}
+    {% if perms.scipost.can_manage_SPB %}
+        <div class="col-12">
+            <h2>Update Agreement</h2>
+          <form method="post" enctype="multipart/form-data">
+            {% csrf_token %}
+            {{ form|bootstrap }}
 
-        <input class="btn btn-primary" type="submit" value="Update"/>
-      </form>
-    </div>
+            <h3>Attachments</h3>
+            {{ attachment_formset|bootstrap }}
+
+            <input class="btn btn-primary" type="submit" value="Update"/>
+          </form>
+        </div>
+    {% endif %}
 </div>
 
 {% endblock content %}
diff --git a/partners/templates/partners/dashboard.html b/partners/templates/partners/dashboard.html
index 48809ccd7..1a79ef615 100644
--- a/partners/templates/partners/dashboard.html
+++ b/partners/templates/partners/dashboard.html
@@ -55,6 +55,14 @@
             <div class="col-md-6">
                 <h3>Your personal details:</h3>
                 {% include "partners/_contact_info_table.html" with contact=request.user.partner_contact %}
+
+                {% if perms.scipost.can_manage_SPB %}
+                <h3 class="mt-4">Administrative actions</h3>
+                <ul>
+                    <li><a href="{% url 'partners:process_contact_requests' %}">Open Contact requests</a> ({{contact_requests_count}})</li>
+                    <li>Contacts awaiting validation ({{inactivate_contacts_count}})</a></li>
+                </ul>
+                {% endif %}
             </div>
             <div class="col-md-6">
                 <h3 class="mb-2">My Partners</h3>
@@ -63,8 +71,11 @@
                         <li class="media mb-2">
                             <img class="d-flex mr-3" width="64" src="{% if partner.institution.logo %}{{partner.institution.logo.url}}{% endif %}" alt="Partner Logo">
                             <div class="media-body">
-                                <h3 class="mt-0 mb-1"><strong>{{partner.institution.name}}</strong></h3>
-                                {{partner.institution.acronym}} ({{partner.institution.get_kind_display}})
+                                <h3 class="mt-0"><strong>{{partner.institution.name}}</strong></h3>
+                                <p>
+                                    {{partner.institution.acronym}} ({{partner.institution.get_kind_display}})<br>
+                                    <a href="{{partner.get_absolute_url}}">View/edit</a>
+                                </p>
                             </div>
                         </li>
                     {% empty %}
diff --git a/partners/templates/partners/partner_edit.html b/partners/templates/partners/partner_edit.html
index 434e5832b..e074cb3ef 100644
--- a/partners/templates/partners/partner_edit.html
+++ b/partners/templates/partners/partner_edit.html
@@ -2,6 +2,7 @@
 
 {% block breadcrumb_items %}
     {{block.super}}
+    <a href="{{form.instance.get_absolute_url}}" class="breadcrumb-item">Partner details</a>
     <span class="breadcrumb-item">Edit Partner</span>
 {% endblock %}
 
diff --git a/partners/templates/partners/partner_request_contact.html b/partners/templates/partners/partner_request_contact.html
new file mode 100644
index 000000000..1ef1498a5
--- /dev/null
+++ b/partners/templates/partners/partner_request_contact.html
@@ -0,0 +1,32 @@
+{% extends 'partners/_partners_page_base.html' %}
+
+{% block breadcrumb_items %}
+    {{block.super}}
+    <a href="{{partner.get_absolute_url}}" class="breadcrumb-item">Partner details</a>
+    <span class="breadcrumb-item">Request new Contact</span>
+{% endblock %}
+
+{% block pagetitle %}{{block.super}} Request new Contact{% endblock pagetitle %}
+
+{% load bootstrap %}
+
+{% block content %}
+
+<div class="row">
+    <div class="col-12">
+        <h1 class="highlight">Request new Contact for Partner {{partner}}</h1>
+    </div>
+</div>
+
+<div class="row">
+    <div class="col-12">
+      <form method="post">
+        {% csrf_token %}
+        {{ form|bootstrap }}
+
+        <input class="btn btn-primary" type="submit" value="Submit"/>
+      </form>
+    </div>
+</div>
+
+{% endblock content %}
diff --git a/partners/templates/partners/partners_detail.html b/partners/templates/partners/partners_detail.html
index 797f9359a..da9797ea4 100644
--- a/partners/templates/partners/partners_detail.html
+++ b/partners/templates/partners/partners_detail.html
@@ -19,7 +19,9 @@
 
 <div class="row">
     <div class="col-md-6">
-        <a href="{% url 'partners:partner_edit' partner.id %}">Edit partner</a>
+        {% if perms.scipost.can_manage_SPB %}
+            <a href="{% url 'partners:partner_edit' partner.id %}">Edit partner</a>
+        {% endif %}
         <address>
             <h3>{{ partner.institution.name }}</h3>
             <strong>{{ partner.institution.acronym }} ({{ partner.institution.get_kind_display }})</strong><br>
@@ -40,32 +42,40 @@
         <h3>Contacts</h3>
         <ul>
             {% for contact in partner.contact_set.all %}
-                <li>
-                    <h4>{{ contact.get_title_display }} {{ contact.user.first_name }} {{ contact.user.last_name }} {% if not contact.user.is_active %}<span class="label label-sm label-warning">Inactive</span>{% endif %}</h4>
-                    <div>({{ contact.kind_display }})</div>
-                    <div class="mb-2"><a href="mailto:{{ contact.user.email }}">{{ contact.user.email }}</a></div>
-                </li>
+                {% include 'partners/_contact_li.html' with contact=contact %}
             {% endfor %}
         </ul>
-    </div>
-    <div class="col-md-6">
-        <h3>Partner Events</h3>
+
+        <h3>Requested Contacts</h3>
         <ul>
-            {% for event in partner.events.all %}
-                {% include 'partners/_prospartner_event_li.html' with event=event %}
+            {% for contact_request in partner.contactrequest_set.awaiting_processing %}
+                <li><strong>{{contact_request}}</strong> ({{contact_request.email}})</li>
             {% empty %}
-                <li>No events were found.</li>
+                <li>All requests are processed</li>
             {% endfor %}
         </ul>
+        <a href="{% url 'partners:partner_request_contact' partner.id %}">Request new Contact</a>
+    </div>
+    <div class="col-md-6">
+        {% if perms.scipost.can_view_partners %}
+            <h3>Partner Events</h3>
+            <ul>
+                {% for event in partner.events.all %}
+                    {% include 'partners/_prospartner_event_li.html' with event=event %}
+                {% empty %}
+                    <li>No events were found.</li>
+                {% endfor %}
+            </ul>
 
-        <hr>
-        <h3>Add new Event</h3>
-        <form method="post">
-            {% csrf_token %}
-            {{ form|bootstrap }}
+            <hr>
+            <h3>Add new Event</h3>
+            <form method="post">
+                {% csrf_token %}
+                {{ form|bootstrap }}
 
-            <input class="btn btn-primary" type="submit" value="Submit"/>
-        </form>
+                <input class="btn btn-primary" type="submit" value="Submit"/>
+            </form>
+        {% endif %}
     </div>
 </div>
 
diff --git a/partners/templates/partners/process_contact_requests.html b/partners/templates/partners/process_contact_requests.html
new file mode 100644
index 000000000..489b9a7e5
--- /dev/null
+++ b/partners/templates/partners/process_contact_requests.html
@@ -0,0 +1,59 @@
+{% extends 'partners/_partners_page_base.html' %}
+
+{% block breadcrumb_items %}
+    {{block.super}}
+    <span class="breadcrumb-item">Process Contact Requests</span>
+{% endblock %}
+
+{% block pagetitle %}{{block.super}} Process Contact Requests{% endblock pagetitle %}
+
+{% load bootstrap %}
+
+{% block content %}
+
+<div class="row">
+    <div class="col-12">
+        <h1 class="highlight">Process Contact Requests</h1>
+    </div>
+</div>
+
+<div class="row">
+    <div class="col-12">
+      <form method="post">
+        {% csrf_token %}
+        {{ formset.management_form }}
+        {% for form in formset %}
+            <div class="contact-form-group">
+                <h3>{{form.instance}}</h3>
+                <p>{{ form.instance.user.email }}</p>
+                <div class="mb-3">{{ form|bootstrap }}</div>
+            </div>
+        {% endfor %}
+
+        <input class="btn btn-primary" type="submit" value="Submit"/>
+      </form>
+    </div>
+</div>
+
+{% endblock content %}
+
+{% block footer_script %}
+<script>
+    function delete_hide_contact_groups(delete_input) {
+        input_el = $(delete_input);
+        if( input_el.prop('checked') ) {
+            input_el
+            .parents('.contact-form-group')
+            .addClass('delete-form-group');
+        } else {
+            input_el
+            .parents('.contact-form-group')
+            .removeClass('delete-form-group');
+        }
+    }
+
+    $('.contact-form-group [name$="DELETE"]').on('change click', function() {
+        delete_hide_contact_groups(this);
+    });
+</script>
+{% endblock %}
diff --git a/partners/urls.py b/partners/urls.py
index 5db245e99..19075e492 100644
--- a/partners/urls.py
+++ b/partners/urls.py
@@ -6,6 +6,8 @@ urlpatterns = [
     url(r'^$', views.supporting_partners, name='partners'),
     url(r'^dashboard$', views.dashboard, name='dashboard'),
     url(r'^membership_request$', views.membership_request, name='membership_request'),
+    url(r'^process_contact_requests$', views.process_contact_requests, name='process_contact_requests'),
+
 
     # Prospects
     url(r'^prospects/add$', views.add_prospective_partner,
@@ -23,6 +25,8 @@ urlpatterns = [
     url(r'agreements/new$', views.add_agreement, name='add_agreement'),
     url(r'agreements/(?P<agreement_id>[0-9]+)$', views.agreement_details,
         name='agreement_details'),
+    url(r'agreements/(?P<agreement_id>[0-9]+)/attachments/(?P<attachment_id>[0-9]+)$',
+        views.agreement_attachments, name='agreement_attachments'),
 
     # Institutions
     url(r'institutions/(?P<institution_id>[0-9]+)/edit$', views.institution_edit,
@@ -36,4 +40,6 @@ urlpatterns = [
     url(r'(?P<partner_id>[0-9]+)/edit$', views.partner_edit, name='partner_edit'),
     url(r'(?P<partner_id>[0-9]+)/contacts/add$', views.partner_add_contact,
         name='partner_add_contact'),
+    url(r'(?P<partner_id>[0-9]+)/contacts/request$', views.partner_request_contact,
+        name='partner_request_contact'),
 ]
diff --git a/partners/views.py b/partners/views.py
index 2580012c3..ed28b1c2e 100644
--- a/partners/views.py
+++ b/partners/views.py
@@ -2,6 +2,7 @@ from django.contrib import messages
 from django.contrib.auth.decorators import login_required
 from django.db import transaction
 from django.forms import modelformset_factory
+from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, render, reverse, redirect
 from django.utils import timezone
 
@@ -10,14 +11,16 @@ from guardian.decorators import permission_required
 from .constants import PROSPECTIVE_PARTNER_REQUESTED,\
     PROSPECTIVE_PARTNER_APPROACHED, PROSPECTIVE_PARTNER_ADDED,\
     PROSPECTIVE_PARTNER_EVENT_REQUESTED, PROSPECTIVE_PARTNER_EVENT_EMAIL_SENT
-from .models import Partner, ProspectivePartner, ProspectiveContact,\
-                    ProspectivePartnerEvent, MembershipAgreement, Contact, Institution
+from .models import Partner, ProspectivePartner, ProspectiveContact, ContactRequest,\
+                    ProspectivePartnerEvent, MembershipAgreement, Contact, Institution,\
+                    PartnersAttachment
 from .forms import ProspectivePartnerForm, ProspectiveContactForm,\
                    EmailProspectivePartnerContactForm, PromoteToPartnerForm,\
                    ProspectivePartnerEventForm, MembershipQueryForm, PromoteToContactForm,\
                    PromoteToContactFormset, PartnerForm, ContactForm, ContactFormset,\
                    NewContactForm, InstitutionForm, ActivationForm, PartnerEventForm,\
-                   MembershipAgreementForm
+                   MembershipAgreementForm, RequestContactForm, RequestContactFormSet,\
+                   ProcessRequestContactForm, PartnersAttachmentFormSet, PartnersAttachmentForm
 
 from .utils import PartnerUtils
 
@@ -44,6 +47,8 @@ def dashboard(request):
         'personal_agreements': personal_agreements
     }
     if request.user.has_perm('scipost.can_manage_SPB'):
+        context['contact_requests_count'] = ContactRequest.objects.awaiting_processing().count()
+        context['inactivate_contacts_count'] = Contact.objects.filter(user__is_active=False).count()
         context['partners'] = Partner.objects.all()
         context['prospective_partners'] = (ProspectivePartner.objects.not_yet_partner()
                                            .order_by('country', 'institution_name'))
@@ -112,9 +117,9 @@ def promote_prospartner(request, prospartner_id):
 ###############
 # Partner views
 ###############
-@permission_required('scipost.can_view_partners', return_403=True)
+@permission_required('scipost.can_view_own_partner_details', return_403=True)
 def partner_view(request, partner_id):
-    partner = get_object_or_404(Partner, id=partner_id)
+    partner = get_object_or_404(Partner.objects.my_partners(request.user), id=partner_id)
     form = PartnerEventForm(request.POST or None)
     if form.is_valid():
         event = form.save(commit=False)
@@ -171,6 +176,43 @@ def partner_add_contact(request, partner_id):
     return render(request, 'partners/partner_add_contact.html', context)
 
 
+@permission_required('scipost.can_view_own_partner_details', return_403=True)
+def partner_request_contact(request, partner_id):
+    partner = get_object_or_404(Partner.objects.my_partners(request.user), id=partner_id)
+    form = RequestContactForm(request.POST or None)
+    if form.is_valid():
+        contact_request = form.save(commit=False)
+        contact_request.partner = partner
+        contact_request.save()
+        messages.success(request, ('<h3>Request sent</h3>'
+                                   'We will process your request as soon as possible.'))
+        return redirect(partner.get_absolute_url())
+    context = {
+        'partner': partner,
+        'form': form
+    }
+    return render(request, 'partners/partner_request_contact.html', context)
+
+
+@permission_required('scipost.can_manage_SPB', return_403=True)
+def process_contact_requests(request):
+    form = RequestContactForm(request.POST or None)
+
+    RequestContactModelFormSet = modelformset_factory(ContactRequest, ProcessRequestContactForm,
+                                                      formset=RequestContactFormSet, extra=0)
+    formset = RequestContactModelFormSet(request.POST or None,
+                                         queryset=ContactRequest.objects.awaiting_processing())
+    if formset.is_valid():
+        formset.process_requests()
+        messages.success(request, 'Processing completed')
+        return redirect(reverse('partners:process_contact_requests'))
+    context = {
+        'form': form,
+        'formset': formset
+    }
+    return render(request, 'partners/process_contact_requests.html', context)
+
+
 ###################
 # Institution Views
 ###################
@@ -178,8 +220,6 @@ def partner_add_contact(request, partner_id):
 def institution_edit(request, institution_id):
     institution = get_object_or_404(Institution, id=institution_id)
     form = InstitutionForm(request.POST or None, request.FILES or None, instance=institution)
-    r = request.FILES
-    # raise
     if form.is_valid():
         form.save()
         messages.success(request, 'Institution has been updated.')
@@ -284,21 +324,42 @@ def add_agreement(request):
     return render(request, 'partners/agreements_add.html', context)
 
 
-@permission_required('scipost.can_manage_SPB', return_403=True)
+@permission_required('scipost.can_view_own_partner_details', return_403=True)
 def agreement_details(request, agreement_id):
     agreement = get_object_or_404(MembershipAgreement, id=agreement_id)
-    form = MembershipAgreementForm(request.POST or None, instance=agreement)
-    if form.is_valid():
-        agreement = form.save(request.user)
-        messages.success(request, 'Membership Agreement updated.')
-        return redirect(agreement.get_absolute_url())
-    context = {
-        'agreement': agreement,
-        'form': form
-    }
+    context = {}
+
+    if request.user.has_perm('scipost.can_manage_SPB'):
+        form = MembershipAgreementForm(request.POST or None, instance=agreement)
+        PartnersAttachmentFormSet
+
+        PartnersAttachmentFormset = modelformset_factory(PartnersAttachment,
+                                                         PartnersAttachmentForm,
+                                                         formset=PartnersAttachmentFormSet)
+        attachment_formset = PartnersAttachmentFormset(request.POST or None, request.FILES or None,
+                                                       queryset=agreement.attachments.all())
+
+        context['form'] = form
+        context['attachment_formset'] = attachment_formset
+        if form.is_valid() and attachment_formset.is_valid():
+            agreement = form.save(request.user)
+            attachment_formset.save(agreement)
+            messages.success(request, 'Membership Agreement updated.')
+            return redirect(agreement.get_absolute_url())
+
+    context['agreement'] = agreement
     return render(request, 'partners/agreements_details.html', context)
 
 
+@permission_required('scipost.can_view_own_partner_details', return_403=True)
+def agreement_attachments(request, agreement_id, attachment_id):
+    attachment = get_object_or_404(PartnersAttachment.objects.my_attachments(request.user),
+                                   agreement__id=agreement_id, id=attachment_id)
+    response = HttpResponse(attachment.attachment.read(), content_type='application/pdf')
+    response['Content-Disposition'] = ('filename=%s' % attachment.name)
+    return response
+
+
 #########
 # Account
 #########
diff --git a/scipost/management/commands/add_groups_and_permissions.py b/scipost/management/commands/add_groups_and_permissions.py
index 716e77542..4d9c8a02f 100644
--- a/scipost/management/commands/add_groups_and_permissions.py
+++ b/scipost/management/commands/add_groups_and_permissions.py
@@ -55,7 +55,11 @@ class Command(BaseCommand):
             content_type=content_type)
         can_view_partners, created = Permission.objects.get_or_create(
             codename='can_view_partners',
-            name='Can view Partner details',
+            name='Can view Partner details of all Partners',
+            content_type=content_type)
+        can_view_own_partner_details, created = Permission.objects.get_or_create(
+            codename='can_view_own_partner_details',
+            name='Can view (its own) partner details',
             content_type=content_type)
 
         # Registration and invitations
@@ -272,18 +276,21 @@ class Command(BaseCommand):
 
         PartnersAdmin.permissions.set([
             can_read_personal_page,
+            can_view_own_partner_details,
             can_manage_SPB,
             can_promote_prospect_to_partner,
             can_email_prospartner_contact,
-            can_view_partners
+            can_view_partners,
         ])
         PartnersOfficers.permissions.set([
             can_read_personal_page,
+            can_view_own_partner_details,
             can_manage_SPB,
             can_view_partners,
         ])
         PartnerAccounts.permissions.set([
-            can_read_personal_page
+            can_read_personal_page,
+            can_view_own_partner_details,
         ])
 
         if verbose:
-- 
GitLab