From 623c5ba7294ded57b6d3b3cc1b6c4176b7648a1f Mon Sep 17 00:00:00 2001 From: Frankie Dintino Date: Fri, 12 Jul 2024 22:15:55 -0400 Subject: [PATCH] chore: CI updates, fix deprecation issues (#252) * fix(ci): bump version of nanasess/setup-chromedriver to v2.2.2 * fix(ci): use latest selenium and update deprecated methods * fix(ci): increase speed of move_to_element actions * fix(tests): finesse element offset targets for drag-and-drop operations * fix: flake8 and black failures * chore(ci): update tox envlist and gha matrix * add setuptools to tox until jazzband/django-polymorphic#599 is released --- .github/workflows/test.yml | 36 ++++++++++--------- nested_admin/nested.py | 12 ++++--- nested_admin/polymorphic.py | 9 +++-- nested_admin/tests/admin_widgets/tests.py | 16 ++++----- nested_admin/tests/base.py | 26 +++++++++++--- nested_admin/tests/drag_drop.py | 33 +++++++++-------- nested_admin/tests/nested_polymorphic/base.py | 29 ++++++++------- .../test_polymorphic_mixed_nesting/tests.py | 9 ++--- nested_admin/tests/one_deep/tests.py | 5 +-- nested_admin/tests/two_deep/tests.py | 3 +- tox.ini | 24 +++++++++++-- 11 files changed, 130 insertions(+), 72 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aab608e..fdf68e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,27 +7,28 @@ jobs: strategy: fail-fast: false matrix: - grappelli: ["0"] - python-version: ["3.8"] - django-version: ["3.2"] + grappelli: ["0", "1"] + python-version: ["3.11"] + django-version: ["4.2"] + exclude: + - python-version: "3.11" + grappelli: "1" include: - - grappelli: "0" - name-suffix: "" - python-version: "3.9" - django-version: "4.0" + django-version: "4.2" + grappelli: "1" + - python-version: "3.9" + django-version: "5.0" + grappelli: "0" - python-version: "3.10" - django-version: "4.1" - - grappelli: "1" - name-suffix: " + grappelli" - python-version: "3.7" - django-version: "3.2" - - grappelli: "1" - name-suffix: " + grappelli" - python-version: "3.8" - django-version: "4.0" + django-version: "5.0" + grappelli: "1" + - python-version: "3.12" + django-version: "5.1" + grappelli: "0" runs-on: ubuntu-latest - name: Django ${{ matrix.django-version }} (Python ${{ matrix.python-version }})${{ matrix.name-suffix }} + name: Django ${{ matrix.django-version }} (Python ${{ matrix.python-version }})${{ matrix.grappelli == '1' && ' + grappelli' || '' }} env: DJANGO: ${{ matrix.django-version }} @@ -45,7 +46,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Setup chromedriver - uses: nanasess/setup-chromedriver@v1.0.5 + # uses: nanasess/setup-chromedriver@v2.2.2 + uses: nanasess/setup-chromedriver@42cc2998329f041de87dc3cfa33a930eacd57eaa - name: Install tox run: | diff --git a/nested_admin/nested.py b/nested_admin/nested.py index 12429d1..1cc505c 100644 --- a/nested_admin/nested.py +++ b/nested_admin/nested.py @@ -193,9 +193,11 @@ def inline_formset_data(self): "lookupAutocomplete": getattr( self.opts, "autocomplete_lookup_fields", {} ), - "formsetFkName": self.formset.fk.name - if getattr(self.formset, "fk", None) - else "", + "formsetFkName": ( + self.formset.fk.name + if getattr(self.formset, "fk", None) + else "" + ), "formsetFkModel": formset_fk_model, "nestingLevel": getattr(self.formset, "nesting_depth", 0), "fieldNames": { @@ -538,13 +540,15 @@ def __init__(self, *args, **kwargs): _get_formsets = ModelAdmin._get_formsets def get_formset(self, request, obj=None, **kwargs): - FormSet = BaseFormSet = kwargs.pop("formset", self.formset) + BaseFormSet = kwargs.pop("formset", self.formset) if self.sortable_field_name: class FormSet(BaseFormSet): sortable_field_name = self.sortable_field_name + else: + FormSet = BaseFormSet kwargs["formset"] = FormSet return super().get_formset(request, obj, **kwargs) diff --git a/nested_admin/polymorphic.py b/nested_admin/polymorphic.py index 311bc3d..da4034f 100644 --- a/nested_admin/polymorphic.py +++ b/nested_admin/polymorphic.py @@ -148,13 +148,15 @@ class Child( formset = NestedBasePolymorphicInlineFormSet def get_formset(self, request, obj=None, **kwargs): - FormSet = BaseFormSet = kwargs.pop("formset", self.formset) + BaseFormSet = kwargs.pop("formset", self.formset) if self.sortable_field_name: class FormSet(BaseFormSet): sortable_field_name = self.sortable_field_name + else: + FormSet = BaseFormSet kwargs["formset"] = FormSet return super(PolymorphicInlineModelAdmin.Child, self).get_formset( request, obj, **kwargs @@ -186,13 +188,16 @@ class Child( formset = NestedBaseGenericPolymorphicInlineFormSet def get_formset(self, request, obj=None, **kwargs): - FormSet = BaseFormSet = kwargs.pop("formset", self.formset) + BaseFormSet = kwargs.pop("formset", self.formset) if self.sortable_field_name: class FormSet(BaseFormSet): sortable_field_name = self.sortable_field_name + else: + FormSet = BaseFormSet + kwargs["formset"] = FormSet return super(GenericPolymorphicInlineModelAdmin.Child, self).get_formset( request, obj, **kwargs diff --git a/nested_admin/tests/admin_widgets/tests.py b/nested_admin/tests/admin_widgets/tests.py index c7bbff3..8f4527f 100644 --- a/nested_admin/tests/admin_widgets/tests.py +++ b/nested_admin/tests/admin_widgets/tests.py @@ -107,7 +107,7 @@ def check_datetime(self, indexes): now_link_xpath = "following-sibling::*[1]/a[1]" date_el.clear() time_el.clear() - self.click(date_el.find_element_by_xpath(now_link_xpath)) + self.click(date_el.find_element(By.XPATH, now_link_xpath)) if self.has_grappelli: selector = "#ui-datepicker-div .ui-state-highlight" with self.clickable_selector(selector, timeout=1) as el: @@ -119,7 +119,7 @@ def check_datetime(self, indexes): "Datepicker widget did not close", ) time.sleep(0.2) - self.click(time_el.find_element_by_xpath(now_link_xpath)) + self.click(time_el.find_element(By.XPATH, now_link_xpath)) if self.has_grappelli: selector = "#ui-timepicker .ui-state-active" with self.clickable_selector(selector, timeout=1) as el: @@ -153,7 +153,7 @@ def check_m2m(self, indexes): def check_fk(self, indexes): field = self.get_field("fk1", indexes) parent = field.get_property("parentNode").get_property("parentNode") - add_related = parent.find_element_by_css_selector(".add-related") + add_related = parent.find_element(By.CSS_SELECTOR, ".add-related") if self.has_grappelli: # Grappelli can be very slow to initialize fk bindings, particularly # when run on travis-ci @@ -185,7 +185,7 @@ def check_gfk_related_lookup(self, indexes): % object_id_field_id, ) - lookup_el = self.selenium.find_element_by_css_selector(related_lookup_selector) + lookup_el = self.selenium.find_element(By.CSS_SELECTOR, related_lookup_selector) lookup_el.click() with self.switch_to_popup_window(): with self.clickable_xpath('//tr//a[text()="Zither"]') as el: @@ -195,7 +195,7 @@ def check_gfk_related_lookup(self, indexes): z_pk = "%s" % WidgetsM2M.objects.get(name="Zither").pk def element_value_populated(d): - el = d.find_element_by_css_selector("#%s" % object_id_field_id) + el = d.find_element(By.CSS_SELECTOR, "#%s" % object_id_field_id) return el.get_attribute("value") self.wait_until( @@ -376,8 +376,8 @@ def test_autocomplete_single_init(self): self.load_admin() self.add_inline() self.add_inline([1]) - autocomplete_elements = self.selenium.find_elements_by_xpath( - '//*[@id="id_widgetsa_set-1-widgetsb_set-0-fk2-autocomplete"]' + autocomplete_elements = self.selenium.find_elements( + By.XPATH, '//*[@id="id_widgetsa_set-1-widgetsb_set-0-fk2-autocomplete"]' ) self.assertNotEqual( len(autocomplete_elements), 0, "Zero autocomplete fields initialized" @@ -405,7 +405,7 @@ def test_nested_autocomplete_extra(self): self.add_inline([0, [0]]) self.add_inline([0, 1, [0]]) select_field = self.get_field("fk3", indexes=[0, 1, [0, 0]]) - select_parent = select_field.find_element_by_xpath("parent::*") + select_parent = select_field.find_element(By.XPATH, "parent::*") select_parent.click() select2_is_active = self.selenium.execute_script( 'return $(".select2-search__field").length > 0' diff --git a/nested_admin/tests/base.py b/nested_admin/tests/base.py index aff9e05..9bd17a7 100644 --- a/nested_admin/tests/base.py +++ b/nested_admin/tests/base.py @@ -12,6 +12,8 @@ from django.contrib.admin.sites import site as admin_site from selenosis import AdminSelenosisTestCase +from selenium.webdriver.common.by import By +from selenium.webdriver.common.actions.pointer_input import PointerInput from .drag_drop import DragAndDropAction from .utils import xpath_item, is_sequence, is_integer, is_str, ElementRect @@ -34,6 +36,20 @@ class BaseNestedAdminTestCase(AdminSelenosisTestCase): def setUpClass(cls): super().setUpClass() + # Increase speed of move_to_element action + PointerInput.DEFAULT_MOVE_DURATION = 1 + + if not hasattr(PointerInput.create_pointer_move, "_patched"): + orig_create_pointer_move = PointerInput.create_pointer_move + + def create_pointer_move(self, *args, **kwargs): + kwargs["duration"] = 1 + return orig_create_pointer_move(self, *args, **kwargs) + + create_pointer_move._patched = True + + PointerInput.create_pointer_move = create_pointer_move + root_admin = admin_site._registry[cls.root_model] def descend_admin_inlines(admin): @@ -180,7 +196,7 @@ def save_form(self): ) ) name_attr = "_continue" if has_continue else "_save" - self.click(self.selenium.find_element_by_xpath('//*[@name="%s"]' % name_attr)) + self.click(self.selenium.find_element(By.XPATH, '//*[@name="%s"]' % name_attr)) if has_continue: self.wait_page_loaded() self.initialize_page() @@ -362,15 +378,15 @@ def get_group(self, indexes=None): ] expr_parts += ["/*[@data-inline-model='%s']" % model_name] expr = "/%s" % ("/".join(expr_parts)) - return self.selenium.find_element_by_xpath(expr) + return self.selenium.find_element(By.XPATH, expr) def get_item(self, indexes): indexes = self._normalize_indexes(indexes) model_name, item_index = indexes.pop() indexes.append(model_name) group = self.get_group(indexes=indexes) - return group.find_element_by_xpath( - ".//*[%s][%d]" % (xpath_item(model_name), item_index + 1) + return group.find_element( + By.XPATH, ".//*[%s][%d]" % (xpath_item(model_name), item_index + 1) ) def add_inline(self, indexes=None, name=None, slug=None): @@ -442,7 +458,7 @@ def get_form_field_selector(self, attname, indexes=None): def get_field(self, attname, indexes=None): indexes = self._normalize_indexes(indexes) field_selector = self.get_form_field_selector(attname, indexes=indexes) - return self.selenium.find_element_by_css_selector(field_selector) + return self.selenium.find_element(By.CSS_SELECTOR, field_selector) def set_field(self, attname, value, indexes=None): indexes = self._normalize_indexes(indexes) diff --git a/nested_admin/tests/drag_drop.py b/nested_admin/tests/drag_drop.py index ab45559..5cd1827 100644 --- a/nested_admin/tests/drag_drop.py +++ b/nested_admin/tests/drag_drop.py @@ -2,6 +2,7 @@ import time from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.by import By from .utils import xpath_cls, xpath_item, is_integer, Position, Size, ElementRect @@ -107,7 +108,7 @@ def source(self): "*[%s]" % xpath_cls("djn-drag-handler"), ] ) - self._source = source_item.find_element_by_xpath(drag_handler_xpath) + self._source = source_item.find_element(By.XPATH, drag_handler_xpath) return self._source @property @@ -127,7 +128,7 @@ def target(self): "item_pred": xpath_item(), "item_pos": self.to_indexes[-1][1] + 1, } - self._target = target_inline_parent.find_element_by_xpath(target_xpath) + self._target = target_inline_parent.find_element(By.XPATH, target_xpath) return self._target def initialize_drag(self): @@ -140,6 +141,10 @@ def initialize_drag(self): document.documentElement.scrollTop += (top - 16); } else { el.scrollIntoView(); + top = el.getBoundingClientRect().top; + if (top <= 15) { + document.documentElement.scrollTop += (top - 16); + } } """, source, @@ -148,15 +153,15 @@ def initialize_drag(self): ( ActionChains(self.selenium) - .move_to_element_with_offset(source, 5, 5) + .move_to_element_with_offset(source, 3, 3) .click_and_hold() .perform() ) time.sleep(0.05) - ActionChains(self.selenium).move_by_offset(0, -15).perform() + ActionChains(self.selenium).move_by_offset(0, -10).perform() time.sleep(0.05) - ActionChains(self.selenium).move_by_offset(0, 15).perform() + ActionChains(self.selenium).move_by_offset(0, 10).perform() with self.test_case.visible_selector(".ui-sortable-helper") as el: return el @@ -189,7 +194,7 @@ def _match_helper_with_target(self, helper_element, target_element): 15, min(viewport_height // 3, (2 * inline_height) // 3, abs(dy) // 2) ) - max_iter = 50 + max_iter = 120 i = 0 prev_pos_diff = None direction = None @@ -220,9 +225,9 @@ def _match_helper_with_target(self, helper_element, target_element): if flip_count > 3: increment = 10 elif flip_count > 5: - increment = 5 + increment = 2 else: - increment = max(abs(dy // 2), flip_count * flip_multiplier) + increment = min(abs(dy // 2), flip_count * flip_multiplier) direction_flip *= -1 direction = pos_diff * direction_flip inc = increment * direction @@ -252,7 +257,7 @@ def _num_preceding_siblings(self, ctx, condition): is extraordinarily slow. So we just grab all siblings and iterate through the elements in python. """ - siblings = ctx.find_element_by_xpath("parent::*").find_elements_by_xpath("*") + siblings = ctx.find_element(By.XPATH, "parent::*").find_elements(By.XPATH, "*") count = 0 for el in siblings: if el.id == ctx.id: @@ -278,8 +283,8 @@ def is_djn_group(el): @property def current_position(self): - placeholder = self.selenium.find_element_by_css_selector( - ".ui-sortable-placeholder" + placeholder = self.selenium.find_element( + By.CSS_SELECTOR, ".ui-sortable-placeholder" ) pos = [] ctx = None @@ -288,10 +293,10 @@ def current_position(self): if ctx is None: ctx = placeholder else: - ctx = ctx.find_element_by_xpath(ancestor_xpath) + ctx = ctx.find_element(By.XPATH, ancestor_xpath) item_index = self._num_preceding_djn_items(ctx) - ctx = ctx.find_element_by_xpath( - "ancestor::*[%s][1]" % xpath_cls("djn-group") + ctx = ctx.find_element( + By.XPATH, "ancestor::*[%s][1]" % xpath_cls("djn-group") ) inline_index = self._num_preceding_djn_groups(ctx) pos.insert(0, (inline_index, item_index)) diff --git a/nested_admin/tests/nested_polymorphic/base.py b/nested_admin/tests/nested_polymorphic/base.py index f384aed..2331007 100644 --- a/nested_admin/tests/nested_polymorphic/base.py +++ b/nested_admin/tests/nested_polymorphic/base.py @@ -1,5 +1,6 @@ from django.apps import apps from polymorphic.utils import get_base_polymorphic_model +from selenium.webdriver.common.by import By from nested_admin.tests.base import BaseNestedAdminTestCase from nested_admin.tests.utils import ( @@ -149,14 +150,15 @@ def get_item(self, indexes): except TypeError: group = self.get_group(indexes=group_indexes + [model_id]) group_id = group.get_attribute("id") - djn_items = self.selenium.find_element_by_css_selector( + djn_items = self.selenium.find_element( + By.CSS_SELECTOR, "#%(id)s > .djn-fieldset > .djn-items, " "#%(id)s > .tabular.inline-related > .djn-fieldset > .djn-items, " - "#%(id)s > .djn-items" % {"id": group_id} + "#%(id)s > .djn-items" % {"id": group_id}, ) model_name, item_index = indexes[-1] - return djn_items.find_element_by_xpath( - "./*[%s][%d]" % (xpath_item(), item_index + 1) + return djn_items.find_element( + By.XPATH, "./*[%s][%d]" % (xpath_item(), item_index + 1) ) def delete_inline(self, indexes): @@ -214,7 +216,7 @@ def add_inline(self, indexes=None, model=None, **kwargs): ctx_id, base_model_identifier, level ) ) - add_els = self.selenium.find_elements_by_css_selector(add_selector) + add_els = self.selenium.find_elements(By.CSS_SELECTOR, add_selector) self.assertNotEqual( len(add_els), 0, "No inline add handlers found for %s" % (error_desc) ) @@ -236,15 +238,17 @@ def add_inline(self, indexes=None, model=None, **kwargs): ) group_id = group_el.get_attribute("id") - items_el = self.selenium.find_element_by_css_selector( + items_el = self.selenium.find_element( + By.CSS_SELECTOR, "#%(id)s > .djn-fieldset > .djn-items, " "#%(id)s > .tabular.inline-related > .djn-fieldset > .djn-items, " - "#%(id)s > .djn-items" % {"id": group_id} + "#%(id)s > .djn-items" % {"id": group_id}, ) num_inlines = len( - items_el.find_elements_by_xpath( - "./*[{} and not({})]".format(xpath_item(), xpath_cls("djn-empty-form")) + items_el.find_elements( + By.XPATH, + "./*[{} and not({})]".format(xpath_item(), xpath_cls("djn-empty-form")), ) ) @@ -265,10 +269,11 @@ def remove_inline(self, indexes): def get_num_inlines(self, indexes=None): group = self.get_group(indexes=indexes) group_id = group.get_attribute("id") - djn_items = self.selenium.find_element_by_css_selector( + djn_items = self.selenium.find_element( + By.CSS_SELECTOR, "#%(id)s > .djn-fieldset > .djn-items, " "#%(id)s > .tabular.inline-related > .djn-fieldset > .djn-items, " - "#%(id)s > .djn-items" % {"id": group_id} + "#%(id)s > .djn-items" % {"id": group_id}, ) selector = "> .djn-item:not(.djn-no-drag,.djn-item-dragging,.djn-thead,.djn-empty-form)" return self.selenium.execute_script( @@ -288,4 +293,4 @@ def get_group(self, indexes=None): "/*[@data-inline-model='%s' and %s]" % (model_name, xpath_cls("djn-group")) ] expr = "/%s" % ("/".join(expr_parts)) - return self.selenium.find_element_by_xpath(expr) + return self.selenium.find_element(By.XPATH, expr) diff --git a/nested_admin/tests/nested_polymorphic/test_polymorphic_mixed_nesting/tests.py b/nested_admin/tests/nested_polymorphic/test_polymorphic_mixed_nesting/tests.py index 2b15e6c..aa63369 100644 --- a/nested_admin/tests/nested_polymorphic/test_polymorphic_mixed_nesting/tests.py +++ b/nested_admin/tests/nested_polymorphic/test_polymorphic_mixed_nesting/tests.py @@ -1,5 +1,6 @@ from unittest import SkipTest from django.test import TestCase +from selenium.webdriver.common.by import By from .models import ( Block, @@ -47,8 +48,8 @@ def test_polymorphic_child_formset_rendering(self): inline_ids = [ el.get_attribute("id") - for el in self.selenium.find_elements_by_css_selector( - '.djn-group:not([id*="-empty-"]' + for el in self.selenium.find_elements( + By.CSS_SELECTOR, '.djn-group:not([id*="-empty-"]' ) ] @@ -64,8 +65,8 @@ def test_polymorphic_child_formset_rendering(self): inline_ids = [ el.get_attribute("id") - for el in self.selenium.find_elements_by_css_selector( - '.djn-group:not([id*="-empty-"]' + for el in self.selenium.find_elements( + By.CSS_SELECTOR, '.djn-group:not([id*="-empty-"]' ) ] diff --git a/nested_admin/tests/one_deep/tests.py b/nested_admin/tests/one_deep/tests.py index c0ead55..b818d93 100644 --- a/nested_admin/tests/one_deep/tests.py +++ b/nested_admin/tests/one_deep/tests.py @@ -25,6 +25,7 @@ def find_executable(arg): from django.conf import settings from django.contrib.admin.sites import site as admin_site from django.test import override_settings +from selenium.webdriver.common.by import By try: from storages.backends.s3boto3 import S3Boto3Storage @@ -147,7 +148,7 @@ def _block_out_arg(self, selector): Generate the --block-out argument passed to pixelmatch that excludes an element from the diff """ - el = self.selenium.find_element_by_css_selector(selector) + el = self.selenium.find_element(By.CSS_SELECTOR, selector) return [ "--block-out", "{x},{y},{w},{h}".format( @@ -165,7 +166,7 @@ def exclude_from_screenshots(self, imgs, exclude=None): exclude = exclude or [] rects = [] for selector in exclude: - el = self.selenium.find_element_by_css_selector(selector) + el = self.selenium.find_element(By.CSS_SELECTOR, selector) x0, y0 = el.location["x"], el.location["y"] w, h = el.size["width"], el.size["height"] x1, y1 = x0 + w, y0 + h diff --git a/nested_admin/tests/two_deep/tests.py b/nested_admin/tests/two_deep/tests.py index c40f47e..9025a78 100644 --- a/nested_admin/tests/two_deep/tests.py +++ b/nested_admin/tests/two_deep/tests.py @@ -2,6 +2,7 @@ import tempfile import time from unittest import SkipTest +from selenium.webdriver.common.by import By from nested_admin.tests.base import BaseNestedAdminTestCase from .models import ( @@ -997,7 +998,7 @@ def test_add_item_inline_label_update(self): ) as el: el.send_keys("Test 2") - inline_label = self.get_item([0, 1]).find_element_by_class_name("inline_label") + inline_label = self.get_item([0, 1]).find_element(By.CLASS_NAME, "inline_label") self.assertEqual(inline_label.text, "#2") def test_upload_file(self): diff --git a/tox.ini b/tox.ini index 4fc5547..6f1843b 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,10 @@ envlist = py{36,37,38,39}-dj22-{grp,nogrp} py{36,37,38,39,310}-dj32-{grp,nogrp} py{38,39,310}-dj40-{grp,nogrp} - py{38,39,310}-dj41-nogrp + py{38,39,310,311}-dj41-{grp,nogrp} + py{38,39,310,311,312}-dj42-{grp,nogrp} + py{310,311,312}-dj50-{grp,nogrp} + py{310,311,312}-dj51-nogrp black,flake8 skipsdist=True @@ -24,17 +27,27 @@ passenv = DATABASE_URL deps = -e.[test] - selenium==3.141.0 + selenium coverage django-polymorphic + # setuptools is required until a django-polymorphic release includes + # the fix for jazzband/django-polymorphic#599 + setuptools boto3 django-storages dj22: Django>=2.2,<3.0 dj32: Django>=3.2,<4.0 dj40: Django>=4.0,<4.1 + dj41: Django>=4.1,<4.2 + dj42: Django>=4.2,<4.3 + dj50: Django>=5.0,<5.1 + dj51: Django>=5.1a1,<5.2 dj22-grp: django-grappelli>=2.13,<2.14 dj32-grp: django-grappelli>=2.15,<2.16 - dj40-grp: django-grappelli>=3.0,<3.1 + dj40-grp: django-grappelli==3.0.8 + dj41-grp: django-grappelli==3.0.8 + dj42-grp: django-grappelli>=3.0,<3.1 + dj50-grp: django-grappelli>=4.0,<4.1 [testenv:black] basepython = python3.9 @@ -79,6 +92,8 @@ python = 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311 + 3.12: py312 [gh-actions:env] DJANGO = @@ -86,6 +101,9 @@ DJANGO = 3.2: dj32 4.0: dj40 4.1: dj41 + 4.2: dj42 + 5.0: dj50 + 5.1: dj51 GRAPPELLI = 0: nogrp 1: grp