diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 7fa968c..bd31b4a 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -34,6 +34,12 @@ jobs: - python-version: "3.11" django-version: Django==3.2 + - python-version: "3.10" + django-version: Django==4.2 + + - python-version: "3.11" + django-version: Django==4.2 + steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/chamber/config.py b/chamber/config.py index a92490c..bf95824 100644 --- a/chamber/config.py +++ b/chamber/config.py @@ -8,6 +8,14 @@ 'PRIVATE_S3_STORAGE_URL_EXPIRATION': 3600, 'AWS_S3_ON': getattr(django_settings, 'AWS_S3_ON', False), 'AWS_REGION': getattr(django_settings, 'AWS_REGION', None), + 'SMART_MODEL_ATTRIBUTES': { + 'is_cleaned_pre_save': False, + 'is_cleaned_post_save': False, + 'is_cleaned_pre_delete': False, + 'is_cleaned_post_delete': False, + 'is_save_atomic': False, + 'is_delete_atomic': False, + } } diff --git a/chamber/formatters/__init__.py b/chamber/formatters/__init__.py index 83a0873..3d88bb0 100644 --- a/chamber/formatters/__init__.py +++ b/chamber/formatters/__init__.py @@ -1,5 +1,4 @@ from django.utils import numberformat -from django.utils.encoding import force_text from django.utils.safestring import mark_safe @@ -16,6 +15,6 @@ def natural_number_with_currency(number, currency, show_decimal_place=True, use_ thousand_sep=' ', force_grouping=True ), - force_text(currency) + str(currency) ) return mark_safe(humanized.replace(' ', '\u00a0')) if use_nbsp else humanized diff --git a/chamber/forms/fields.py b/chamber/forms/fields.py index ccb43e2..d1198e2 100644 --- a/chamber/forms/fields.py +++ b/chamber/forms/fields.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import ugettext +from django.utils.translation import gettext from chamber.config import settings @@ -38,7 +38,7 @@ class PriceField(DecimalField): widget = PriceNumberInput def __init__(self, *args, **kwargs): - currency = kwargs.pop('currency', ugettext('CZK')) + currency = kwargs.pop('currency', gettext('CZK')) kwargs.setdefault('max_digits', 10) kwargs.setdefault('decimal_places', 2) if 'widget' not in kwargs: diff --git a/chamber/forms/validators.py b/chamber/forms/validators.py index 7e1e9ab..571bd5d 100644 --- a/chamber/forms/validators.py +++ b/chamber/forms/validators.py @@ -3,7 +3,7 @@ import magic # pylint: disable=E0401 from django.core.exceptions import ValidationError -from django.utils.translation import ugettext +from django.utils.translation import gettext from django.template.defaultfilters import filesizeformat @@ -15,7 +15,7 @@ def __init__(self, max_upload_size): def __call__(self, data): if data.size > self.max_upload_size: raise ValidationError( - ugettext('Please keep filesize under {max}. Current filesize {current}').format( + gettext('Please keep filesize under {max}. Current filesize {current}').format( max=filesizeformat(self.max_upload_size), current=filesizeformat(data.size) ) @@ -33,7 +33,7 @@ def __call__(self, data): extension_mime_type = mimetypes.guess_type(data.name)[0] if extension_mime_type not in self.content_types: - raise ValidationError(ugettext('Extension of file name is not allowed')) + raise ValidationError(gettext('Extension of file name is not allowed')) return data @@ -49,6 +49,6 @@ def __call__(self, data): mime_type = m.id_buffer(data.read(2048)) data.seek(0) if mime_type not in self.content_types: - raise ValidationError(ugettext('File content was evaluated as not supported file type')) + raise ValidationError(gettext('File content was evaluated as not supported file type')) return data diff --git a/chamber/models/__init__.py b/chamber/models/__init__.py index 43ae298..0e09cb9 100644 --- a/chamber/models/__init__.py +++ b/chamber/models/__init__.py @@ -1,5 +1,12 @@ -from .base import AuditModel, SmartQuerySet, SmartManager, SmartModel, SmartModelBase # noqa: F401 +from .base import SmartAuditModel, SmartQuerySet, SmartManager, SmartModel, SmartModelBase # noqa: F401 from .fields import ( # noqa: F401 DecimalField, FileField, ImageField, PrevValuePositiveIntegerField, SubchoicesPositiveIntegerField, EnumSequencePositiveIntegerField, EnumSequenceCharField, PriceField, PositivePriceField ) + + +__all__ = ( + 'SmartAuditModel', 'SmartQuerySet', 'SmartManager', 'SmartModel', 'SmartModelBase', + 'DecimalField', 'FileField', 'ImageField', 'PrevValuePositiveIntegerField', 'SubchoicesPositiveIntegerField', + 'EnumSequencePositiveIntegerField', 'EnumSequenceCharField', 'PriceField', 'PositivePriceField' +) diff --git a/chamber/models/base.py b/chamber/models/base.py index 71c58f6..db53ca3 100644 --- a/chamber/models/base.py +++ b/chamber/models/base.py @@ -2,12 +2,13 @@ from django.db.models.manager import BaseManager from django.db.models.base import ModelBase from django.core.exceptions import ValidationError -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.utils.functional import cached_property from chamber.exceptions import PersistenceException from chamber.patch import Options from chamber.shortcuts import change_and_save, change, bulk_change_and_save +from chamber.config import settings from .changed_fields import DynamicChangedFields from .signals import dispatcher_post_save, dispatcher_pre_save @@ -67,14 +68,13 @@ class SmartModelBase(ModelBase): """ def __new__(cls, name, bases, attrs): - new_cls = super().__new__(cls, name, bases, attrs) for dispatcher in new_cls.dispatchers: dispatcher.connect(new_cls) return new_cls -class AuditModel(models.Model): +class AuditModelMixin: created_at = models.DateTimeField( verbose_name=_('created at'), @@ -91,11 +91,8 @@ class AuditModel(models.Model): db_index=True ) - class Meta: - abstract = True - -class SmartModel(AuditModel, metaclass=SmartModelBase): +class SmartModel(models.Model, metaclass=SmartModelBase): objects = SmartManager() @@ -234,7 +231,9 @@ def _save(self, update_only_changed_fields=False, is_cleaned_pre_save=None, is_c post_save_changed_fields = self.changed_fields.get_static_changes() if not update_fields and update_only_changed_fields: - update_fields = list(post_save_changed_fields.keys()) + ['changed_at'] + update_fields = list(post_save_changed_fields.keys()) + [ + field.name for field in self._meta.fields if getattr(field, 'auto_now', False) + ] # remove primary key from updating fields if self._meta.pk.name in update_fields: update_fields.remove(self._meta.pk.name) @@ -360,11 +359,10 @@ class SmartOptions(Options): meta_class_name = 'SmartMeta' meta_name = '_smart_meta' model_class = SmartModel - attributes = { - 'is_cleaned_pre_save': True, - 'is_cleaned_post_save': False, - 'is_cleaned_pre_delete': False, - 'is_cleaned_post_delete': False, - 'is_save_atomic': False, - 'is_delete_atomic': False, - } + attributes = settings.SMART_MODEL_ATTRIBUTES + + +class SmartAuditModel(AuditModelMixin, SmartModel): + + class Meta: + abstract = True diff --git a/chamber/models/fields.py b/chamber/models/fields.py index 085bd03..6008e13 100644 --- a/chamber/models/fields.py +++ b/chamber/models/fields.py @@ -7,8 +7,7 @@ from django.db import models from django.db.models import FileField as OriginFileField from django.db.models.fields import DecimalField as OriginDecimalField -from django.utils.encoding import force_text -from django.utils.translation import ugettext +from django.utils.translation import gettext from chamber.config import settings from chamber.forms import fields as chamber_fields @@ -71,7 +70,7 @@ def generate_filename(self, instance, filename): """ from unidecode import unidecode - return super().generate_filename(instance, unidecode(force_text(filename))) + return super().generate_filename(instance, unidecode(str(filename))) class FileField(RestrictedFileFieldMixin, OriginFileField): @@ -133,12 +132,12 @@ def clean(self, value, model_instance): def _raise_error_if_value_should_be_empty(self, value, subvalue): if self.enum and subvalue not in self.enum.categories and value is not None: - raise ValidationError(ugettext('Value must be empty')) + raise ValidationError(gettext('Value must be empty')) def _raise_error_if_value_not_allowed(self, value, subvalue, model_instance): allowed_values = self.enum.get_allowed_states(getattr(model_instance, self.supchoices_field_name)) if subvalue in self.enum.categories and value not in allowed_values: - raise ValidationError(ugettext('Allowed choices are {}.').format( + raise ValidationError(gettext('Allowed choices are {}.').format( ', '.join(('{} ({})'.format(*(self.enum.get_label(val), val)) for val in allowed_values)) )) @@ -169,7 +168,7 @@ def validate(self, value, model_instance): if ((self.name in model_instance.changed_fields or model_instance.is_adding) and value not in allowed_next_values): raise ValidationError( - ugettext('Allowed choices are {}.').format( + gettext('Allowed choices are {}.').format( ', '.join(('{} ({})'.format(*(self.enum.get_label(val), val)) for val in allowed_next_values)))) @@ -184,7 +183,7 @@ class EnumSequenceCharField(EnumSequenceFieldMixin, models.CharField): class PriceField(DecimalField): def __init__(self, *args, **kwargs): - self.currency = kwargs.pop('currency', ugettext('CZK')) + self.currency = kwargs.pop('currency', gettext('CZK')) super().__init__(*args, **{ 'decimal_places': 2, 'max_digits': 10, diff --git a/chamber/models/humanized_helpers/__init__.py b/chamber/models/humanized_helpers/__init__.py index 637d5ff..59633c8 100644 --- a/chamber/models/humanized_helpers/__init__.py +++ b/chamber/models/humanized_helpers/__init__.py @@ -1,4 +1,4 @@ -from django.utils.translation import ugettext +from django.utils.translation import gettext from chamber.formatters import natural_number_with_currency @@ -7,5 +7,5 @@ def price_humanized(value, inst, currency=None): """ Return a humanized price """ - return (natural_number_with_currency(value, ugettext('CZK') if currency is None else currency) if value is not None - else ugettext('(None)')) + return (natural_number_with_currency(value, gettext('CZK') if currency is None else currency) if value is not None + else gettext('(None)')) diff --git a/chamber/utils/http.py b/chamber/utils/http.py index 9e3517b..dfe92c3 100644 --- a/chamber/utils/http.py +++ b/chamber/utils/http.py @@ -1,14 +1,13 @@ from collections import OrderedDict from django.http.request import QueryDict -from django.utils.encoding import force_text def query_string_from_dict(qs_dict): qs_prepared_dict = OrderedDict() for key, val in qs_dict.items(): if isinstance(val, list): - val = '[%s]' % ','.join([force_text(v) for v in val]) + val = '[%s]' % ','.join([str(v) for v in val]) qs_prepared_dict[key] = val qdict = QueryDict('').copy() diff --git a/docs/models.rst b/docs/models.rst index f479252..c8826de 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -76,14 +76,6 @@ SmartModel .. class:: chamber.models.SmartModel - .. attribute:: created_at - - Because our experience has shown us that datetime of creation is very useful this field ``django.models.DateTimeField`` with ``auto_add_no`` set to ``True`` is added to every model that inherits from ``SmartModel`` - - .. attribute:: changed_at - - This model field is same case as ``created_at`` with the difference that there is used ``auto_now=True`` therefore every date and time of change is stored here. - .. attribute:: dispatchers List of defined pre or post save dispatchers. More obout it will find _dispatchers @@ -165,6 +157,21 @@ SmartModel The method returns the new instance of the self object which is locked in the database with ``select_for_update``. Method must be used in the django atomic block. +SmartAuditModel +---------- + +SmartModel with two extra fields `created_at` and `changed_at` to log when the model was created or updated. + +.. class:: chamber.models.SmartAuditModel + + .. attribute:: created_at + + Because our experience has shown us that datetime of creation is very useful this field ``django.models.DateTimeField`` with ``auto_add_no`` set to ``True`` is added to every model that inherits from ``SmartModel`` + + .. attribute:: changed_at + + This model field is same case as ``created_at`` with the difference that there is used ``auto_now=True`` therefore every date and time of change is stored here. + SmartMeta --------- @@ -174,7 +181,7 @@ SmartMeta similar like django meta is defined inside ``SmartModel`` and is acces .. attribute:: is_cleaned_pre_save - Defines if ``SmartModel`` will be automatically validated before saving. Default value is ``True`` + Defines if ``SmartModel`` will be automatically validated before saving. Default value is ``False`` .. attribute:: is_cleaned_post_save @@ -204,6 +211,19 @@ SmartMeta similar like django meta is defined inside ``SmartModel`` and is acces is_cleaned_pre_save = True is_cleaned_pre_delete = True +The default configuration for all smart models can be defined in your settings with setting `CHAMBER_SMART_MODEL_ATTRIBUTES`: + +.. code:: python + + CHAMBER_SMART_MODEL_ATTRIBUTES = { + 'is_cleaned_pre_save': True, + 'is_cleaned_post_save': False, + 'is_cleaned_pre_delete': False, + 'is_cleaned_post_delete': False, + 'is_save_atomic': False, + 'is_delete_atomic': False, + } + Unknown ------- diff --git a/example/dj/apps/test_chamber/models.py b/example/dj/apps/test_chamber/models.py index 73ec700..d96f8c1 100644 --- a/example/dj/apps/test_chamber/models.py +++ b/example/dj/apps/test_chamber/models.py @@ -1,6 +1,6 @@ from django.contrib.auth.models import AbstractBaseUser from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from chamber import models as chamber_models from chamber.models import fields as chamber_fields diff --git a/example/dj/apps/test_chamber/tests/models/__init__.py b/example/dj/apps/test_chamber/tests/models/__init__.py index 87e6438..1c05649 100644 --- a/example/dj/apps/test_chamber/tests/models/__init__.py +++ b/example/dj/apps/test_chamber/tests/models/__init__.py @@ -74,7 +74,7 @@ def test_smart_model_initial_values_should_be_unknown_for_not_saved_instance(sel assert_true(obj.has_changed) assert_true(obj.changed_fields) assert_equal( - set(obj.changed_fields.keys()), {'created_at', 'changed_at', 'id', 'datetime', 'name', 'number', 'data'} + set(obj.changed_fields.keys()), {'id', 'datetime', 'name', 'number', 'data'} ) assert_true(obj.is_adding) assert_false(obj.is_changing) @@ -123,7 +123,7 @@ def test_smart_model_initial_values_should_be_deferred_for_partly_loaded_instanc def test_smart_model_changed_fields(self): obj = TestProxySmartModel.objects.create(name='a') changed_fields = DynamicChangedFields(obj) - assert_equal(len(changed_fields), 4) + assert_equal(len(changed_fields), 2) changed_fields.from_db() assert_equal(len(changed_fields), 0) obj.name = 'b' @@ -133,7 +133,6 @@ def test_smart_model_changed_fields(self): assert_equal(changed_fields.changed_values, {'name': 'b'}) assert_equal(str(changed_fields), "{'name': ValueChange(initial='a', current='b')}") assert_true(changed_fields.has_key('name')) - assert_false(changed_fields.has_key('changed_at')) assert_equal(list(changed_fields.values()), [changed_fields['name']]) assert_equal(changed_fields.keys(), {'name'}) @@ -141,15 +140,15 @@ def test_smart_model_changed_fields(self): obj.save() # Initial values is not changed - assert_equal(len(changed_fields), 2) + assert_equal(len(changed_fields), 1) assert_equal(len(static_changed_fields), 1) - assert_equal(set(changed_fields.keys()), {'name', 'changed_at'}) + assert_equal(set(changed_fields.keys()), {'name'}) assert_equal(set(static_changed_fields.keys()), {'name'}) assert_equal(changed_fields['name'].initial, 'a') assert_equal(changed_fields['name'].current, 'b') - assert_true(changed_fields.has_any_key('name', 'crated_at')) - assert_false(changed_fields.has_any_key('invalid', 'crated_at')) + assert_true(changed_fields.has_any_key('name')) + assert_false(changed_fields.has_any_key('invalid')) assert_raises(AttributeError, changed_fields.__delitem__, 'name') assert_raises(AttributeError, changed_fields.clear) diff --git a/example/dj/apps/test_chamber/tests/models/fields.py b/example/dj/apps/test_chamber/tests/models/fields.py index 1e2a551..37a12ba 100644 --- a/example/dj/apps/test_chamber/tests/models/fields.py +++ b/example/dj/apps/test_chamber/tests/models/fields.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.core.files import File from django.test import TransactionTestCase -from django.utils.translation import ugettext_lazy +from django.utils.translation import gettext_lazy from chamber.exceptions import PersistenceException from chamber.forms import fields as form_fields @@ -130,20 +130,20 @@ def test_should_validate_positive_price_field(self): def test_should_check_price_form_field(self): field = TestFieldsModel._meta.get_field('price') # pylint: disable=W0212 - assert_equal(ugettext_lazy('EUR'), field.currency) + assert_equal(gettext_lazy('EUR'), field.currency) form_field = field.formfield() assert_true(isinstance(form_field.widget, form_fields.PriceNumberInput)) assert_equal(field.currency, form_field.widget.placeholder) def test_should_check_total_price_form_field(self): field = TestFieldsModel._meta.get_field('total_price') # pylint: disable=W0212 - assert_equal(ugettext_lazy('CZK'), field.currency) + assert_equal(gettext_lazy('CZK'), field.currency) form_field = field.formfield() assert_true(isinstance(form_field.widget, form_fields.PriceNumberInput)) model_fields = ( - ('price', ugettext_lazy('EUR'), {'max_digits', 'decimal_places'}), - ('total_price', ugettext_lazy('CZK'), {'max_digits', 'decimal_places', 'validators'}), + ('price', gettext_lazy('EUR'), {'max_digits', 'decimal_places'}), + ('total_price', gettext_lazy('CZK'), {'max_digits', 'decimal_places', 'validators'}), ) @data_consumer(model_fields) diff --git a/example/dj/backend_urls.py b/example/dj/backend_urls.py index 997a189..b366098 100644 --- a/example/dj/backend_urls.py +++ b/example/dj/backend_urls.py @@ -1,20 +1,8 @@ -from distutils.version import StrictVersion - -import django - -from django.conf.urls import url +from django.urls import re_path from test_chamber import views # pylint: disable=E0401 -if StrictVersion(django.get_version()) < StrictVersion('1.9'): - from django.conf.urls import patterns - - urlpatterns = patterns( - '', - url(r'^current_time_backend/$', views.current_datetime, name='current-datetime') - ) -else: - urlpatterns = [ - url(r'^current_time_backend/$', views.current_datetime, name='current-datetime') - ] +urlpatterns = [ + re_path(r'^current_time_backend/$', views.current_datetime, name='current-datetime') +] diff --git a/example/dj/frontend_urls.py b/example/dj/frontend_urls.py index 99a1a9c..5bcf6ab 100644 --- a/example/dj/frontend_urls.py +++ b/example/dj/frontend_urls.py @@ -1,20 +1,8 @@ -from distutils.version import StrictVersion - -import django - -from django.conf.urls import url +from django.urls import re_path from test_chamber import views # pylint: disable=E0401 -if StrictVersion(django.get_version()) < StrictVersion('1.9'): - from django.conf.urls import patterns - - urlpatterns = patterns( - '', - url(r'^current_time_frontend/$', views.current_datetime, name='current-datetime') - ) -else: - urlpatterns = [ - url(r'^current_time_frontend/$', views.current_datetime, name='current-datetime') - ] +urlpatterns = [ + re_path(r'^current_time_frontend/$', views.current_datetime, name='current-datetime') +] diff --git a/example/dj/libs/utils.py b/example/dj/libs/utils.py index 9a60f83..91b0e1b 100644 --- a/example/dj/libs/utils.py +++ b/example/dj/libs/utils.py @@ -2,6 +2,7 @@ class FakeObject(object): + def __init__(self, *args): pass diff --git a/example/dj/settings/base.py b/example/dj/settings/base.py index 7d8b133..aba4534 100644 --- a/example/dj/settings/base.py +++ b/example/dj/settings/base.py @@ -193,3 +193,13 @@ } } +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + +CHAMBER_SMART_MODEL_ATTRIBUTES = { + 'is_cleaned_pre_save': True, + 'is_cleaned_post_save': False, + 'is_cleaned_pre_delete': False, + 'is_cleaned_post_delete': False, + 'is_save_atomic': False, + 'is_delete_atomic': False, +} diff --git a/example/requirements.txt b/example/requirements.txt index 056bd23..9e226ca 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -1,9 +1,9 @@ -Django<=3.2 +Django<=4.2 flake8 freezegun==1.1.0 coveralls diff-match-patch==20110725.1 -django-germanium==2.3.6 +django-germanium==2.3.9 six==1.10.0 Pillow==9.3.0 boto3==1.16.47 diff --git a/setup.py b/setup.py index 8f9f880..b978d6a 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ 'Framework :: Django', ], install_requires=[ - 'django>=2.2, <4.0', + 'django>=3.1', 'Unidecode>=1.1.1', 'pyprind>=2.11.2', 'filemagic>=1.6',