Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release/2.6 #253

Closed
wants to merge 11 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[flake8]
max-complexity = 12
max-line-length = 120
exclude =
.*/
__pycache__
docs
~build
dist
*.md

per-file-ignores =
src/**/migrations/*.py:E501
4 changes: 2 additions & 2 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [ "3.10", "3.11"]
django-version: [ "3.2", "4.2", "5.0"]
python-version: [ "3.11", "3.12"]
django-version: [ "4.2", "5.1"]
db-engine: ["pg", "mysql"]
env:
PY_VER: ${{ matrix.python-version}}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
!.editorconfig
!.pre-commit-config.yaml
!.readthedocs.yaml
!.flake8
coverage.xml
notes.txt
build/
Expand Down
3 changes: 2 additions & 1 deletion CHANGES
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
(Dev)
Release 2.6
-----------
* add support do Django 5.x
* drop support python 3.9
* drop support django 3.x
* move to .pyproject.toml


Expand Down
48 changes: 32 additions & 16 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "django-concurrency"
version = "2.5"
version = "2.6.0"
description = "Optimistic lock implementation for Django. Prevents users from doing concurrent editing"
authors = [
{name = "sax", email = "[email protected]"},
Expand All @@ -10,6 +10,14 @@ requires-python = ">=3.10"
readme = "README.md"
license = {text = "MIT"}

[project.optional-dependencies]
dj4 = [
"django>=4.2,<5",
]
dj5 = [
"django>=5.1",
]

[tool.pdm]
[[tool.pdm.source]]
url = "https://pypi.org/simple"
Expand All @@ -18,18 +26,17 @@ name = "pypi"

[tool.pdm.dev-dependencies]
dev = [
"black",
"black>=24.8.0",
"bump2version>=1.0.1",
"check-manifest",
"django",
"django-reversion",
"django-webtest",
"flake8",
"isort",
"mock",
"pre-commit",
"psycopg2-binary",
"pytest",
"pytest>=8.3.3",
"pytest-cov",
"pytest-django",
"pytest-echo",
Expand All @@ -40,15 +47,24 @@ dev = [
]

[tool.isort]
combine_as_imports = true
default_section = "THIRDPARTY"
include_trailing_comma = true
known_tests = "pytest,unittest,factory"
known_demo = "demo"
known_django = "django"
sections = "FUTURE,STDLIB,DJANGO,THIRDPARTY,TESTS,FIRSTPARTY,DEMO,LOCALFOLDER"
known_first_party = "etools_validator"
multi_line_output = 3
line_length = 120
balanced_wrapping = true
order_by_type = false
profile = "black"

[tool.black]
line-length = 120
include = '\.pyi?$'
exclude = '''
/(
\.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| venv
| _build
| buck-out
| build
| dist
| migrations
| snapshots
)/
'''
19 changes: 0 additions & 19 deletions setup.cfg

This file was deleted.

6 changes: 3 additions & 3 deletions src/concurrency/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__author__ = 'sax'
__author__ = "sax"

VERSION = __version__ = "2.5"
NAME = 'django-concurrency'
VERSION = __version__ = "2.5.0"
NAME = "django-concurrency"
116 changes: 67 additions & 49 deletions src/concurrency/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@
from django.db import transaction
from django.db.models import Q
from django.forms import CheckboxInput
from django.forms.formsets import INITIAL_FORM_COUNT, ManagementForm, MAX_NUM_FORM_COUNT, TOTAL_FORM_COUNT
from django.forms.formsets import (
INITIAL_FORM_COUNT,
MAX_NUM_FORM_COUNT,
TOTAL_FORM_COUNT,
ManagementForm,
)
from django.forms.models import BaseModelFormSet
from django.http import HttpResponse, HttpResponseRedirect
from django.utils.encoding import force_str
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, ngettext
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext

from concurrency import core, forms
from concurrency.api import get_revision_of_object
Expand Down Expand Up @@ -66,7 +72,7 @@ def response_action(self, request, queryset): # noqa
# and bottom of the change list, for example). Get the action
# whose button was pushed.
try:
action_index = int(request.POST.get('index', 0))
action_index = int(request.POST.get("index", 0))
except ValueError: # pragma: no cover
action_index = 0

Expand All @@ -77,24 +83,24 @@ def response_action(self, request, queryset): # noqa

# Use the action whose button was pushed
try:
data.update({'action': data.getlist('action')[action_index]})
data.update({"action": data.getlist("action")[action_index]})
except IndexError: # pragma: no cover
# If we didn't get an action from the chosen form that's invalid
# POST data, so by deleting action it'll fail the validation check
# below. So no need to do anything here
pass

action_form = self.action_form(data, auto_id=None)
action_form.fields['action'].choices = self.get_action_choices(request)
action_form.fields["action"].choices = self.get_action_choices(request)

# If the form's valid we can handle the action.
if action_form.is_valid():
action = action_form.cleaned_data['action']
action = action_form.cleaned_data["action"]
func, name, description = self.get_actions(request)[action]

# Get the list of selected PKs. If nothing's selected, we can't
# perform an action on it, so bail.
if action_form.cleaned_data['select_across']:
if action_form.cleaned_data["select_across"]:
selected = ALL
else:
selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
Expand All @@ -114,20 +120,27 @@ def response_action(self, request, queryset): # noqa
try:
pk, version = x.split(",")
except ValueError: # pragma: no cover
raise ImproperlyConfigured('`ConcurrencyActionMixin` error.'
'A tuple with `primary_key, version_number` '
'expected: `%s` found' % x)
filters.append(Q(**{'pk': pk,
revision_field.attname: version}))
raise ImproperlyConfigured(
"`ConcurrencyActionMixin` error."
"A tuple with `primary_key, version_number` "
"expected: `%s` found" % x
)
filters.append(Q(**{"pk": pk, revision_field.attname: version}))

queryset = queryset.filter(reduce(operator.or_, filters))
if len(selected) != queryset.count():
messages.error(request, 'One or more record were updated. '
'(Probably by other user) '
'The execution was aborted.')
messages.error(
request,
"One or more record were updated. "
"(Probably by other user) "
"The execution was aborted.",
)
return HttpResponseRedirect(".")
else:
messages.warning(request, 'Selecting all records, you will avoid the concurrency check')
messages.warning(
request,
"Selecting all records, you will avoid the concurrency check",
)

response = func(self, request, queryset)

Expand All @@ -142,7 +155,7 @@ def response_action(self, request, queryset): # noqa

class ConcurrentManagementForm(ManagementForm):
def __init__(self, *args, **kwargs):
self._versions = kwargs.pop('versions', [])
self._versions = kwargs.pop("versions", [])
super().__init__(*args, **kwargs)

def _get_concurrency_fields(self):
Expand Down Expand Up @@ -172,18 +185,20 @@ class ConcurrentBaseModelFormSet(BaseModelFormSet):
def _management_form(self):
"""Returns the ManagementForm instance for this FormSet."""
if self.is_bound:
form = ConcurrentManagementForm(self.data, auto_id=self.auto_id,
prefix=self.prefix)
form = ConcurrentManagementForm(self.data, auto_id=self.auto_id, prefix=self.prefix)
if not form.is_valid():
raise ValidationError('ManagementForm data is missing or has been tampered with')
raise ValidationError("ManagementForm data is missing or has been tampered with")
else:
form = ConcurrentManagementForm(auto_id=self.auto_id,
prefix=self.prefix,
initial={TOTAL_FORM_COUNT: self.total_form_count(),
INITIAL_FORM_COUNT: self.initial_form_count(),
MAX_NUM_FORM_COUNT: self.max_num},
versions=[(form.instance.pk, get_revision_of_object(form.instance)) for form
in self.initial_forms])
form = ConcurrentManagementForm(
auto_id=self.auto_id,
prefix=self.prefix,
initial={
TOTAL_FORM_COUNT: self.total_form_count(),
INITIAL_FORM_COUNT: self.initial_form_count(),
MAX_NUM_FORM_COUNT: self.max_num,
},
versions=[(form.instance.pk, get_revision_of_object(form.instance)) for form in self.initial_forms],
)
return form

management_form = property(_management_form)
Expand All @@ -193,17 +208,17 @@ class ConcurrencyListEditableMixin:
list_editable_policy = conf.POLICY

def get_changelist_formset(self, request, **kwargs):
kwargs['formset'] = ConcurrentBaseModelFormSet
kwargs["formset"] = ConcurrentBaseModelFormSet
return super().get_changelist_formset(request, **kwargs)

def _add_conflict(self, request, obj):
if hasattr(request, '_concurrency_list_editable_errors'):
if hasattr(request, "_concurrency_list_editable_errors"):
request._concurrency_list_editable_errors.append(obj.pk)
else:
request._concurrency_list_editable_errors = [obj.pk]

def _get_conflicts(self, request):
if hasattr(request, '_concurrency_list_editable_errors'):
if hasattr(request, "_concurrency_list_editable_errors"):
return request._concurrency_list_editable_errors
else:
return []
Expand All @@ -212,7 +227,7 @@ def _get_conflicts(self, request):
def save_model(self, request, obj, form, change):
try:
if change:
version = request.POST.get(f'{concurrency_param_name}_{obj.pk}', None)
version = request.POST.get(f"{concurrency_param_name}_{obj.pk}", None)
if version:
core._set_version(obj, version)
super().save_model(request, obj, form, change)
Expand Down Expand Up @@ -248,33 +263,36 @@ def message_user(self, request, message, *args, **kwargs):
m = rex.match(message)
concurrency_errros = len(conflicts)
if m:
updated_record = int(m.group('num')) - concurrency_errros
updated_record = int(m.group("num")) - concurrency_errros

ids = ",".join(map(str, conflicts))
messages.error(request,
ngettext("Record with pk `{0}` has been modified and was not updated",
"Records `{0}` have been modified and were not updated",
concurrency_errros).format(ids))
messages.error(
request,
ngettext(
"Record with pk `{0}` has been modified and was not updated",
"Records `{0}` have been modified and were not updated",
concurrency_errros,
).format(ids),
)
if updated_record == 1:
name = force_str(opts.verbose_name)
else:
name = force_str(opts.verbose_name_plural)

message = None
if updated_record > 0:
message = ngettext("%(count)s %(name)s was changed successfully.",
"%(count)s %(name)s were changed successfully.",
updated_record) % {'count': updated_record,
'name': name}
message = ngettext(
"%(count)s %(name)s was changed successfully.",
"%(count)s %(name)s were changed successfully.",
updated_record,
) % {"count": updated_record, "name": name}

return super().message_user(request, message, *args, **kwargs)


class ConcurrentModelAdmin(ConcurrencyActionMixin,
ConcurrencyListEditableMixin,
admin.ModelAdmin):
class ConcurrentModelAdmin(ConcurrencyActionMixin, ConcurrencyListEditableMixin, admin.ModelAdmin):
form = ConcurrentForm
formfield_overrides = {forms.VersionField: {'widget': VersionWidget}}
formfield_overrides = {forms.VersionField: {"widget": VersionWidget}}

def check(self, **kwargs):
errors = super().check(**kwargs)
Expand All @@ -283,23 +301,23 @@ def check(self, **kwargs):
if version_field.name not in self.fields:
errors.append(
Error(
'Missed version field in {} fields definition'.format(self),
"Missed version field in {} fields definition".format(self),
hint="Please add '{}' to the 'fields' attribute".format(version_field.name),
obj=None,
id='concurrency.A001',
id="concurrency.A001",
)
)
if self.fieldsets:
version_field = self.model._concurrencymeta.field
fields = flatten([v['fields'] for k, v in self.fieldsets])
fields = flatten([v["fields"] for k, v in self.fieldsets])

if version_field.name not in fields:
errors.append(
Error(
'Missed version field in {} fieldsets definition'.format(self),
"Missed version field in {} fieldsets definition".format(self),
hint="Please add '{}' to the 'fieldsets' attribute".format(version_field.name),
obj=None,
id='concurrency.A002',
id="concurrency.A002",
)
)
return errors
Loading
Loading