Compare commits

...

6 Commits

Author SHA1 Message Date
Paul Marillonnet 711513b713 [WIP] test middleware modifications 2023-02-17 14:49:36 +01:00
Paul Marillonnet c322a62aab [WIP] manager: add CUT partner BO management interface (#73170) 2023-02-17 14:49:36 +01:00
Paul Marillonnet 3e7ecae49e [WIP] middleware behavior, retrieval through models 2023-02-17 14:49:36 +01:00
Paul Marillonnet cada3f164d migrations: client data migration (#73170) 2023-02-17 14:49:36 +01:00
Paul Marillonnet 3b405b5eae models: add dedicated CUTPartner model (#73170) 2023-02-17 14:49:36 +01:00
Paul Marillonnet 25e7948951 custom_settings: provide thorough A2_CUT_PARTNERS entries (#73170)
more closely matching the custom entries in the test & production
    tenants json configuration (/var/lib/authentic2-multenant/tenants/)
2023-02-17 14:49:36 +01:00
12 changed files with 404 additions and 2 deletions

View File

@ -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': 'Entrouvert',
'stat_emails': ['entrouvert@example.com'],
},
]

View File

@ -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']

View File

@ -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()

View File

@ -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

View File

@ -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',
),
),
],
),
]

View File

@ -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),
]

View File

@ -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,
}

View File

@ -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 %}

View File

@ -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',
),
],
)

View File

@ -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)

View File

@ -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

53
tests/test_migrations.py Normal file
View File

@ -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': 'Entrouvert',
'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='Entrouvert', # 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']