From 835ff3dfee7dccb768abde16a00afe94bdbc4df0 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 19 Dec 2018 00:10:52 +0100 Subject: [PATCH] add compatibility layer for support of Django native JSONField (fixes #29193) --- src/authentic2/apps.py | 49 ++++++++- src/authentic2/compat.py | 100 +++++++++++++++++- .../migrations/0007_auto_20181219_0005.py | 22 ++++ src/authentic2_auth_oidc/models.py | 5 +- tox.ini | 1 + 5 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 src/authentic2_auth_oidc/migrations/0007_auto_20181219_0005.py diff --git a/src/authentic2/apps.py b/src/authentic2/apps.py index cd6b4718..421d12f1 100644 --- a/src/authentic2/apps.py +++ b/src/authentic2/apps.py @@ -3,14 +3,61 @@ import re from django.apps import AppConfig from django.views import debug -from . import plugins +from django.db import connection +from django.db.models.signals import post_migrate + +from . import plugins, compat class Authentic2Config(AppConfig): name = 'authentic2' verbose_name = 'Authentic2' + def post_migrate_update_json_column(self, sender, **kwargs): + # adapted from https://github.com/kbussell/django-jsonfield-compat/blob/4f6ac4bfaea2224559b174b6d16d846b93d125c6/jsonfield_compat/convert.py + # MIT License, kbussel + if connection.vendor != 'postgresql': + return + + if compat.has_postgresql_support(): + expected_type = 'JSONB' + else: + expected_type = 'TEXT' + + + def convert_column_to_json(model, column_name): + table_name = model._meta.db_table + + with connection.cursor() as cursor: + cursor.execute( + "select data_type from information_schema.columns " + "where table_name = %s and column_name = %s;", + [table_name, column_name]) + + current_type = cursor.fetchone()[0].upper() + + if current_type != expected_type: + print("{app}: Converting {col} to use native {type} field".format( + app=model._meta.app_label, col=column_name, type=expected_type)) + + cursor.execute( + "ALTER TABLE {table} ALTER COLUMN {col} " + "TYPE {type} USING {col}::{type};".format( + table=table_name, col=column_name, type=expected_type + ) + ) + + def convert_model_json_fields(model): + json_fields = [f for f in model._meta.fields if f.__class__ == compat.JSONField] + for field in json_fields: + _, column_name = field.get_attname_column() + convert_column_to_json(model, column_name) + + for model in list(sender.get_models()): + convert_model_json_fields(model) + def ready(self): plugins.init() debug.HIDDEN_SETTINGS = re.compile( 'API|TOKEN|KEY|SECRET|PASS|PROFANITIES_LIST|SIGNATURE|LDAP') + post_migrate.connect(self.post_migrate_update_json_column) diff --git a/src/authentic2/compat.py b/src/authentic2/compat.py index ed09f92f..fe9dc665 100644 --- a/src/authentic2/compat.py +++ b/src/authentic2/compat.py @@ -1,6 +1,11 @@ from datetime import datetime +import inspect +import django from django.conf import settings +from django.db import connection + +from django.contrib.auth.tokens import PasswordResetTokenGenerator try: from django.contrib.auth import get_user_model @@ -14,10 +19,97 @@ try: except ImportError: from django.db.transaction import commit_on_success -from . import app_settings, utils - user_model_label = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') -from django.contrib.auth.tokens import PasswordResetTokenGenerator - default_token_generator = PasswordResetTokenGenerator() + + +def has_postgresql_support(): + if not settings.DATABASES['default'].get('NAME'): + return False + return connection.vendor == 'postgresql' and connection.pg_version > 90400 + + +def use_django_native_field(): + return has_postgresql_support() and django.VERSION >= (1, 11) + + +class JSONField(object): + __dj11_field = None + __jsonfield_field = None + __name = None + + def __init__(self, *args, **kwargs): + self.__args = args + self.__kwargs = kwargs + if django.VERSION >= (1, 11): + from django.contrib.postgres.fields import JSONField + self.__dj11_field = JSONField(*args, **kwargs) + try: + from jsonfield.fields import JSONField + self.__jsonfield_field = JSONField(*args, **kwargs) + except ImportError: + pass + + def __real_field__(self): + if use_django_native_field(): + assert self.__dj11_field + return self.__dj11_field + assert self.__jsonfield_field + return self.__jsonfield_field + + def __getattr__(self, key): + return getattr(self.__real_field__(), key) + + def __setattr__(self, key, value): + if key.startswith('_JSONField__'): + super(JSONField, self).__setattr__(key, value) + else: + setattr(self.__real__field(), key, value) + + # we need to implement contribute_to_class so that the direct + # implementation from the two sub-fields is not used directly + def contribute_to_class(self, cls, name, private_only=False, virtual_only=False, **kwargs): + assert not virtual_only and not private_only, 'virtual_only / private_only are not supported' + assert not kwargs, 'new arguments to contribute_to_class not supported' + self.__name = name + if self.__dj11_field: + self.__dj11_field.set_attributes_from_name(name) + self.__dj11_field.model = cls + if self.__jsonfield_field: + self.__jsonfield_field.set_attributes_from_name(name) + self.__jsonfield_field.model = cls + cls._meta.add_field(self) + + # the next two methods are useful for compatibilit with the migration engine + # inspect is used because migration autodetector cannot recognize this class + # as a subclass of models.Field. + def deconstruct(self): + d = (self.__name, 'authentic2.compat.JSONField', self.__args, self.__kwargs) + previous_frame = inspect.currentframe().f_back + if inspect.getframeinfo(previous_frame)[2] in ('serialize', 'deep_deconstruct'): + d = d[1:] + return d + + def clone(self): + from copy import copy + new = copy(self) + if self.__dj11_field: + new.__dj11_field = new.__dj11_field.clone() + if self.__jsonfield_field: + new.__jsonfield_field = new.__jsonfield_field.clone() + return new + + +try: + from jsonfield import fields +except ImportError: + pass +else: + # prevent django-jsonfield from modifying postgresql connection when we are + # not using it + def configure_database_connection(connection, **kwargs): + if django.VERSION < (1, 11): + fields.configure_database_connection(connection, **kwargs) + fields.connection_created.disconnect(fields.configure_database_connection) + fields.connection_created.connect(configure_database_connection) diff --git a/src/authentic2_auth_oidc/migrations/0007_auto_20181219_0005.py b/src/authentic2_auth_oidc/migrations/0007_auto_20181219_0005.py new file mode 100644 index 00000000..36774499 --- /dev/null +++ b/src/authentic2_auth_oidc/migrations/0007_auto_20181219_0005.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.17 on 2018-12-18 23:05 +from __future__ import unicode_literals + +import authentic2.compat +import authentic2_auth_oidc.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentic2_auth_oidc', '0006_oidcprovider_claims_parameter_supported'), + ] + + operations = [ + migrations.AlterField( + model_name='oidcprovider', + name='jwkset_json', + field=authentic2.compat.JSONField(blank=True, null=True, validators=[authentic2_auth_oidc.models.validate_jwkset], verbose_name='JSON WebKey set'), + ), + ] diff --git a/src/authentic2_auth_oidc/models.py b/src/authentic2_auth_oidc/models.py index a95acac5..28ada5da 100644 --- a/src/authentic2_auth_oidc/models.py +++ b/src/authentic2_auth_oidc/models.py @@ -6,12 +6,13 @@ from django.utils.translation import ugettext_lazy as _ from django.conf import settings from django.core.exceptions import ValidationError -from jsonfield import JSONField from jwcrypto.jwk import JWKSet, InvalidJWKValue, JWK from django_rbac.utils import get_ou_model_name +from authentic2 import compat + from . import managers @@ -89,7 +90,7 @@ class OIDCProvider(models.Model): max_length=128, blank=True, verbose_name=_('scopes')) - jwkset_json = JSONField( + jwkset_json = compat.JSONField( verbose_name=_('JSON WebKey set'), null=True, blank=True, diff --git a/tox.ini b/tox.ini index 152c85a0..6598b8c6 100644 --- a/tox.ini +++ b/tox.ini @@ -31,6 +31,7 @@ deps = dj111: django<2.0 dj111: django-tables<2.0 pg: psycopg2-binary + dj111: psycopg2-binary coverage pytest-cov pytest-django -- 2.18.0