Compare commits
6 Commits
main
...
wip/73170-
Author | SHA1 | Date |
---|---|---|
Paul Marillonnet | 711513b713 | |
Paul Marillonnet | c322a62aab | |
Paul Marillonnet | 3e7ecae49e | |
Paul Marillonnet | cada3f164d | |
Paul Marillonnet | 3b405b5eae | |
Paul Marillonnet | 25e7948951 |
|
@ -39,11 +39,15 @@ A2_RBAC_MANAGED_CONTENT_TYPES = ()
|
|||
A2_CUT_PARTNERS = [
|
||||
{
|
||||
'domains': ['.lyon.fr'],
|
||||
'url': 'www.lyon.fr',
|
||||
'name': 'Ville de Lyon',
|
||||
'stat_emails': ['lyon@example.com'],
|
||||
},
|
||||
{
|
||||
'domains': ['.entrouvert.org'],
|
||||
'name': 'Ville de Lyon',
|
||||
'url': 'www.entrouvert.org',
|
||||
'name': 'Entr’ouvert',
|
||||
'stat_emails': ['entrouvert@example.com'],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
# authentic2_cut - Authentic2 plugin for CUT
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from authentic2_idp_oidc.manager.forms import OIDCClientForm
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class CUTPartnerForm(OIDCClientForm):
|
||||
domains = forms.CharField(
|
||||
label=_('(Comma-separated) domains'),
|
||||
required=False,
|
||||
widget=forms.Textarea,
|
||||
)
|
||||
url = forms.URLField(label=_('Partner URL'), required=False)
|
||||
stat_emails = forms.CharField(
|
||||
label=_('(Comma-separated) statistics email recipients'),
|
||||
required=False,
|
||||
widget=forms.Textarea,
|
||||
)
|
||||
|
||||
def _clean_array(self, key):
|
||||
data = self.cleaned_data[key].replace(' ', '')
|
||||
if data:
|
||||
return data.split(',')
|
||||
return None
|
||||
|
||||
def clean_domains(self):
|
||||
return self._clean_array('domains')
|
||||
|
||||
def clean_stat_emails(self):
|
||||
return self._clean_array('stat_emails')
|
||||
|
||||
class Meta(OIDCClientForm.Meta):
|
||||
fields = OIDCClientForm.Meta.fields + ['domains', 'url', 'stat_emails']
|
|
@ -0,0 +1,83 @@
|
|||
# authentic2_cut - Authentic2 plugin for CUT
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from authentic2.manager.service_views import ServiceEditView, ServiceSettingsView
|
||||
from authentic2_idp_oidc.manager.views import OIDCServiceAddView
|
||||
|
||||
from .. import models
|
||||
from . import forms
|
||||
|
||||
|
||||
class CUTPartnerAddView(OIDCServiceAddView):
|
||||
form_class = forms.CUTPartnerForm
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
# create associated cut partner
|
||||
models.CUTPartner.objects.create(
|
||||
oidc_client=self.object,
|
||||
domains=form.cleaned_data.get('domains'),
|
||||
url=form.cleaned_data.get('url'),
|
||||
stat_emails=form.cleaned_data.get('stat_emails'),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
add_cut_partner = CUTPartnerAddView.as_view()
|
||||
|
||||
|
||||
class CUTPartnerEditView(ServiceEditView):
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
if getattr(self.object, 'cut_partner', None):
|
||||
cut_partner = self.object.cut_partner
|
||||
# pre-populate CUT partner specific fields
|
||||
initial['domains'] = cut_partner.domains
|
||||
initial['url'] = cut_partner.url
|
||||
initial['stat_emails'] = cut_partner.stat_emails
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
if getattr(self.object, 'cut_partner', None):
|
||||
cut_partner = self.object.cut_partner
|
||||
# save CUT partner specific fields into dedicated model object
|
||||
cut_partner.domains = form.cleaned_data.get('domains', [])
|
||||
cut_partner.url = form.cleaned_data.get('url')
|
||||
cut_partner.stat_emails = form.cleaned_data.get('stat_emails', [])
|
||||
cut_partner.save()
|
||||
|
||||
def get_form_class(self):
|
||||
return forms.CUTPartnerForm
|
||||
|
||||
|
||||
edit_cut_partner = CUTPartnerEditView.as_view()
|
||||
|
||||
|
||||
class CUTPartnerSettingsView(ServiceSettingsView):
|
||||
template_name = 'authentic2/cut_partner_settings.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if getattr(self.object, 'cut_partner', None):
|
||||
ctx['cut_partner_fields_values'] = {
|
||||
'domains': self.object.cut_partner.domains,
|
||||
'url': self.object.cut_partner.url,
|
||||
'stat_emails': self.object.cut_partner.stat_emails,
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
cut_partner_settings = CUTPartnerSettingsView.as_view()
|
|
@ -51,6 +51,9 @@ class CUTMiddleware(MiddlewareMixin):
|
|||
request.domain = None
|
||||
else:
|
||||
if domain not in self.MATCHES:
|
||||
pattern_found = False
|
||||
|
||||
# first search in legacy partner definition
|
||||
for partner_def in getattr(settings, 'A2_CUT_PARTNERS', []):
|
||||
patterns = partner_def.get('domains', [])
|
||||
for pattern in patterns:
|
||||
|
@ -60,8 +63,24 @@ class CUTMiddleware(MiddlewareMixin):
|
|||
continue
|
||||
self.MATCHES[domain] = partner_def
|
||||
request.session['cut_domain'] = domain
|
||||
pattern_found = True
|
||||
break
|
||||
else:
|
||||
|
||||
# then in model-based partner definition
|
||||
if not pattern_found:
|
||||
for partner_object in CUTPartner.objects.all():
|
||||
patterns = partner_object.domains
|
||||
for pattern in patterns:
|
||||
if same_domain(domain, pattern):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
self.MATCHES[domain] = partner_object.as_dict()
|
||||
request.session['cut_domain'] = domain
|
||||
pattern_found = True
|
||||
break
|
||||
|
||||
if not pattern_found:
|
||||
# when adding a domain, you must reload, must thing of emptying the cache
|
||||
# sometimes
|
||||
self.MATCHES[domain] = None
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
# Generated by Django 2.2.26 on 2023-01-10 10:42
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authentic2_idp_oidc', '0017_oidcaccesstoken_profile'),
|
||||
('authentic2_cut', '0006_delete_journal'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CUTPartner',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
(
|
||||
'domains',
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(blank=True, max_length=128, null=True),
|
||||
size=None,
|
||||
verbose_name='Domains',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
('url', models.URLField(verbose_name='Partner URL', null=True)),
|
||||
(
|
||||
'stat_emails',
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
size=None,
|
||||
verbose_name='Statistics email recipients',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'oidc_client',
|
||||
models.OneToOneField(
|
||||
related_name='cut_partner',
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='authentic2_idp_oidc.OIDCClient',
|
||||
verbose_name='OIDC Client',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,30 @@
|
|||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def add_cut_partners(apps, schema_editor):
|
||||
cut_partners = getattr(settings, 'A2_CUT_PARTNERS', {})
|
||||
|
||||
OIDCClient = apps.get_model('authentic2_idp_oidc', 'OIDCClient')
|
||||
CUTPartner = apps.get_model('authentic2_cut', 'CUTPartner')
|
||||
|
||||
cut_partner_names = [partner['name'] for partner in cut_partners]
|
||||
for oidc_client in OIDCClient.objects.filter(name__in=cut_partner_names):
|
||||
cut_partner_data = cut_partners[cut_partner_names.index(oidc_client.name)]
|
||||
cut_partner = CUTPartner.objects.create(
|
||||
oidc_client=oidc_client,
|
||||
domains=cut_partner_data['domains'],
|
||||
url=cut_partner_data['url'],
|
||||
stat_emails=cut_partner_data['stat_emails'],
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authentic2_cut', '0007_cutpartner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_cut_partners, reverse_code=migrations.RunPython.noop),
|
||||
]
|
|
@ -1,9 +1,11 @@
|
|||
import os.path
|
||||
from datetime import timedelta
|
||||
|
||||
from authentic2_idp_oidc.models import OIDCClient
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import models
|
||||
from django.db.models.query import Q
|
||||
|
@ -11,6 +13,7 @@ from django.db.models.signals import post_delete
|
|||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from sorl.thumbnail import delete, get_thumbnail
|
||||
|
||||
|
||||
|
@ -226,3 +229,30 @@ def auto_delete_image_files(sender, instance, **kwargs):
|
|||
pass
|
||||
if os.path.isfile(instance.image.path):
|
||||
instance.image.delete(save=False)
|
||||
|
||||
|
||||
class CUTPartner(models.Model):
|
||||
oidc_client = models.OneToOneField(
|
||||
to='authentic2_idp_oidc.OIDCClient',
|
||||
verbose_name=_('OIDC Client'),
|
||||
on_delete=models.CASCADE,
|
||||
related_name='cut_partner',
|
||||
)
|
||||
domains = ArrayField(
|
||||
base_field=models.CharField(blank=True, null=True, max_length=128),
|
||||
verbose_name=_('Domains'),
|
||||
null=True,
|
||||
)
|
||||
url = models.URLField(verbose_name=_('Partner URL'), null=True)
|
||||
stat_emails = ArrayField(
|
||||
base_field=models.EmailField(blank=True, null=True),
|
||||
verbose_name=_('Statistics email recipients'),
|
||||
null=True,
|
||||
)
|
||||
|
||||
def as_dict():
|
||||
return {
|
||||
'domains': self.domains,
|
||||
'url': self.urls,
|
||||
'stat_emails': self.stat_emails,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
{% extends "authentic2/manager/service_settings.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block main %}
|
||||
<div class="section">
|
||||
{% for field, value in object_fields_values %}
|
||||
<div class="service-field">
|
||||
<div class="service-field--name">{{ field|capfirst }}{% trans ":" %}</div>
|
||||
<div class="service-field--value">{% if value == True %}{% trans "yes" %}
|
||||
{% elif value == False %}{% trans "no" %}
|
||||
{% elif value == None %}{% trans "none" %}
|
||||
{% else %}{{ value|linebreaksbr }}
|
||||
{% endif %}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for field, value in cut_partner_fields_values.items %}
|
||||
<div class="cut-partner service-field">
|
||||
<div class="service-field--name">{{ field|capfirst }}{% trans ":" %}</div>
|
||||
<div class="service-field--value">{% if value == True %}{% trans "yes" %}
|
||||
{% elif value == False %}{% trans "no" %}
|
||||
{% elif value == None %}{% trans "none" %}
|
||||
{% else %}{{ value|linebreaksbr }}
|
||||
{% endif %}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if extra_details_template %}
|
||||
{% include extra_details_template %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -19,6 +19,7 @@ from authentic2.manager.utils import manager_login_required
|
|||
from django.conf.urls import url
|
||||
|
||||
from . import api_views, views
|
||||
from .manager import views as manager_views
|
||||
|
||||
urlpatterns = required(
|
||||
manager_login_required,
|
||||
|
@ -49,6 +50,22 @@ urlpatterns = required(
|
|||
views.validation_attachment_thumbnail,
|
||||
name='cut-manager-user-validation-attachment-thumbnail',
|
||||
),
|
||||
# override a2's generic service edition views
|
||||
url(
|
||||
r'^manage/services/(?P<service_pk>\d*)/settings/$',
|
||||
manager_views.cut_partner_settings,
|
||||
name='a2-manager-service-settings',
|
||||
),
|
||||
url(
|
||||
r'^manage/services/(?P<service_pk>\d*)/settings/edit/$',
|
||||
manager_views.edit_cut_partner,
|
||||
name='a2-manager-service-settings-edit',
|
||||
),
|
||||
url(
|
||||
r'^manage/services/add-oidc/$',
|
||||
manager_views.add_cut_partner,
|
||||
name='a2-manager-add-oidc-service',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
@ -13,6 +13,8 @@ except ImportError:
|
|||
from authentic2.a2_rbac.models import OrganizationalUnit as OU
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.db import connection
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
|
||||
User = get_user_model()
|
||||
TEST_DIR = pathlib.Path(__file__).parent
|
||||
|
@ -157,3 +159,29 @@ def clean_caches():
|
|||
from authentic2.apps.journal.models import event_type_cache
|
||||
|
||||
event_type_cache.cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migration(request, transactional_db):
|
||||
# see https://gist.github.com/asfaltboy/b3e6f9b5d95af8ba2cc46f2ba6eae5e2
|
||||
# copied from authentic mainline's fixtures
|
||||
class Migrator:
|
||||
def before(self, targets, at_end=True):
|
||||
"""Specify app and starting migration names as in:
|
||||
before([('app', '0001_before')]) => app/migrations/0001_before.py
|
||||
"""
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.migrate(targets)
|
||||
executor.loader.build_graph()
|
||||
return executor._create_project_state(with_applied_migrations=True).apps
|
||||
|
||||
def apply(self, targets):
|
||||
"""Migrate forwards to the "targets" migration"""
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.migrate(targets)
|
||||
executor.loader.build_graph()
|
||||
return executor._create_project_state(with_applied_migrations=True).apps
|
||||
|
||||
yield Migrator()
|
||||
|
||||
call_command('migrate', verbosity=0)
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
from utils import login
|
||||
|
||||
def test_cut_middleware(app, db, request, admin, settings):
|
||||
login(app, admin, path='/accounts/')
|
||||
assert 'cut_domain' in app.session
|
||||
assert 'cut_next' in app.session
|
|
@ -0,0 +1,53 @@
|
|||
def test_migration_0008_migrate_oidcclients_to_cutpartners(transactional_db, migration, settings):
|
||||
settings.A2_CUT_PARTNERS = [
|
||||
{
|
||||
'domains': ['.lyon.fr'],
|
||||
'url': 'www.lyon.fr',
|
||||
'name': 'Ville de Lyon',
|
||||
'stat_emails': ['lyon@example.com'],
|
||||
},
|
||||
{
|
||||
'domains': ['.entrouvert.org'],
|
||||
'url': 'www.entrouvert.org',
|
||||
'name': 'Entr’ouvert',
|
||||
'stat_emails': ['entrouvert@example.com'],
|
||||
},
|
||||
# partner that does not match an existing oidc client
|
||||
{
|
||||
'domains': ['.example.com'],
|
||||
'url': 'www.example.com',
|
||||
'name': 'Example',
|
||||
'stat_emails': ['void@example.com'],
|
||||
},
|
||||
]
|
||||
|
||||
old_apps = migration.before([('authentic2_cut', '0007_cutpartner')])
|
||||
|
||||
OIDCClient = old_apps.get_model('authentic2_idp_oidc', 'OIDCClient')
|
||||
OIDCClient.objects.create(
|
||||
name='Ville de Lyon', # matches A2_CUT_PARTNERS’ first entry
|
||||
slug='ville-de-lyon',
|
||||
client_id='abc',
|
||||
client_secret='def',
|
||||
)
|
||||
OIDCClient.objects.create(
|
||||
name='Entr’ouvert', # matches A2_CUT_PARTNERS’ second entry
|
||||
slug='entrouvert',
|
||||
client_id='ghi',
|
||||
client_secret='jkl',
|
||||
)
|
||||
|
||||
CUTPartner = old_apps.get_model('authentic2_cut', 'CUTPartner')
|
||||
assert not CUTPartner.objects.count()
|
||||
|
||||
new_apps = migration.apply([('authentic2_cut', '0008_migrate_oidcclients_to_cutpartners')])
|
||||
CUTPartner = new_apps.get_model('authentic2_cut', 'CUTPartner')
|
||||
assert CUTPartner.objects.count() == 2
|
||||
|
||||
eo_partner = CUTPartner.objects.get(url='www.entrouvert.org')
|
||||
assert eo_partner.domains == ['.entrouvert.org']
|
||||
assert eo_partner.stat_emails == ['entrouvert@example.com']
|
||||
|
||||
lyon_partner = CUTPartner.objects.get(url='www.lyon.fr')
|
||||
assert lyon_partner.domains == ['.lyon.fr']
|
||||
assert lyon_partner.stat_emails == ['lyon@example.com']
|
Loading…
Reference in New Issue