From db0549f2211a7adcee4bc159b762f343768fb904 Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Thu, 21 Jul 2022 21:34:21 +0200 Subject: [PATCH] WIP: Fix #102 by store tree information for Item and Location * Remove limitation of item parents. * Remove "Group Items" functionality * Replace "Group Items" change list filter by "Limit tree depth" for Item and Location. * Display Item and Location as a tree. --- README.creole | 5 + README.rst | 64 +++--- deployment/project.env | 2 +- pyproject.toml | 2 +- src/inventory/__init__.py | 2 +- src/inventory/admin/base.py | 19 ++ src/inventory/admin/item.py | 99 ++------ src/inventory/admin/location.py | 15 +- src/inventory/management/commands/tree.py | 62 +++++ src/inventory/migrations/0011_parent_tree1.py | 104 +++++++++ src/inventory/migrations/0012_parent_tree2.py | 21 ++ src/inventory/models/base.py | 119 ++++++++++ src/inventory/models/item.py | 27 +-- src/inventory/models/location.py | 19 +- src/inventory/parent_tree.py | 71 ++++++ src/inventory/string_utils.py | 15 ++ .../admin/inventory/item/column_item.html | 8 - .../tests/test_management_command_tree.py | 29 +++ src/inventory/tests/test_parent_tree.py | 43 ++++ src/inventory/tests/test_parent_tree_model.py | 127 ++++++++++ src/inventory_project/settings/base.py | 5 + .../tests/test_admin_item.py | 83 +++---- ...dmin_item_auto_group_items_1.snapshot.html | 217 ++++++++++++------ ...l_user_create_minimal_item_1.snapshot.html | 1 + ...l_user_create_minimal_item_2.snapshot.html | 11 +- 25 files changed, 891 insertions(+), 279 deletions(-) create mode 100644 src/inventory/management/commands/tree.py create mode 100644 src/inventory/migrations/0011_parent_tree1.py create mode 100644 src/inventory/migrations/0012_parent_tree2.py create mode 100644 src/inventory/parent_tree.py create mode 100644 src/inventory/string_utils.py delete mode 100644 src/inventory/templates/admin/inventory/item/column_item.html create mode 100644 src/inventory/tests/test_management_command_tree.py create mode 100644 src/inventory/tests/test_parent_tree.py create mode 100644 src/inventory/tests/test_parent_tree_model.py diff --git a/README.creole b/README.creole index 3dbb25c..e8df182 100644 --- a/README.creole +++ b/README.creole @@ -176,6 +176,11 @@ Files are separated into: "/src/" and "/development/" * [[https://github.com/jedie/PyInventory/compare/v0.13.1...main|compare v0.13.1...main]] **dev** ** tbc +* [[https://github.com/jedie/PyInventory/compare/v0.13.1...v0.14.0rc3|v0.14.0rc3 - 22.07.2022]] +** [[https://github.com/jedie/PyInventory/issues/102|Fix #102]] by remove limitation of item parents. +** Remove "Group Items" functionality +** Replace "Group Items" change list filter by "Limit tree depth" for Item and Location. +** Display Item and Location as a tree. * [[https://github.com/jedie/PyInventory/compare/v0.13.0...v0.13.1|v0.13.1 - 21.07.2022]] ** Rename git "master" branch into "main" ** Update CI/Test setup: diff --git a/README.rst b/README.rst index 2ed0ddb..d2d12e1 100644 --- a/README.rst +++ b/README.rst @@ -241,11 +241,21 @@ Files are separated into: "/src/" and "/development/" history ------- -* `compare v0.13.1...main `_ **dev** +* `compare v0.13.1...main `_ **dev** * tbc -* `v0.13.1 - 21.07.2022 `_ +* `v0.14.0rc3 - 22.07.2022 `_ + + * `Fix #102 `_ by remove limitation of item parents. + + * Remove "Group Items" functionality + + * Replace "Group Items" change list filter by "Limit tree depth" for Item and Location. + + * Display Item and Location as a tree. + +* `v0.13.1 - 21.07.2022 `_ * Rename git "master" branch into "main" @@ -257,37 +267,37 @@ history * Replace Selenium tests with Playwright -* `v0.13.0 - 01.01.2022 `_ +* `v0.13.0 - 01.01.2022 `_ * `Update requirements, e.g.: Django v3.2 `_ -* `v0.12.0 - 22.11.2021 `_ +* `v0.12.0 - 22.11.2021 `_ * NEW: `Protect user to overwrite newer Item/Memo/Location with a older one (e.g.: in other browser TAB) `_ * update requirements -* `v0.11.0 - 09.10.2021 `_ +* `v0.11.0 - 09.10.2021 `_ * NEW: Memo model/admin: Store Information (incl. images/files/links) independent of items/locations * Bugfix CKEditor sizes and fix toolbar (e.g.: remove useless pdf generator button and add sourcecode function) -* `v0.10.1 - 09.10.2021 `_ +* `v0.10.1 - 09.10.2021 `_ * Update to Django 3.1.x * Don't make requests to the a name for a Link, if we already have one or if last request was not long ago. -* `v0.10.0 - 29.09.2021 `_ +* `v0.10.0 - 29.09.2021 `_ * Group item: default "automatic" mode and can be disabled by filter action -* `v0.9.4 - 15.09.2021 `_ +* `v0.9.4 - 15.09.2021 `_ * Pin ``psycopg < 2.9`` because of `https://github.com/psycopg/psycopg2/issues/1293 `_ -* `v0.9.3 - 15.09.2021 `_ +* `v0.9.3 - 15.09.2021 `_ * Optimize "items" changelist queries @@ -295,7 +305,7 @@ history * Expand ``run_testserver`` command and recognize address and port argument -* `v0.9.2 - 11.05.2021 `_ +* `v0.9.2 - 11.05.2021 `_ * Update requirements @@ -307,17 +317,17 @@ history * Add a auto login if Django dev. server is used. -* `v0.9.0 - 11.04.2021 `_ +* `v0.9.0 - 11.04.2021 `_ * Use `https://github.com/jedie/dev-shell `_ for development -* `v0.8.4 - 19.01.2021 `_ +* `v0.8.4 - 19.01.2021 `_ * Search items in change list by "kind" and "tags", too * update requirements -* `v0.8.3 - 29.12.2020 `_ +* `v0.8.3 - 29.12.2020 `_ * update requirements @@ -325,11 +335,11 @@ history * Small project setup changes -* `v0.8.2 - 20.12.2020 `_ +* `v0.8.2 - 20.12.2020 `_ * Bugfix `#33 `_: Upload images to new created Items -* `v0.8.1 - 09.12.2020 `_ +* `v0.8.1 - 09.12.2020 `_ * Fix migration: Don't create "/media/migrate.log" if there is nothing to migrate @@ -339,11 +349,11 @@ history * update requirements -* `v0.8.0 - 06.12.2020 `_ +* `v0.8.0 - 06.12.2020 `_ * Outsource the "MEDIA file serve" part into `django.tools.serve_media_app `_ -* `v0.7.0 - 23.11.2020 `_ +* `v0.7.0 - 23.11.2020 `_ * Change deployment setup: @@ -357,15 +367,15 @@ history * pull all docker images before build -* `v0.6.0 - 15.11.2020 `_ +* `v0.6.0 - 15.11.2020 `_ * User can store images to every item: The image can only be accessed by the same user. -* `v0.5.0 - 14.11.2020 `_ +* `v0.5.0 - 14.11.2020 `_ * Merge separate git branches into one: "/src/" and "/development/" `#19 `_ -* `v0.4.2 - 13.11.2020 `_ +* `v0.4.2 - 13.11.2020 `_ * Serve static files by Caddy @@ -373,11 +383,11 @@ history * reduce CKEditor plugins -* `v0.4.1 - 2.11.2020 `_ +* `v0.4.1 - 2.11.2020 `_ * Small bugfixes -* `v0.4.0 - 1.11.2020 `_ +* `v0.4.0 - 1.11.2020 `_ * Move docker stuff and production use information into separate git branch @@ -385,11 +395,11 @@ history * Add django-processinfo: collect information about the running server processes -* `v0.3.2 - 26.10.2020 `_ +* `v0.3.2 - 26.10.2020 `_ * Bugfix missing translations -* `v0.3.0 - 26.10.2020 `_ +* `v0.3.0 - 26.10.2020 `_ * setup production usage: @@ -407,7 +417,7 @@ history * Bugfix for using manage commands ``dumpdata`` and ``loaddata`` -* `v0.2.0 - 24.10.2020 `_ +* `v0.2.0 - 24.10.2020 `_ * Simplify item change list by nested item @@ -419,7 +429,7 @@ history * Add docker-compose usage -* `v0.1.0 - 17.10.2020 `_ +* `v0.1.0 - 17.10.2020 `_ * Enhance models, admin and finish project setup @@ -462,4 +472,4 @@ donation ------------ -``Note: this file is generated from README.creole 2022-07-21 16:53:31 with "python-creole"`` \ No newline at end of file +``Note: this file is generated from README.creole 2022-07-22 19:32:28 with "python-creole"`` diff --git a/deployment/project.env b/deployment/project.env index 0603ab0..0cf8059 100644 --- a/deployment/project.env +++ b/deployment/project.env @@ -1,3 +1,3 @@ export PROJECT_NAME=pyinventory export PROJECT_PACKAGE_NAME=pyinventory -export PROJECT_VERSION=0.13.1 +export PROJECT_VERSION=0.14.0rc3 diff --git a/pyproject.toml b/pyproject.toml index 2f9a5d1..049e70b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "PyInventory" -version = "0.13.1" +version = "0.14.0rc3" description = "Web based management to catalog things including state and location etc. using Python/Django." authors = ["JensDiemer "] homepage = "https://github.com/jedie/PyInventory" diff --git a/src/inventory/__init__.py b/src/inventory/__init__.py index b59e7e0..376b5ea 100644 --- a/src/inventory/__init__.py +++ b/src/inventory/__init__.py @@ -4,4 +4,4 @@ :license: GNU GPL v3 or above, see LICENSE for more details. """ -__version__ = "0.13.1" +__version__ = "0.14.0rc3" diff --git a/src/inventory/admin/base.py b/src/inventory/admin/base.py index 8ea4a6b..db58989 100644 --- a/src/inventory/admin/base.py +++ b/src/inventory/admin/base.py @@ -1,6 +1,7 @@ from adminsortable2.admin import SortableInlineAdminMixin from django.contrib import admin from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ from reversion_compare.admin import CompareVersionAdmin from inventory.forms import OnlyUserRelationsModelForm @@ -66,3 +67,21 @@ class BaseFileModelInline(UserInlineMixin, SortableInlineAdminMixin, admin.Tabul fields = ( 'position', 'file', 'name', 'tags' ) + + +class LimitTreeDepthListFilter(admin.SimpleListFilter): + title = _('Limit tree depth') + parameter_name = 'level' + + def lookups(self, request, model_admin): + return ( + ('1', _('Only root')), + ('2', _('Root + first sub')), + ('3', _('Root + first + second sub')), + ) + + def queryset(self, request, queryset): + level = self.value() + if level: + level = int(level) + return queryset.filter(level__lte=level) diff --git a/src/inventory/admin/item.py b/src/inventory/admin/item.py index 86204fd..f51898f 100644 --- a/src/inventory/admin/item.py +++ b/src/inventory/admin/item.py @@ -2,8 +2,10 @@ import logging import tagulous from adminsortable2.admin import SortableInlineAdminMixin +from django.conf import settings from django.contrib import admin -from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from import_export.admin import ImportExportMixin from import_export.resources import ModelResource @@ -12,10 +14,12 @@ from inventory.admin.base import ( BaseFileModelInline, BaseImageModelInline, BaseUserAdmin, + LimitTreeDepthListFilter, UserInlineMixin, ) from inventory.models import ItemLinkModel, ItemModel from inventory.models.item import ItemFileModel, ItemImageModel +from inventory.string_utils import ltruncatechars logger = logging.getLogger(__name__) @@ -39,52 +43,25 @@ class ItemModelResource(ModelResource): model = ItemModel -class GroupItemsListFilter(admin.SimpleListFilter): - title = _('Group Items') - parameter_name = 'grouping' - - GET_KEY_AUTO = 'auto' - GET_KEY_NO = 'no' - - def lookups(self, request, model_admin): - return ( - (self.GET_KEY_AUTO, _('Automatic')), - (self.GET_KEY_NO, _('No')), - ) - - def value(self): - return super().value() or self.GET_KEY_AUTO - - def queryset(self, request, queryset): - auto_mode = self.value() == self.GET_KEY_AUTO - if auto_mode: - request.group_items = not request.GET.keys() - else: - request.group_items = self.value() != self.GET_KEY_NO - - logger.info('Group items: %r (auto mode: %r)', request.group_items, auto_mode) - - if request.group_items: - queryset = queryset.filter(parent__isnull=True) - - return queryset - - def choices(self, changelist): - for lookup, title in self.lookup_choices: - if lookup == self.GET_KEY_AUTO: - query_string = changelist.get_query_string(remove=[self.parameter_name]) - else: - query_string = changelist.get_query_string({self.parameter_name: lookup}) - - yield { - 'selected': self.value() == lookup, - 'query_string': query_string, - 'display': title, - } - - @admin.register(ItemModel) class ItemModelAdmin(ImportExportMixin, BaseUserAdmin): + @admin.display(ordering='path_str', description=_('ItemModel.verbose_name')) + def item(self, obj): + path = obj.path + if len(path) > 1: + prefixes = ' › '.join(path[:-1] + ['']) + prefixes = ltruncatechars(prefixes, max_length=settings.TREE_PATH_STR_MAX_LENGTH) + else: + prefixes = '' + item = path[-1] + url = reverse('admin:inventory_itemmodel_change', args=[obj.pk]) + return format_html( + '{}{}', + url, + prefixes, + item, + ) + def get_queryset(self, request): qs = super().get_queryset(request) qs = qs.prefetch_related( @@ -93,37 +70,11 @@ class ItemModelAdmin(ImportExportMixin, BaseUserAdmin): ) return qs - def column_item(self, obj): - context = { - 'base_item': obj, - } - - if self.request.group_items: # Attribute added in GroupItemsListFilter.queryset() - logger.debug('Display sub items inline') - # TODO: annotate "sub_items" ! - qs = ItemModel.objects.filter( - user=self.user # user added in BaseUserAdmin.get_changelist() - ) - qs = qs.filter(parent=obj).sort() - context['sub_items'] = qs - - return render_to_string( - template_name='admin/inventory/item/column_item.html', - context=context, - ) - - column_item.short_description = _('ItemModel.verbose_name_plural') - date_hierarchy = 'create_dt' - list_display = ( - 'kind', 'producer', - 'column_item', - 'location', - 'received_date', 'update_dt' - ) - ordering = ('kind', 'producer', 'name') + list_display = ('item', 'kind', 'producer', 'location', 'received_date', 'update_dt') + ordering = ('path_str',) list_display_links = None - list_filter = (GroupItemsListFilter, 'kind', 'location', 'producer', 'tags') + list_filter = (LimitTreeDepthListFilter, 'kind', 'location', 'producer', 'tags') search_fields = ('name', 'description', 'kind__name', 'tags__name') fieldsets = ( (_('Internals'), { diff --git a/src/inventory/admin/location.py b/src/inventory/admin/location.py index 1c6fc68..2b0fe7e 100644 --- a/src/inventory/admin/location.py +++ b/src/inventory/admin/location.py @@ -1,9 +1,12 @@ +from django.conf import settings from django.contrib import admin +from django.utils.translation import gettext_lazy as _ from import_export.admin import ImportExportMixin from import_export.resources import ModelResource -from inventory.admin.base import BaseUserAdmin +from inventory.admin.base import BaseUserAdmin, LimitTreeDepthListFilter from inventory.models import LocationModel +from inventory.string_utils import ltruncatechars class LocationModelResource(ModelResource): @@ -14,4 +17,12 @@ class LocationModelResource(ModelResource): @admin.register(LocationModel) class LocationModelAdmin(ImportExportMixin, BaseUserAdmin): - pass + @admin.display(ordering='path_str', description=_('LocationModel.verbose_name')) + def location(self, obj): + text = ' › '.join(obj.path) + text = ltruncatechars(text, max_length=settings.TREE_PATH_STR_MAX_LENGTH) + return text + + list_display = ('location', 'create_dt', 'update_dt') + list_filter = (LimitTreeDepthListFilter,) + ordering = ('path_str',) diff --git a/src/inventory/management/commands/tree.py b/src/inventory/management/commands/tree.py new file mode 100644 index 0000000..017ed19 --- /dev/null +++ b/src/inventory/management/commands/tree.py @@ -0,0 +1,62 @@ +import time +from argparse import OPTIONAL + +from django.apps import apps +from django.core.management.base import BaseCommand + + +class PrintDuration: + def __init__(self, stdout): + self.stdout = stdout + + def __enter__(self): + self.start_time = time.monotonic() + + def __exit__(self, exc_type, exc_val, exc_tb): + duration = (time.monotonic() - self.start_time) * 1000 + self.stdout.write(f'(Done in: {duration:.1f}ms)') + + +class Command(BaseCommand): + help = 'Repair tree information' + + def add_arguments(self, parser): + parser.add_argument( + 'model_name', + metavar='model_name', + nargs=OPTIONAL, + default='itemmodel', + choices=['itemmodel', 'locationmodel'], + help='Model Name (default: "%(default)s")', + ) + + def handle(self, *args, **options): + self.stdout.write() + self.stdout.write('=' * 100) + self.stdout.write(self.help) + self.stdout.write('-' * 100) + + model_name = options['model_name'] + ModelClass = apps.get_model(app_label='inventory', model_name=model_name) + + self.print_info(ModelClass, text='Old information about model:') + + self.stdout.write('_' * 100) + self.stdout.write(f'Clean tree information on model: {ModelClass._meta.verbose_name!r}') + with PrintDuration(self.stdout): + ModelClass.objects.update(path=None, path_str=None, level=None) + + self.stdout.write('_' * 100) + self.stdout.write(f'Repair tree model: {ModelClass._meta.verbose_name!r}') + with PrintDuration(self.stdout): + ModelClass.tree_objects.update_tree_info() + + self.print_info(ModelClass, text='New information about model:') + + def print_info(self, ModelClass, text): + self.stdout.write('_' * 100) + self.stdout.write(f'{text} {ModelClass._meta.verbose_name!r}') + with PrintDuration(self.stdout): + data = ModelClass.objects.values('level', 'path_str', 'path', 'name') + for entry in data: + self.stdout.write(repr(entry)) diff --git a/src/inventory/migrations/0011_parent_tree1.py b/src/inventory/migrations/0011_parent_tree1.py new file mode 100644 index 0000000..13a5a53 --- /dev/null +++ b/src/inventory/migrations/0011_parent_tree1.py @@ -0,0 +1,104 @@ +# Generated by Django 3.2.14 on 2022-07-21 18:36 + +import django.db.models.deletion +import tagulous.models.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0010_version_protect_models'), + ] + + operations = [ + migrations.AddField( + model_name='itemmodel', + name='level', + field=models.PositiveSmallIntegerField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='itemmodel', + name='path', + field=models.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='itemmodel', + name='path_str', + field=models.TextField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='locationmodel', + name='level', + field=models.PositiveSmallIntegerField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='locationmodel', + name='path', + field=models.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='locationmodel', + name='path_str', + field=models.TextField(blank=True, editable=False, null=True), + ), + migrations.AlterField( + model_name='itemmodel', + name='parent', + field=models.ForeignKey( + blank=True, + help_text='LocationModel.parent.help_text', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='inventory.itemmodel', + verbose_name='LocationModel.parent.verbose_name', + ), + ), + migrations.CreateModel( + name='Tagulous_BaseParentTreeModel_tags', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ('name', models.CharField(max_length=255, unique=True)), + ('slug', models.SlugField()), + ( + 'count', + models.IntegerField( + default=0, help_text='Internal counter of how many times this tag is in use' + ), + ), + ( + 'protected', + models.BooleanField( + default=False, help_text='Will not be deleted when the count reaches 0' + ), + ), + ], + options={ + 'ordering': ('name',), + 'abstract': False, + 'unique_together': {('slug',)}, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.AlterModelOptions( + name='itemmodel', + options={ + 'ordering': ('path_str',), + 'verbose_name': 'ItemModel.verbose_name', + 'verbose_name_plural': 'ItemModel.verbose_name_plural', + }, + ), + migrations.AlterModelOptions( + name='locationmodel', + options={ + 'ordering': ('path_str',), + 'verbose_name': 'LocationModel.verbose_name', + 'verbose_name_plural': 'LocationModel.verbose_name_plural', + }, + ), + ] diff --git a/src/inventory/migrations/0012_parent_tree2.py b/src/inventory/migrations/0012_parent_tree2.py new file mode 100644 index 0000000..c5bd6a3 --- /dev/null +++ b/src/inventory/migrations/0012_parent_tree2.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.14 on 2022-07-21 18:36 +from django.core import management +from django.db import migrations + +from inventory.management.commands import tree + + +def forward_code(apps, schema_editor): + management.call_command(tree.Command(), model_name='itemmodel') + management.call_command(tree.Command(), model_name='locationmodel') + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0011_parent_tree1'), + ] + + operations = [ + migrations.RunPython(forward_code, reverse_code=migrations.RunPython.noop), + ] diff --git a/src/inventory/models/base.py b/src/inventory/models/base.py index 8eefc52..e9cf4d7 100644 --- a/src/inventory/models/base.py +++ b/src/inventory/models/base.py @@ -1,3 +1,7 @@ +import logging +import re +import time +import unicodedata import uuid import tagulous.models @@ -6,6 +10,12 @@ from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ +from inventory.parent_tree import ValuesListTree +from inventory.string_utils import ltruncatechars + + +logger = logging.getLogger(__name__) + class BaseModel(TimetrackingBaseModel): id = models.UUIDField( @@ -45,6 +55,115 @@ class BaseModel(TimetrackingBaseModel): abstract = True +def nomalize_text(text): + """ + >>> nomalize_text('Foo Bar 1 §$% äö-üß +') + 'foobar1aou' + """ + text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii') + text = text.lower() + text = re.sub(r'[^\w]', '', text) + return text + + +def generate_path_str(path): + """ + >>> generate_path_str(['Foo', 'B a r', '1 §$% äö-üß +']) + 'foo 0 bar 0 1aou' + """ + # The choice of the separator is very important for the correct sorting by the database! + # Use 0, because this character is used for sorting and is the first character in the charset. + # The spaces are only visual separators ;) + return ' 0 '.join(nomalize_text(part) for part in path) + + +class ParentTreeModelManager(models.Manager): + def update_tree_info(self): + start_time = time.monotonic() + + values = self.all().values('pk', 'name', 'parent__pk', 'path') + tree = ValuesListTree(values=values) + tree_path = tree.get_tree_path() + logger.debug('Tree path: %r', tree_path) + update_path_info = tree.get_update_path_info() + + duration = (time.monotonic() - start_time) * 1000 + logger.info('Get update_path_info: %r in %ims', update_path_info, duration) + + if not update_path_info: + logger.info('No tree path changed, ok') + else: + start_time = time.monotonic() + + entries = self.filter(pk__in=update_path_info.keys()) + for entry in entries: + path = update_path_info[entry.pk] + entry.path = path + entry.path_str = generate_path_str(path) + entry.level = len(path) + + self.bulk_update(entries, ['path', 'path_str', 'level']) + + duration = (time.monotonic() - start_time) * 1000 + logger.info('Update %i entries in %ims', len(entries), duration) + + +class BaseParentTreeModel(BaseModel): + path = models.JSONField( + blank=True, + null=True, + editable=False, + ) + path_str = models.TextField( + blank=True, + null=True, + editable=False, + ) + level = models.PositiveSmallIntegerField( + blank=True, + null=True, + editable=False, + ) + parent = models.ForeignKey( + 'self', + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('LocationModel.parent.verbose_name'), + help_text=_('LocationModel.parent.help_text'), + ) + + objects = models.Manager() + tree_objects = ParentTreeModelManager() + + def save(self, **kwargs): + if not self.path: + if self.parent: + path = self.parent.path + if path: + self.path = [*path, self.name] + else: + self.path = [self.name] + self.path_str = generate_path_str(self.path) + self.level = len(self.path) + logger.info('Init path with: %r', self.path) + + self.full_clean() + super().save(**kwargs) + self.__class__.tree_objects.update_tree_info() + + def __str__(self): + if self.path: + text = ' › '.join(self.path) + text = ltruncatechars(text, max_length=settings.TREE_PATH_STR_MAX_LENGTH) + return text + + return self.name + + class Meta: + abstract = True + + class BaseAttachmentModel(BaseModel): """ Base model to store files or images to Items diff --git a/src/inventory/models/item.py b/src/inventory/models/item.py index 14726da..b23f9b7 100644 --- a/src/inventory/models/item.py +++ b/src/inventory/models/item.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _ from django_tools.model_version_protect.models import VersionProtectBaseModel from django_tools.serve_media_app.models import user_directory_path -from inventory.models.base import BaseItemAttachmentModel, BaseModel +from inventory.models.base import BaseItemAttachmentModel, BaseParentTreeModel from inventory.models.links import BaseLink @@ -22,7 +22,7 @@ class ItemQuerySet(models.QuerySet): return self.order_by('kind', 'producer', 'name') -class ItemModel(BaseModel, VersionProtectBaseModel): +class ItemModel(BaseParentTreeModel, VersionProtectBaseModel): """ A Item that can be described and store somewhere ;) """ @@ -63,14 +63,6 @@ class ItemModel(BaseModel, VersionProtectBaseModel): verbose_name=_('ItemModel.location.verbose_name'), help_text=_('ItemModel.location.help_text') ) - parent = models.ForeignKey( - 'self', - limit_choices_to={'parent_id': None}, - on_delete=models.SET_NULL, - blank=True, null=True, - verbose_name=_('ItemModel.parent.verbose_name'), - help_text=_('ItemModel.parent.help_text') - ) # ________________________________________________________________________ # lent @@ -142,21 +134,8 @@ class ItemModel(BaseModel, VersionProtectBaseModel): parts = [str(part) for part in (self.kind, self.producer, self.name)] return ' - '.join(part for part in parts if part) - def __str__(self): - if self.parent_id is None: - title = self.name - else: - title = f'{self.name} › {self.parent}' - - if self.producer: - title = f'{self.producer} - {title}' - - if self.location_id is not None: - title = f'{title} ({self.location})' - - return title - class Meta: + ordering = ('path_str',) verbose_name = _('ItemModel.verbose_name') verbose_name_plural = _('ItemModel.verbose_name_plural') diff --git a/src/inventory/models/location.py b/src/inventory/models/location.py index e840c09..1b0895f 100644 --- a/src/inventory/models/location.py +++ b/src/inventory/models/location.py @@ -1,12 +1,11 @@ from ckeditor_uploader.fields import RichTextUploadingField -from django.db import models from django.utils.translation import gettext_lazy as _ from django_tools.model_version_protect.models import VersionProtectBaseModel -from inventory.models.base import BaseModel +from inventory.models.base import BaseParentTreeModel -class LocationModel(BaseModel, VersionProtectBaseModel): +class LocationModel(BaseParentTreeModel, VersionProtectBaseModel): """ A Storage for items. """ @@ -16,20 +15,8 @@ class LocationModel(BaseModel, VersionProtectBaseModel): verbose_name=_('LocationModel.description.verbose_name'), help_text=_('LocationModel.description.help_text') ) - parent = models.ForeignKey( - 'self', - on_delete=models.SET_NULL, - blank=True, null=True, - verbose_name=_('LocationModel.parent.verbose_name'), - help_text=_('LocationModel.parent.help_text') - ) - - def __str__(self): - if self.parent_id is None: - return self.name - else: - return f'{self.name} › {self.parent}' class Meta: + ordering = ('path_str',) verbose_name = _('LocationModel.verbose_name') verbose_name_plural = _('LocationModel.verbose_name_plural') diff --git a/src/inventory/parent_tree.py b/src/inventory/parent_tree.py new file mode 100644 index 0000000..03c7b2d --- /dev/null +++ b/src/inventory/parent_tree.py @@ -0,0 +1,71 @@ +class TreeNode: + def __init__(self, pk, name, current_path): + self.pk = pk + self.name = name + self.current_path = current_path + self.parent_node = None + self.path = None + + def _set_parent(self, parent_node): + self.parent_node = parent_node + + def _get_path(self): + if self.parent_node: + parent_path = self.parent_node._get_path() + return [*parent_path, self.name] + else: + return [self.name] + + def _set_tree_info(self): + self.path = self._get_path() + + @property + def path_string(self): + return ' / '.join(self.path) + + def __str__(self): + return f'pk:{self.pk} name:"{self.name}" path:"{self.path_string}"' + + def __repr__(self): + return f'' + + +class ValuesListTree: + def __init__(self, values): + nodes = {} + + # init all nodes: + for entry in values: + pk = entry['pk'] + name = entry['name'] + current_path = entry['path'] + nodes[pk] = TreeNode(pk=pk, name=name, current_path=current_path) + + # Set parents: + for entry in values: + parent_pk = entry['parent__pk'] + if parent_pk: + pk = entry['pk'] + node = nodes[pk] + parent_node = nodes[parent_pk] + node._set_parent(parent_node=parent_node) + + # Set tree info: + nodes = list(nodes.values()) + for node in nodes: + node._set_tree_info() + + # Oder by hierarchy: + nodes.sort(key=lambda x: x.path) + + self.nodes = nodes + + def get_tree_path(self): + return [node.path_string for node in self.nodes] + + def get_update_path_info(self): + update_info = {} + for node in self.nodes: + if node.current_path != node.path: + update_info[node.pk] = node.path + return update_info diff --git a/src/inventory/string_utils.py b/src/inventory/string_utils.py new file mode 100644 index 0000000..590d7a6 --- /dev/null +++ b/src/inventory/string_utils.py @@ -0,0 +1,15 @@ +def ltruncatechars(text, max_length, truncate='…'): + """ + >>> ltruncatechars('1234567890', max_length=10) + '1234567890' + >>> ltruncatechars('1234567890', max_length=5) + '…7890' + >>> ltruncatechars('1234567890', max_length=6) + '…67890' + >>> ltruncatechars('1234567890', max_length=6, truncate='...') + '...890' + """ + if len(text) > max_length: + length = max_length - len(truncate) + text = truncate + text[-length:] + return text diff --git a/src/inventory/templates/admin/inventory/item/column_item.html b/src/inventory/templates/admin/inventory/item/column_item.html deleted file mode 100644 index fce3772..0000000 --- a/src/inventory/templates/admin/inventory/item/column_item.html +++ /dev/null @@ -1,8 +0,0 @@ -{{ base_item.name }} -{% if sub_items %} - -{% endif %} \ No newline at end of file diff --git a/src/inventory/tests/test_management_command_tree.py b/src/inventory/tests/test_management_command_tree.py new file mode 100644 index 0000000..496da92 --- /dev/null +++ b/src/inventory/tests/test_management_command_tree.py @@ -0,0 +1,29 @@ +import io + +from django.core import management +from django.test import TestCase +from model_bakery import baker + +from inventory.management.commands import tree +from inventory.models import ItemModel + + +class ManagementCommandTestCase(TestCase): + def test_tree_command(self): + baker.make(ItemModel, name='Foo Bar') + ItemModel.objects.update(path_str='OLD', path=['OLD']) + + output = io.StringIO() + + management.call_command(tree.Command(), stdout=output) + + output = output.getvalue() + assert 'Repair tree information' in output + + assert "Old information about model: 'Item'" in output + assert "{'level': 1, 'path_str': 'OLD', 'path': ['OLD'], 'name': 'Foo Bar'}" in output + + assert "New information about model: 'Item'" in output + assert ( + "{'level': 1, 'path_str': 'foobar', 'path': ['Foo Bar'], 'name': 'Foo Bar'}" in output + ) diff --git a/src/inventory/tests/test_parent_tree.py b/src/inventory/tests/test_parent_tree.py new file mode 100644 index 0000000..a16a679 --- /dev/null +++ b/src/inventory/tests/test_parent_tree.py @@ -0,0 +1,43 @@ +import random + +from inventory.parent_tree import ValuesListTree + + +def test_values_list_tree(): + values_list = [ + (1, '1.', None), + (2, '1.1.', 1), + (3, '1.1.1', 2), + (4, '1.1.2', 2), + (5, '1.2.', 1), + (6, '2.', None), + ] + random.shuffle(values_list) + values = [ + {'pk': entry[0], 'name': entry[1], 'parent__pk': entry[2], 'path': ''} + for entry in values_list + ] + tree = ValuesListTree(values) + + tree_path = tree.get_tree_path() + assert tree_path == [ + '1.', + '1. / 1.1.', + '1. / 1.1. / 1.1.1', + '1. / 1.1. / 1.1.2', + '1. / 1.2.', + '2.', + ] + update_path_info = tree.get_update_path_info() + assert update_path_info == { + 1: ['1.'], + 2: ['1.', '1.1.'], + 3: ['1.', '1.1.', '1.1.1'], + 4: ['1.', '1.1.', '1.1.2'], + 5: ['1.', '1.2.'], + 6: ['2.'], + } + + node_three = tree.nodes[2] + assert str(node_three) == 'pk:3 name:"1.1.1" path:"1. / 1.1. / 1.1.1"' + assert repr(node_three) == '' diff --git a/src/inventory/tests/test_parent_tree_model.py b/src/inventory/tests/test_parent_tree_model.py new file mode 100644 index 0000000..dc13e31 --- /dev/null +++ b/src/inventory/tests/test_parent_tree_model.py @@ -0,0 +1,127 @@ +from bx_django_utils.test_utils.assert_queries import AssertQueries +from django.test import TestCase + +from inventory.admin import ItemModelAdmin, LocationModelAdmin +from inventory.models import ItemModel, LocationModel +from inventory_project.tests.fixtures import get_normal_user + + +class TreeModelTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.normaluser = get_normal_user() + + def test_parent_tree_model(self): + for main_item_no in range(1, 3): + main_item = ItemModel.objects.create( + user=self.normaluser, + name=f'{main_item_no}.', + ) + main_item.full_clean() + + for sub_item_no in range(1, 3): + sub_item = ItemModel.objects.create( + parent=main_item, + user=self.normaluser, + name=f'{main_item_no}.{sub_item_no}.', + ) + sub_item.full_clean() + + for sub_sub_item_no in range(1, 3): + sub_sub_item = ItemModel.objects.create( + parent=sub_item, + user=self.normaluser, + name=f'{main_item_no}.{sub_item_no}.{sub_sub_item_no}.', + ) + sub_sub_item.full_clean() + + data = list(ItemModel.objects.values_list('level', 'path_str', 'name')) + assert data == [ + (1, '1', '1.'), + (2, '1 0 11', '1.1.'), + (3, '1 0 11 0 111', '1.1.1.'), + (3, '1 0 11 0 112', '1.1.2.'), + (2, '1 0 12', '1.2.'), + (3, '1 0 12 0 121', '1.2.1.'), + (3, '1 0 12 0 122', '1.2.2.'), + (1, '2', '2.'), + (2, '2 0 21', '2.1.'), + (3, '2 0 21 0 211', '2.1.1.'), + (3, '2 0 21 0 212', '2.1.2.'), + (2, '2 0 22', '2.2.'), + (3, '2 0 22 0 221', '2.2.1.'), + (3, '2 0 22 0 222', '2.2.2.'), + ] + + item_2_1 = ItemModel.objects.get(name='2.1.') + item_2_1.name = 'NEW 2.1. Name' + with AssertQueries() as queries: + item_2_1.save() + + data = list(ItemModel.objects.values_list('level', 'path_str', 'name')) + assert data == [ + (1, '1', '1.'), + (2, '1 0 11', '1.1.'), + (3, '1 0 11 0 111', '1.1.1.'), + (3, '1 0 11 0 112', '1.1.2.'), + (2, '1 0 12', '1.2.'), + (3, '1 0 12 0 121', '1.2.1.'), + (3, '1 0 12 0 122', '1.2.2.'), + (1, '2', '2.'), + (2, '2 0 22', '2.2.'), + (3, '2 0 22 0 221', '2.2.1.'), + (3, '2 0 22 0 222', '2.2.2.'), + (2, '2 0 new21name', 'NEW 2.1. Name'), + (3, '2 0 new21name 0 211', '2.1.1.'), + (3, '2 0 new21name 0 212', '2.1.2.'), + ] + + itemmodel_count = 1 # full_clean(): Check if parent exists + itemmodel_count += 1 # VersionProtectBaseModel: Check version + itemmodel_count += 1 # VersionProtectBaseModel: Save new version + itemmodel_count += 1 # Get info for tree update + itemmodel_count += 1 # Fetch the items to update + itemmodel_count += 1 # Bulk update save + + queries.assert_queries( + table_counts={ + 'inventory_itemmodel': itemmodel_count, + 'auth_user': 1, # full_clean(): Check if user exists + }, + double_tables=False, + duplicated=True, + similar=True, + ) + + def test_parent_tree_model_ordering(self): + assert LocationModel._meta.ordering == ('path_str',) + assert LocationModelAdmin.ordering == ('path_str',) + + assert ItemModel._meta.ordering == ('path_str',) + assert ItemModelAdmin.ordering == ('path_str',) + + def create(name, parent=None): + instance = ItemModel.objects.create(user=self.normaluser, name=name, parent=parent) + instance.full_clean() + return instance + + # Create a "Special" case for the correct ordering: + # 1. all "PC-1" entries + # 2. all "PC1640" entries + # + # The correct order depends on the seperator, here: " 0 " + + pc1 = create(name='PC-1') + pc1640 = create(name='PC1640 SD') + create(name='FZ-502 Rev A 5.25″ Floppy', parent=pc1) + create(name='1,44MB / 3.5" Floppy FD-235HF- 3800-U', parent=pc1) + create(name='PC 1640ECD', parent=pc1640) + + data = list(ItemModel.objects.values_list('level', 'path_str', 'name')) + assert data == [ + (1, 'pc1', 'PC-1'), + (2, 'pc1 0 144mb35floppyfd235hf3800u', '1,44MB / 3.5" Floppy FD-235HF- 3800-U'), + (2, 'pc1 0 fz502reva525floppy', 'FZ-502 Rev A 5.25″ Floppy'), + (1, 'pc1640sd', 'PC1640 SD'), + (2, 'pc1640sd 0 pc1640ecd', 'PC 1640ECD'), + ] diff --git a/src/inventory_project/settings/base.py b/src/inventory_project/settings/base.py index 08b7879..e4ef076 100644 --- a/src/inventory_project/settings/base.py +++ b/src/inventory_project/settings/base.py @@ -35,6 +35,11 @@ print(f'BASE_PATH:{BASE_PATH}') ############################################################################### +# Max length of Item/Location "path name" in change list: +TREE_PATH_STR_MAX_LENGTH = 70 + +############################################################################### + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False diff --git a/src/inventory_project/tests/test_admin_item.py b/src/inventory_project/tests/test_admin_item.py index b54b037..ef8ef1d 100644 --- a/src/inventory_project/tests/test_admin_item.py +++ b/src/inventory_project/tests/test_admin_item.py @@ -1,5 +1,4 @@ import datetime -import logging from unittest import mock from bx_django_utils.test_utils.datetime import MockDatetimeGenerator @@ -8,16 +7,13 @@ from bx_django_utils.test_utils.html_assertion import ( assert_html_response_snapshot, ) from bx_py_utils.test_utils.snapshot import assert_html_snapshot -from django.contrib.auth.models import User from django.template.defaulttags import CsrfTokenNode, NowNode from django.test import TestCase, override_settings from django.utils import timezone from django_tools.unittest_utils.mockup import ImageDummy -from model_bakery import baker from inventory import __version__ from inventory.models import ItemImageModel, ItemModel -from inventory.permissions import get_or_create_normal_user_group from inventory_project.tests.fixtures import get_normal_user @@ -120,10 +116,13 @@ class AdminTestCase(HtmlAssertionMixin, TestCase): item = ItemModel.objects.first() - self.assert_messages(response, expected_messages=[ - f'The Item “ - name”' - ' was added successfully.' - ]) + self.assert_messages( + response, + expected_messages=[ + f'The Item “name”' + ' was added successfully.' + ], + ) assert item.user_id == self.normaluser.pk @@ -171,10 +170,13 @@ class AdminTestCase(HtmlAssertionMixin, TestCase): item = ItemModel.objects.first() - self.assert_messages(response, expected_messages=[ - f'The Item “ - name”' - ' was added successfully.' - ]) + self.assert_messages( + response, + expected_messages=[ + f'The Item “name”' + ' was added successfully.' + ], + ) assert item.user_id == self.normaluser.pk @@ -211,52 +213,21 @@ class AdminTestCase(HtmlAssertionMixin, TestCase): 'main item 2', 'sub item 2.1', 'sub item 2.2', ] - # Default mode, without any GET parameter -> group "automatic": - - with mock.patch.object(NowNode, 'render', return_value='MockedNowNode'), \ - mock.patch.object(CsrfTokenNode, 'render', return_value='MockedCsrfTokenNode'), \ - self.assertLogs(logger='inventory', level=logging.DEBUG) as logs: + with mock.patch.object(NowNode, 'render', return_value='MockedNowNode'), mock.patch.object( + CsrfTokenNode, 'render', return_value='MockedCsrfTokenNode' + ): response = self.client.get( path='/admin/inventory/itemmodel/', ) assert response.status_code == 200 - self.assert_html_parts(response, parts=( - f'Select Item to change | PyInventory v{__version__}', - - '' - 'main item 1', - - '
  • ' - 'sub item 1.1
  • ', - )) - assert logs.output == [ - 'INFO:inventory.admin.item:Group items: True (auto mode: True)', - 'DEBUG:inventory.admin.item:Display sub items inline', - 'DEBUG:inventory.admin.item:Display sub items inline' - ] - assert_html_response_snapshot(response=response, validate=False) - - # Search should disable grouping: - - with mock.patch.object(NowNode, 'render', return_value='MockedNowNode'), \ - mock.patch.object(CsrfTokenNode, 'render', return_value='MockedCsrfTokenNode'), \ - self.assertLogs(logger='inventory', level=logging.DEBUG) as logs: - response = self.client.get( - path='/admin/inventory/itemmodel/?q=sub+item+2.', - ) - assert response.status_code == 200 - self.assert_html_parts(response, parts=( - '', - '2 results (6 total)', - - '' - 'sub item 2.1', - - '' - 'sub item 2.2', - )) - assert logs.output == [ - # grouping disabled? - 'INFO:inventory.admin.item:Group items: False (auto mode: True)' - ] + self.assert_html_parts( + response, + parts=( + f'Select Item to change | PyInventory v{__version__}', + '' + 'main item 1', + '' + 'main item 1 › sub item 1.1', + ), + ) assert_html_response_snapshot(response=response, validate=False) diff --git a/src/inventory_project/tests/test_admin_item_auto_group_items_1.snapshot.html b/src/inventory_project/tests/test_admin_item_auto_group_items_1.snapshot.html index 7830e0d..9cd3820 100644 --- a/src/inventory_project/tests/test_admin_item_auto_group_items_1.snapshot.html +++ b/src/inventory_project/tests/test_admin_item_auto_group_items_1.snapshot.html @@ -31,13 +31,6 @@ - - 2 results ( - - 6 total - - ) - @@ -74,8 +67,8 @@ - - 0 of 2 selected + + 0 of 6 selected
    @@ -91,6 +84,21 @@
    + +
    + + + + +
    + +
    +
    +
    @@ -109,18 +117,9 @@
    - -
    - - Items - -
    -
    -
    - @@ -129,7 +128,7 @@ @@ -138,7 +137,7 @@ @@ -152,29 +151,17 @@ + + + + main item 1 + + + - - - - main item 1 - - - - - @@ -187,30 +174,70 @@ - + + + + + main item 1 › + + sub item 1.1 + + - - - + + - + + + - + + + Jan. 1, 2000, 1:02 a.m. + + + + + + + + + main item 1 › + + sub item 1.2 + + + + + + + + + - + + + - + + + Jan. 1, 2000, 1:03 a.m. + + + + + + + + + main item 2 - - - + + + + + + - @@ -222,11 +249,63 @@ Jan. 1, 2000, 1:04 a.m. + + + + + + + main item 2 › + + sub item 2.1 + + + + + + + + + - + + + - + + + Jan. 1, 2000, 1:05 a.m. + + + + + + + + + main item 2 › + + sub item 2.2 + + + + + + + + + - + + + - + + + Jan. 1, 2000, 1:06 a.m. + +

    - 2 Items + 6 Items

    @@ -235,17 +314,27 @@ Filter

    - By Group Items + By Limit tree depth

    diff --git a/src/inventory_project/tests/test_admin_item_normal_user_create_minimal_item_1.snapshot.html b/src/inventory_project/tests/test_admin_item_normal_user_create_minimal_item_1.snapshot.html index b36faa5..ca8635d 100644 --- a/src/inventory_project/tests/test_admin_item_normal_user_create_minimal_item_1.snapshot.html +++ b/src/inventory_project/tests/test_admin_item_normal_user_create_minimal_item_1.snapshot.html @@ -171,6 +171,7 @@
    + Locations can be nested. Example: The box 12 in cupboard 3
    diff --git a/src/inventory_project/tests/test_admin_item_normal_user_create_minimal_item_2.snapshot.html b/src/inventory_project/tests/test_admin_item_normal_user_create_minimal_item_2.snapshot.html index 4812f54..9c78011 100644 --- a/src/inventory_project/tests/test_admin_item_normal_user_create_minimal_item_2.snapshot.html +++ b/src/inventory_project/tests/test_admin_item_normal_user_create_minimal_item_2.snapshot.html @@ -2,7 +2,7 @@ - Change Item | PyInventory v0.13.1 + Change Item | PyInventory v0.14.0rc3 @@ -80,7 +80,7 @@ @@ -116,7 +116,7 @@ Items - › - A new Name! + › name