From 3349e6f4afce2ab612d38834b453d60b799b3718 Mon Sep 17 00:00:00 2001
From: "J.-S. Caux" <J.S.Caux@uva.nl>
Date: Sat, 8 May 2021 17:23:11 +0200
Subject: [PATCH] Fire up basic affiliates app

---
 SciPost_v1/settings/base.py                   |  1 +
 SciPost_v1/urls.py                            |  4 ++
 affiliates/__init__.py                        |  0
 affiliates/admin.py                           | 38 +++++++++++
 affiliates/apps.py                            |  9 +++
 affiliates/converters.py                      | 11 +++
 affiliates/migrations/0001_initial.py         | 68 +++++++++++++++++++
 .../migrations/0002_auto_20210508_1629.py     | 20 ++++++
 .../migrations/0003_auto_20210508_1653.py     | 19 ++++++
 affiliates/migrations/__init__.py             |  0
 affiliates/models/__init__.py                 | 11 +++
 affiliates/models/journal.py                  | 51 ++++++++++++++
 affiliates/models/pubfraction.py              | 40 +++++++++++
 affiliates/models/publication.py              | 41 +++++++++++
 affiliates/models/publisher.py                | 23 +++++++
 affiliates/regexes.py                         |  5 ++
 .../affiliates/affiliatejournal_detail.html   | 22 ++++++
 .../affiliates/affiliatejournal_list.html     | 17 +++++
 .../affiliatepublication_detail.html          | 23 +++++++
 affiliates/urls.py                            | 30 ++++++++
 affiliates/validators.py                      | 12 ++++
 affiliates/views.py                           | 23 +++++++
 22 files changed, 468 insertions(+)
 create mode 100644 affiliates/__init__.py
 create mode 100644 affiliates/admin.py
 create mode 100644 affiliates/apps.py
 create mode 100644 affiliates/converters.py
 create mode 100644 affiliates/migrations/0001_initial.py
 create mode 100644 affiliates/migrations/0002_auto_20210508_1629.py
 create mode 100644 affiliates/migrations/0003_auto_20210508_1653.py
 create mode 100644 affiliates/migrations/__init__.py
 create mode 100644 affiliates/models/__init__.py
 create mode 100644 affiliates/models/journal.py
 create mode 100644 affiliates/models/pubfraction.py
 create mode 100644 affiliates/models/publication.py
 create mode 100644 affiliates/models/publisher.py
 create mode 100644 affiliates/regexes.py
 create mode 100644 affiliates/templates/affiliates/affiliatejournal_detail.html
 create mode 100644 affiliates/templates/affiliates/affiliatejournal_list.html
 create mode 100644 affiliates/templates/affiliates/affiliatepublication_detail.html
 create mode 100644 affiliates/urls.py
 create mode 100644 affiliates/validators.py
 create mode 100644 affiliates/views.py

diff --git a/SciPost_v1/settings/base.py b/SciPost_v1/settings/base.py
index 47b1c76fd..e0f5c8fb3 100644
--- a/SciPost_v1/settings/base.py
+++ b/SciPost_v1/settings/base.py
@@ -86,6 +86,7 @@ INSTALLED_APPS = (
     'django_countries',
     'django_extensions',
     'haystack',
+    'affiliates',
     'api',
     'apimail',
     'careers',
diff --git a/SciPost_v1/urls.py b/SciPost_v1/urls.py
index cb15a9fb9..33de07773 100644
--- a/SciPost_v1/urls.py
+++ b/SciPost_v1/urls.py
@@ -28,6 +28,10 @@ urlpatterns = [
     url(r'^sitemap.xml$', scipost_views.sitemap_xml, name='sitemap_xml'),
     url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
     url(r'^admin/', admin.site.urls),
+    path(
+        'affiliates/',
+        include('affiliates.urls', namespace='affiliates')
+    ),
     url(r'^api/', include('api.urls', namespace='api')),
     path(
         'mail/',
diff --git a/affiliates/__init__.py b/affiliates/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/affiliates/admin.py b/affiliates/admin.py
new file mode 100644
index 000000000..550598ea9
--- /dev/null
+++ b/affiliates/admin.py
@@ -0,0 +1,38 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.contrib import admin
+
+from .models import (
+    AffiliatePublisher, AffiliateJournal,
+    AffiliatePublication, AffiliatePubFraction
+)
+
+
+admin.site.register(AffiliatePublisher)
+
+
+class AffiliateJournalAdmin(admin.ModelAdmin):
+    search_fields = ['name']
+    list_display = ['name', 'publisher']
+
+admin.site.register(AffiliateJournal, AffiliateJournalAdmin)
+
+
+class AffiliatePubFractionInline(admin.TabularInline):
+    model = AffiliatePubFraction
+    list_display = ('organization', 'publication', 'fraction')
+    autocomplete_fields = [
+        'organization',
+    ]
+
+class AffiliatePublicationAdmin(admin.ModelAdmin):
+    search_fields = ['doi', 'journal',]
+    list_display = [
+        'journal',
+        'doi',
+    ]
+    inlines = [AffiliatePubFractionInline,]
+
+admin.site.register(AffiliatePublication, AffiliatePublicationAdmin)
diff --git a/affiliates/apps.py b/affiliates/apps.py
new file mode 100644
index 000000000..dd56ccff1
--- /dev/null
+++ b/affiliates/apps.py
@@ -0,0 +1,9 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.apps import AppConfig
+
+
+class AffiliatesConfig(AppConfig):
+    name = 'affiliates'
diff --git a/affiliates/converters.py b/affiliates/converters.py
new file mode 100644
index 000000000..c5621a88c
--- /dev/null
+++ b/affiliates/converters.py
@@ -0,0 +1,11 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.urls.converters import StringConverter
+
+from .regexes import DOI_AFFILIATEPUBLICATION_REGEX
+
+
+class Crossref_DOI_converter(StringConverter):
+    regex = DOI_AFFILIATEPUBLICATION_REGEX
diff --git a/affiliates/migrations/0001_initial.py b/affiliates/migrations/0001_initial.py
new file mode 100644
index 000000000..2678619a9
--- /dev/null
+++ b/affiliates/migrations/0001_initial.py
@@ -0,0 +1,68 @@
+# Generated by Django 2.2.16 on 2021-05-08 14:20
+
+from decimal import Decimal
+import django.contrib.postgres.fields.jsonb
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import re
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('organizations', '0012_auto_20210310_2026'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AffiliateJournal',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=256)),
+                ('short_name', models.CharField(default='', max_length=256)),
+                ('slug', models.SlugField(max_length=128, validators=[django.core.validators.RegexValidator(re.compile('^[-\\w]+\\Z'), "Enter a valid 'slug' consisting of Unicode letters, numbers, underscores, or hyphens.", 'invalid')])),
+            ],
+            options={
+                'ordering': ['publisher', 'name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='AffiliatePublisher',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=256)),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='AffiliatePublication',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('doi', models.CharField(db_index=True, max_length=256, unique=True, validators=[django.core.validators.RegexValidator('^10.\\d{4,9}/[-._;()/:a-zA-Z0-9]+$', 'Only expressions with regex 10.\\d{4,9}/[-._;()/:a-zA-Z0-9]+ are allowed.')])),
+                ('_metadata_crossref', django.contrib.postgres.fields.jsonb.JSONField(default=dict)),
+                ('journal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='affiliates.AffiliateJournal')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='affiliatejournal',
+            name='publisher',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='journals', to='affiliates.AffiliatePublisher'),
+        ),
+        migrations.CreateModel(
+            name='AffiliatePubFraction',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('fraction', models.DecimalField(decimal_places=3, default=Decimal('0.000'), max_digits=4)),
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='affiliate_pubfractions', to='organizations.Organization')),
+                ('publication', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pubfractions', to='affiliates.AffiliatePublication')),
+            ],
+            options={
+                'unique_together': {('organization', 'publication')},
+            },
+        ),
+    ]
diff --git a/affiliates/migrations/0002_auto_20210508_1629.py b/affiliates/migrations/0002_auto_20210508_1629.py
new file mode 100644
index 000000000..e683662f4
--- /dev/null
+++ b/affiliates/migrations/0002_auto_20210508_1629.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.2.16 on 2021-05-08 14:29
+
+import django.core.validators
+from django.db import migrations, models
+import re
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('affiliates', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='affiliatejournal',
+            name='slug',
+            field=models.SlugField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator(re.compile('^[-\\w]+\\Z'), "Enter a valid 'slug' consisting of Unicode letters, numbers, underscores, or hyphens.", 'invalid')]),
+        ),
+    ]
diff --git a/affiliates/migrations/0003_auto_20210508_1653.py b/affiliates/migrations/0003_auto_20210508_1653.py
new file mode 100644
index 000000000..893701aef
--- /dev/null
+++ b/affiliates/migrations/0003_auto_20210508_1653.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.16 on 2021-05-08 14:53
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('affiliates', '0002_auto_20210508_1629'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='affiliatepublication',
+            name='journal',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='publications', to='affiliates.AffiliateJournal'),
+        ),
+    ]
diff --git a/affiliates/migrations/__init__.py b/affiliates/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/affiliates/models/__init__.py b/affiliates/models/__init__.py
new file mode 100644
index 000000000..991e72420
--- /dev/null
+++ b/affiliates/models/__init__.py
@@ -0,0 +1,11 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from .publisher import AffiliatePublisher
+
+from .journal import AffiliateJournal
+
+from .publication import AffiliatePublication
+
+from .pubfraction import AffiliatePubFraction
diff --git a/affiliates/models/journal.py b/affiliates/models/journal.py
new file mode 100644
index 000000000..989f257e6
--- /dev/null
+++ b/affiliates/models/journal.py
@@ -0,0 +1,51 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.core.validators import validate_unicode_slug
+from django.db import models
+from django.urls import reverse
+
+
+class AffiliateJournal(models.Model):
+    """
+    A Journal which piggybacks on SciPost's services.
+    """
+
+    publisher = models.ForeignKey(
+        'affiliates.AffiliatePublisher',
+        on_delete=models.CASCADE,
+        related_name='journals'
+    )
+
+    name = models.CharField(
+        max_length=256
+    )
+
+    # Note that the short name can be just as long as the full name. This is because not all
+    # journals have abbreviated names in Crossref, and instead return the full journal name.
+    short_name = models.CharField(
+        max_length=256,
+        default=""
+    )
+
+    slug = models.SlugField(
+        max_length=128,
+        validators=[validate_unicode_slug,],
+        unique=True
+    )
+
+    class Meta:
+        ordering = [
+            'publisher',
+            'name'
+        ]
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse(
+            'affiliates:journal_detail',
+            kwargs={'slug': self.slug}
+        )
diff --git a/affiliates/models/pubfraction.py b/affiliates/models/pubfraction.py
new file mode 100644
index 000000000..92d87a913
--- /dev/null
+++ b/affiliates/models/pubfraction.py
@@ -0,0 +1,40 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from decimal import Decimal
+
+from django.db import models
+
+
+class AffiliatePubFraction(models.Model):
+    """
+    PubFraction for an AffiliatePublication.
+    """
+    organization = models.ForeignKey(
+        'organizations.Organization',
+        on_delete=models.CASCADE,
+        related_name='affiliate_pubfractions'
+    )
+    publication = models.ForeignKey(
+        'affiliates.AffiliatePublication',
+        on_delete=models.CASCADE,
+        related_name='pubfractions'
+    )
+    fraction = models.DecimalField(
+        max_digits=4,
+        decimal_places=3,
+        default=Decimal('0.000')
+    )
+
+    class Meta:
+        unique_together = (
+            ('organization', 'publication'),
+        )
+
+    def __str__(self):
+        return 'PubFraction of %s for %s: %d' % (
+            self.organization,
+            self.publication,
+            self.fraction
+        )
diff --git a/affiliates/models/publication.py b/affiliates/models/publication.py
new file mode 100644
index 000000000..a46385053
--- /dev/null
+++ b/affiliates/models/publication.py
@@ -0,0 +1,41 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.contrib.postgres.fields import JSONField
+from django.db import models
+from django.urls import reverse
+
+from ..validators import doi_affiliatepublication_validator
+
+
+class AffiliatePublication(models.Model):
+    """
+    Publication item from an affiliate Publisher/Journal.
+    """
+
+    doi = models.CharField(
+        max_length=256,
+        unique=True,
+        db_index=True,
+        validators=[doi_affiliatepublication_validator]
+    )
+    _metadata_crossref = JSONField(
+        default=dict,
+
+    )
+
+    journal = models.ForeignKey(
+        'affiliates.AffiliateJournal',
+        on_delete=models.CASCADE,
+        related_name='publications'
+    )
+
+    def __str__(self):
+        return self.doi
+
+    def get_absolute_url(self):
+        return reverse(
+            'affiliates:publication_detail',
+            kwargs={'doi': self.doi}
+        )
diff --git a/affiliates/models/publisher.py b/affiliates/models/publisher.py
new file mode 100644
index 000000000..c8305c638
--- /dev/null
+++ b/affiliates/models/publisher.py
@@ -0,0 +1,23 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.db import models
+
+
+class AffiliatePublisher(models.Model):
+    """
+    A Publisher which piggybacks on SciPost's services.
+    """
+
+    name = models.CharField(
+        max_length=256
+    )
+
+    class Meta:
+        ordering = [
+            'name'
+        ]
+
+    def __str__(self):
+        return self.name
diff --git a/affiliates/regexes.py b/affiliates/regexes.py
new file mode 100644
index 000000000..c334c8f57
--- /dev/null
+++ b/affiliates/regexes.py
@@ -0,0 +1,5 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+DOI_AFFILIATEPUBLICATION_REGEX = r'10.\d{4,9}/[-._;()/:a-zA-Z0-9]+'
diff --git a/affiliates/templates/affiliates/affiliatejournal_detail.html b/affiliates/templates/affiliates/affiliatejournal_detail.html
new file mode 100644
index 000000000..b30a23998
--- /dev/null
+++ b/affiliates/templates/affiliates/affiliatejournal_detail.html
@@ -0,0 +1,22 @@
+{% extends 'scipost/base.html' %}
+
+{% block pagetitle %}: Affiliate Journal: {{ object }}{% endblock %}
+
+{% block content %}
+
+  <h2 class="highlight">Affiliate Journal: {{ object }}</h2>
+
+  <h3 class="highlight">Publications</h3>
+  <table class="table">
+    {% for pub in object.publications.all %}
+      <tr>
+	<td><a href="{{ pub.get_absolute_url }}">{{ pub }}</a></td>
+      </tr>
+    {% empty %}
+      <tr>
+	<td>No publications yet</td>
+      </tr>
+    {% endfor %}
+  </table>
+
+{% endblock content %}
diff --git a/affiliates/templates/affiliates/affiliatejournal_list.html b/affiliates/templates/affiliates/affiliatejournal_list.html
new file mode 100644
index 000000000..cc71786c1
--- /dev/null
+++ b/affiliates/templates/affiliates/affiliatejournal_list.html
@@ -0,0 +1,17 @@
+{% extends 'scipost/base.html' %}
+
+{% block pagetitle %}: Affiliate Journals{% endblock %}
+
+{% block content %}
+
+  <h2 class="highlight">Affiliate Journals</h2>
+
+  <ul>
+    {% for journal in object_list %}
+      <li><a href="{{ journal.get_absolute_url }}">{{ journal }}</a></li>
+    {% empty %}
+      <li>There are no affiliate journals at this moment</li>
+    {% endfor %}
+  </ul>
+
+{% endblock content %}
diff --git a/affiliates/templates/affiliates/affiliatepublication_detail.html b/affiliates/templates/affiliates/affiliatepublication_detail.html
new file mode 100644
index 000000000..4c180f8a8
--- /dev/null
+++ b/affiliates/templates/affiliates/affiliatepublication_detail.html
@@ -0,0 +1,23 @@
+{% extends 'scipost/base.html' %}
+
+{% block pagetitle %}: Publication: {{ object }}{% endblock %}
+
+{% block content %}
+
+  <h2 class="highlight">Publication: {{ object }} </h2>
+  <p>(in affiliate journal {{ object.journal }})</p>
+
+  <h3 class="highlight">PubFractions</h3>
+  <table class="table">
+    {% for pubfrac in object.pufractions.all %}
+      <tr>
+	<td>{{ pubfrac }}</td>
+      </tr>
+    {% empty %}
+      <tr>
+	<td>No PubFractions yet</td>
+      </tr>
+    {% endfor %}
+  </table>
+
+{% endblock content %}
diff --git a/affiliates/urls.py b/affiliates/urls.py
new file mode 100644
index 000000000..91ce17930
--- /dev/null
+++ b/affiliates/urls.py
@@ -0,0 +1,30 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.urls import path, register_converter
+
+from . import views
+from .converters import Crossref_DOI_converter
+
+app_name='affiliates'
+
+register_converter(Crossref_DOI_converter, 'doi')
+
+urlpatterns = [
+    path( # /affiliates/journals
+        'journals',
+        views.AffiliateJournalListView.as_view(),
+        name='journals'
+    ),
+    path( # /affiliates/journals/<slug>
+        'journals/<slug:slug>',
+        views.AffiliateJournalDetailView.as_view(),
+        name='journal_detail'
+    ),
+    path( # /affiliates/publications/<doi:doi>
+        'publications/<doi:doi>',
+        views.AffiliatePublicationDetailView.as_view(),
+        name='publication_detail'
+    ),
+]
diff --git a/affiliates/validators.py b/affiliates/validators.py
new file mode 100644
index 000000000..eb13135b7
--- /dev/null
+++ b/affiliates/validators.py
@@ -0,0 +1,12 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.core.validators import RegexValidator
+
+from .regexes import DOI_AFFILIATEPUBLICATION_REGEX
+
+
+doi_affiliatepublication_validator = RegexValidator(
+    r'^{regex}$'.format(regex=DOI_AFFILIATEPUBLICATION_REGEX),
+    'Only expressions with regex %s are allowed.' % DOI_AFFILIATEPUBLICATION_REGEX)
diff --git a/affiliates/views.py b/affiliates/views.py
new file mode 100644
index 000000000..0a6661eb4
--- /dev/null
+++ b/affiliates/views.py
@@ -0,0 +1,23 @@
+__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
+__license__ = "AGPL v3"
+
+
+from django.shortcuts import render
+from django.views.generic.detail import DetailView
+from django.views.generic.list import ListView
+
+from .models import AffiliateJournal, AffiliatePublication
+
+
+class AffiliateJournalListView(ListView):
+    model = AffiliateJournal
+
+
+class AffiliateJournalDetailView(DetailView):
+    model = AffiliateJournal
+
+
+class AffiliatePublicationDetailView(DetailView):
+    model = AffiliatePublication
+    slug_field = 'doi'
+    slug_url_kwarg = 'doi'
-- 
GitLab