kopia lustrzana https://github.com/jedie/PyInventory
Merge pull request #106 from jedie/fix#102
WIP: Fix #102 by store tree information for Item and Locationpull/109/head
commit
e2f66b7d3a
|
@ -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:
|
||||
|
|
64
README.rst
64
README.rst
|
@ -241,11 +241,21 @@ Files are separated into: "/src/" and "/development/"
|
|||
history
|
||||
-------
|
||||
|
||||
* `compare v0.13.1...main <https://github.com/jedie/PyInventory/compare/v0.13.1...main>`_ **dev**
|
||||
* `compare v0.13.1...main <https://github.com/jedie/PyInventory/compare/v0.13.1...main>`_ **dev**
|
||||
|
||||
* tbc
|
||||
|
||||
* `v0.13.1 - 21.07.2022 <https://github.com/jedie/PyInventory/compare/v0.13.0...v0.13.1>`_
|
||||
* `v0.14.0rc3 - 22.07.2022 <https://github.com/jedie/PyInventory/compare/v0.13.1...v0.14.0rc3>`_
|
||||
|
||||
* `Fix #102 <https://github.com/jedie/PyInventory/issues/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 <https://github.com/jedie/PyInventory/compare/v0.13.0...v0.13.1>`_
|
||||
|
||||
* Rename git "master" branch into "main"
|
||||
|
||||
|
@ -257,37 +267,37 @@ history
|
|||
|
||||
* Replace Selenium tests with Playwright
|
||||
|
||||
* `v0.13.0 - 01.01.2022 <https://github.com/jedie/PyInventory/compare/v0.12.0...v0.13.0>`_
|
||||
* `v0.13.0 - 01.01.2022 <https://github.com/jedie/PyInventory/compare/v0.12.0...v0.13.0>`_
|
||||
|
||||
* `Update requirements, e.g.: Django v3.2 <https://github.com/jedie/PyInventory/pull/83>`_
|
||||
|
||||
* `v0.12.0 - 22.11.2021 <https://github.com/jedie/PyInventory/compare/v0.11.0...v0.12.0>`_
|
||||
* `v0.12.0 - 22.11.2021 <https://github.com/jedie/PyInventory/compare/v0.11.0...v0.12.0>`_
|
||||
|
||||
* NEW: `Protect user to overwrite newer Item/Memo/Location with a older one (e.g.: in other browser TAB) <https://github.com/jedie/PyInventory/pull/78>`_
|
||||
|
||||
* update requirements
|
||||
|
||||
* `v0.11.0 - 09.10.2021 <https://github.com/jedie/PyInventory/compare/v0.10.1...v0.11.0>`_
|
||||
* `v0.11.0 - 09.10.2021 <https://github.com/jedie/PyInventory/compare/v0.10.1...v0.11.0>`_
|
||||
|
||||
* 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 <https://github.com/jedie/PyInventory/compare/v0.10.0...v0.10.1>`_
|
||||
* `v0.10.1 - 09.10.2021 <https://github.com/jedie/PyInventory/compare/v0.10.0...v0.10.1>`_
|
||||
|
||||
* 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 <https://github.com/jedie/PyInventory/compare/v0.9.4...v0.10.0>`_
|
||||
* `v0.10.0 - 29.09.2021 <https://github.com/jedie/PyInventory/compare/v0.9.4...v0.10.0>`_
|
||||
|
||||
* Group item: default "automatic" mode and can be disabled by filter action
|
||||
|
||||
* `v0.9.4 - 15.09.2021 <https://github.com/jedie/PyInventory/compare/v0.9.3...v0.9.4>`_
|
||||
* `v0.9.4 - 15.09.2021 <https://github.com/jedie/PyInventory/compare/v0.9.3...v0.9.4>`_
|
||||
|
||||
* Pin ``psycopg < 2.9`` because of `https://github.com/psycopg/psycopg2/issues/1293 <https://github.com/psycopg/psycopg2/issues/1293>`_
|
||||
|
||||
* `v0.9.3 - 15.09.2021 <https://github.com/jedie/PyInventory/compare/v0.9.2...v0.9.3>`_
|
||||
* `v0.9.3 - 15.09.2021 <https://github.com/jedie/PyInventory/compare/v0.9.2...v0.9.3>`_
|
||||
|
||||
* 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 <https://github.com/jedie/PyInventory/compare/v0.9.1...v0.9.2>`_
|
||||
* `v0.9.2 - 11.05.2021 <https://github.com/jedie/PyInventory/compare/v0.9.1...v0.9.2>`_
|
||||
|
||||
* Update requirements
|
||||
|
||||
|
@ -307,17 +317,17 @@ history
|
|||
|
||||
* Add a auto login if Django dev. server is used.
|
||||
|
||||
* `v0.9.0 - 11.04.2021 <https://github.com/jedie/PyInventory/compare/v0.8.4...v0.9.0>`_
|
||||
* `v0.9.0 - 11.04.2021 <https://github.com/jedie/PyInventory/compare/v0.8.4...v0.9.0>`_
|
||||
|
||||
* Use `https://github.com/jedie/dev-shell <https://github.com/jedie/dev-shell>`_ for development
|
||||
|
||||
* `v0.8.4 - 19.01.2021 <https://github.com/jedie/PyInventory/compare/v0.8.3...v0.8.4>`_
|
||||
* `v0.8.4 - 19.01.2021 <https://github.com/jedie/PyInventory/compare/v0.8.3...v0.8.4>`_
|
||||
|
||||
* Search items in change list by "kind" and "tags", too
|
||||
|
||||
* update requirements
|
||||
|
||||
* `v0.8.3 - 29.12.2020 <https://github.com/jedie/PyInventory/compare/v0.8.2...v0.8.3>`_
|
||||
* `v0.8.3 - 29.12.2020 <https://github.com/jedie/PyInventory/compare/v0.8.2...v0.8.3>`_
|
||||
|
||||
* update requirements
|
||||
|
||||
|
@ -325,11 +335,11 @@ history
|
|||
|
||||
* Small project setup changes
|
||||
|
||||
* `v0.8.2 - 20.12.2020 <https://github.com/jedie/PyInventory/compare/v0.8.1...v0.8.2>`_
|
||||
* `v0.8.2 - 20.12.2020 <https://github.com/jedie/PyInventory/compare/v0.8.1...v0.8.2>`_
|
||||
|
||||
* Bugfix `#33 <https://github.com/jedie/PyInventory/issues/33>`_: Upload images to new created Items
|
||||
|
||||
* `v0.8.1 - 09.12.2020 <https://github.com/jedie/PyInventory/compare/v0.8.0...v0.8.1>`_
|
||||
* `v0.8.1 - 09.12.2020 <https://github.com/jedie/PyInventory/compare/v0.8.0...v0.8.1>`_
|
||||
|
||||
* 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 <https://github.com/jedie/PyInventory/compare/v0.7.0...v0.8.0>`_
|
||||
* `v0.8.0 - 06.12.2020 <https://github.com/jedie/PyInventory/compare/v0.7.0...v0.8.0>`_
|
||||
|
||||
* Outsource the "MEDIA file serve" part into `django.tools.serve_media_app <https://github.com/jedie/django-tools/tree/master/django_tools/serve_media_app#readme>`_
|
||||
|
||||
* `v0.7.0 - 23.11.2020 <https://github.com/jedie/PyInventory/compare/v0.6.0...v0.7.0>`_
|
||||
* `v0.7.0 - 23.11.2020 <https://github.com/jedie/PyInventory/compare/v0.6.0...v0.7.0>`_
|
||||
|
||||
* Change deployment setup:
|
||||
|
||||
|
@ -357,15 +367,15 @@ history
|
|||
|
||||
* pull all docker images before build
|
||||
|
||||
* `v0.6.0 - 15.11.2020 <https://github.com/jedie/PyInventory/compare/v0.5.0...v0.6.0>`_
|
||||
* `v0.6.0 - 15.11.2020 <https://github.com/jedie/PyInventory/compare/v0.5.0...v0.6.0>`_
|
||||
|
||||
* User can store images to every item: The image can only be accessed by the same user.
|
||||
|
||||
* `v0.5.0 - 14.11.2020 <https://github.com/jedie/PyInventory/compare/v0.4.2...v0.5.0>`_
|
||||
* `v0.5.0 - 14.11.2020 <https://github.com/jedie/PyInventory/compare/v0.4.2...v0.5.0>`_
|
||||
|
||||
* Merge separate git branches into one: "/src/" and "/development/" `#19 <https://github.com/jedie/PyInventory/issues/19>`_
|
||||
|
||||
* `v0.4.2 - 13.11.2020 <https://github.com/jedie/PyInventory/compare/v0.4.1...v0.4.2>`_
|
||||
* `v0.4.2 - 13.11.2020 <https://github.com/jedie/PyInventory/compare/v0.4.1...v0.4.2>`_
|
||||
|
||||
* Serve static files by Caddy
|
||||
|
||||
|
@ -373,11 +383,11 @@ history
|
|||
|
||||
* reduce CKEditor plugins
|
||||
|
||||
* `v0.4.1 - 2.11.2020 <https://github.com/jedie/PyInventory/compare/v0.4.0...v0.4.1>`_
|
||||
* `v0.4.1 - 2.11.2020 <https://github.com/jedie/PyInventory/compare/v0.4.0...v0.4.1>`_
|
||||
|
||||
* Small bugfixes
|
||||
|
||||
* `v0.4.0 - 1.11.2020 <https://github.com/jedie/PyInventory/compare/v0.3.2...v0.4.0>`_
|
||||
* `v0.4.0 - 1.11.2020 <https://github.com/jedie/PyInventory/compare/v0.3.2...v0.4.0>`_
|
||||
|
||||
* 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 <https://github.com/jedie/PyInventory/compare/v0.3.0...v0.3.2>`_
|
||||
* `v0.3.2 - 26.10.2020 <https://github.com/jedie/PyInventory/compare/v0.3.0...v0.3.2>`_
|
||||
|
||||
* Bugfix missing translations
|
||||
|
||||
* `v0.3.0 - 26.10.2020 <https://github.com/jedie/PyInventory/compare/v0.2.0...v0.3.0>`_
|
||||
* `v0.3.0 - 26.10.2020 <https://github.com/jedie/PyInventory/compare/v0.2.0...v0.3.0>`_
|
||||
|
||||
* setup production usage:
|
||||
|
||||
|
@ -407,7 +417,7 @@ history
|
|||
|
||||
* Bugfix for using manage commands ``dumpdata`` and ``loaddata``
|
||||
|
||||
* `v0.2.0 - 24.10.2020 <https://github.com/jedie/PyInventory/compare/v0.1.0...v0.2.0>`_
|
||||
* `v0.2.0 - 24.10.2020 <https://github.com/jedie/PyInventory/compare/v0.1.0...v0.2.0>`_
|
||||
|
||||
* Simplify item change list by nested item
|
||||
|
||||
|
@ -419,7 +429,7 @@ history
|
|||
|
||||
* Add docker-compose usage
|
||||
|
||||
* `v0.1.0 - 17.10.2020 <https://github.com/jedie/PyInventory/compare/v0.0.1...v0.1.0>`_
|
||||
* `v0.1.0 - 17.10.2020 <https://github.com/jedie/PyInventory/compare/v0.0.1...v0.1.0>`_
|
||||
|
||||
* 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"``
|
||||
``Note: this file is generated from README.creole 2022-07-22 19:32:28 with "python-creole"``
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <git@jensdiemer.de>"]
|
||||
homepage = "https://github.com/jedie/PyInventory"
|
||||
|
|
|
@ -4,4 +4,4 @@
|
|||
:license: GNU GPL v3 or above, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
__version__ = "0.13.1"
|
||||
__version__ = "0.14.0rc3"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
'<a href="{}">{}<strong>{}</strong></a>',
|
||||
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'), {
|
||||
|
|
|
@ -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',)
|
||||
|
|
|
@ -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))
|
|
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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'<TreeNode {self.__str__()}>'
|
||||
|
||||
|
||||
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
|
|
@ -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
|
|
@ -1,8 +0,0 @@
|
|||
<strong><a href="{{ base_item.local_admin_link }}">{{ base_item.name }}</a></strong>
|
||||
{% if sub_items %}
|
||||
<ul>
|
||||
{% for item in sub_items %}
|
||||
<li><a href="{{ item.local_admin_link }}">{{ item.verbose_name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
|
@ -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
|
||||
)
|
|
@ -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) == '<TreeNode pk:3 name:"1.1.1" path:"1. / 1.1. / 1.1.1">'
|
|
@ -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'),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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 “<a href="/admin/inventory/itemmodel/{item.pk}/change/"> - name</a>”'
|
||||
' was added successfully.'
|
||||
])
|
||||
self.assert_messages(
|
||||
response,
|
||||
expected_messages=[
|
||||
f'The Item “<a href="/admin/inventory/itemmodel/{item.pk}/change/">name</a>”'
|
||||
' 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 “<a href="/admin/inventory/itemmodel/{item.pk}/change/"> - name</a>”'
|
||||
' was added successfully.'
|
||||
])
|
||||
self.assert_messages(
|
||||
response,
|
||||
expected_messages=[
|
||||
f'The Item “<a href="/admin/inventory/itemmodel/{item.pk}/change/">name</a>”'
|
||||
' 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'<title>Select Item to change | PyInventory v{__version__}</title>',
|
||||
|
||||
'<a href="/admin/inventory/itemmodel/00000000-0001-0000-0000-000000000000/change/">'
|
||||
'main item 1</a>',
|
||||
|
||||
'<li><a href="/admin/inventory/itemmodel/00000000-0001-0001-0000-000000000000/change/">'
|
||||
'sub item 1.1</a></li>',
|
||||
))
|
||||
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=(
|
||||
'<input type="text" size="40" name="q" value="sub item 2." id="searchbar" autofocus>',
|
||||
'2 results (<a href="?">6 total</a>)',
|
||||
|
||||
'<a href="/admin/inventory/itemmodel/00000000-0002-0001-0000-000000000000/change/">'
|
||||
'sub item 2.1</a>',
|
||||
|
||||
'<a href="/admin/inventory/itemmodel/00000000-0002-0002-0000-000000000000/change/">'
|
||||
'sub item 2.2</a>',
|
||||
))
|
||||
assert logs.output == [
|
||||
# grouping disabled?
|
||||
'INFO:inventory.admin.item:Group items: False (auto mode: True)'
|
||||
]
|
||||
self.assert_html_parts(
|
||||
response,
|
||||
parts=(
|
||||
f'<title>Select Item to change | PyInventory v{__version__}</title>',
|
||||
'<a href="/admin/inventory/itemmodel/00000000-0001-0000-0000-000000000000/change/">'
|
||||
'<strong>main item 1</strong></a>',
|
||||
'<a href="/admin/inventory/itemmodel/00000000-0001-0001-0000-000000000000/change/">'
|
||||
'main item 1 › <strong>sub item 1.1</strong></a>',
|
||||
),
|
||||
)
|
||||
assert_html_response_snapshot(response=response, validate=False)
|
||||
|
|
|
@ -31,13 +31,6 @@
|
|||
</label>
|
||||
<input autofocus="" id="searchbar" name="q" size="40" type="text" value=""/>
|
||||
<input type="submit" value="Search"/>
|
||||
<span class="small quiet">
|
||||
2 results (
|
||||
<a href="?">
|
||||
6 total
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -74,8 +67,8 @@
|
|||
<button class="button" name="index" title="Run the selected action" type="submit" value="0">
|
||||
Go
|
||||
</button>
|
||||
<span class="action-counter" data-actions-icnt="2">
|
||||
0 of 2 selected
|
||||
<span class="action-counter" data-actions-icnt="6">
|
||||
0 of 6 selected
|
||||
</span>
|
||||
</div>
|
||||
<div class="results">
|
||||
|
@ -91,6 +84,21 @@
|
|||
<div class="clear">
|
||||
</div>
|
||||
</th>
|
||||
<th class="sortable column-item sorted ascending" scope="col">
|
||||
<div class="sortoptions">
|
||||
<a class="sortremove" href="?o=" title="Remove from sorting">
|
||||
</a>
|
||||
<a class="toggle ascending" href="?o=-1" title="Toggle sorting">
|
||||
</a>
|
||||
</div>
|
||||
<div class="text">
|
||||
<a href="?o=-1">
|
||||
Item
|
||||
</a>
|
||||
</div>
|
||||
<div class="clear">
|
||||
</div>
|
||||
</th>
|
||||
<th class="column-_tagulous_display_kind" scope="col">
|
||||
<div class="text">
|
||||
<span>
|
||||
|
@ -109,18 +117,9 @@
|
|||
<div class="clear">
|
||||
</div>
|
||||
</th>
|
||||
<th class="column-column_item" scope="col">
|
||||
<div class="text">
|
||||
<span>
|
||||
Items
|
||||
</span>
|
||||
</div>
|
||||
<div class="clear">
|
||||
</div>
|
||||
</th>
|
||||
<th class="sortable column-location" scope="col">
|
||||
<div class="text">
|
||||
<a href="?o=4">
|
||||
<a href="?o=4.1">
|
||||
Location
|
||||
</a>
|
||||
</div>
|
||||
|
@ -129,7 +128,7 @@
|
|||
</th>
|
||||
<th class="sortable column-received_date" scope="col">
|
||||
<div class="text">
|
||||
<a href="?o=5">
|
||||
<a href="?o=5.1">
|
||||
Received date
|
||||
</a>
|
||||
</div>
|
||||
|
@ -138,7 +137,7 @@
|
|||
</th>
|
||||
<th class="sortable column-update_dt" scope="col">
|
||||
<div class="text">
|
||||
<a href="?o=6">
|
||||
<a href="?o=6.1">
|
||||
Last update
|
||||
</a>
|
||||
</div>
|
||||
|
@ -152,29 +151,17 @@
|
|||
<td class="action-checkbox">
|
||||
<input class="action-select" name="_selected_action" type="checkbox" value="00000000-0001-0000-0000-000000000000"/>
|
||||
</td>
|
||||
<td class="field-item">
|
||||
<a href="/admin/inventory/itemmodel/00000000-0001-0000-0000-000000000000/change/">
|
||||
<strong>
|
||||
main item 1
|
||||
</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td class="field-_tagulous_display_kind">
|
||||
</td>
|
||||
<td class="field-_tagulous_display_producer">
|
||||
</td>
|
||||
<td class="field-column_item">
|
||||
<strong>
|
||||
<a href="/admin/inventory/itemmodel/00000000-0001-0000-0000-000000000000/change/">
|
||||
main item 1
|
||||
</a>
|
||||
</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/admin/inventory/itemmodel/00000000-0001-0001-0000-000000000000/change/">
|
||||
sub item 1.1
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/inventory/itemmodel/00000000-0001-0002-0000-000000000000/change/">
|
||||
sub item 1.2
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td class="field-location nowrap">
|
||||
-
|
||||
</td>
|
||||
|
@ -187,30 +174,70 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td class="action-checkbox">
|
||||
<input class="action-select" name="_selected_action" type="checkbox" value="00000000-0002-0000-0000-000000000000"/>
|
||||
<input class="action-select" name="_selected_action" type="checkbox" value="00000000-0001-0001-0000-000000000000"/>
|
||||
</td>
|
||||
<td class="field-item">
|
||||
<a href="/admin/inventory/itemmodel/00000000-0001-0001-0000-000000000000/change/">
|
||||
main item 1 ›
|
||||
<strong>
|
||||
sub item 1.1
|
||||
</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td class="field-_tagulous_display_kind">
|
||||
</td>
|
||||
<td class="field-_tagulous_display_producer">
|
||||
</td>
|
||||
<td class="field-column_item">
|
||||
<strong>
|
||||
<a href="/admin/inventory/itemmodel/00000000-0002-0000-0000-000000000000/change/">
|
||||
<td class="field-location nowrap">
|
||||
-
|
||||
</td>
|
||||
<td class="field-received_date nowrap">
|
||||
-
|
||||
</td>
|
||||
<td class="field-update_dt nowrap">
|
||||
Jan. 1, 2000, 1:02 a.m.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="action-checkbox">
|
||||
<input class="action-select" name="_selected_action" type="checkbox" value="00000000-0001-0002-0000-000000000000"/>
|
||||
</td>
|
||||
<td class="field-item">
|
||||
<a href="/admin/inventory/itemmodel/00000000-0001-0002-0000-000000000000/change/">
|
||||
main item 1 ›
|
||||
<strong>
|
||||
sub item 1.2
|
||||
</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td class="field-_tagulous_display_kind">
|
||||
</td>
|
||||
<td class="field-_tagulous_display_producer">
|
||||
</td>
|
||||
<td class="field-location nowrap">
|
||||
-
|
||||
</td>
|
||||
<td class="field-received_date nowrap">
|
||||
-
|
||||
</td>
|
||||
<td class="field-update_dt nowrap">
|
||||
Jan. 1, 2000, 1:03 a.m.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="action-checkbox">
|
||||
<input class="action-select" name="_selected_action" type="checkbox" value="00000000-0002-0000-0000-000000000000"/>
|
||||
</td>
|
||||
<td class="field-item">
|
||||
<a href="/admin/inventory/itemmodel/00000000-0002-0000-0000-000000000000/change/">
|
||||
<strong>
|
||||
main item 2
|
||||
</a>
|
||||
</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/admin/inventory/itemmodel/00000000-0002-0001-0000-000000000000/change/">
|
||||
sub item 2.1
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/inventory/itemmodel/00000000-0002-0002-0000-000000000000/change/">
|
||||
sub item 2.2
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td class="field-_tagulous_display_kind">
|
||||
</td>
|
||||
<td class="field-_tagulous_display_producer">
|
||||
</td>
|
||||
<td class="field-location nowrap">
|
||||
-
|
||||
|
@ -222,11 +249,63 @@
|
|||
Jan. 1, 2000, 1:04 a.m.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="action-checkbox">
|
||||
<input class="action-select" name="_selected_action" type="checkbox" value="00000000-0002-0001-0000-000000000000"/>
|
||||
</td>
|
||||
<td class="field-item">
|
||||
<a href="/admin/inventory/itemmodel/00000000-0002-0001-0000-000000000000/change/">
|
||||
main item 2 ›
|
||||
<strong>
|
||||
sub item 2.1
|
||||
</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td class="field-_tagulous_display_kind">
|
||||
</td>
|
||||
<td class="field-_tagulous_display_producer">
|
||||
</td>
|
||||
<td class="field-location nowrap">
|
||||
-
|
||||
</td>
|
||||
<td class="field-received_date nowrap">
|
||||
-
|
||||
</td>
|
||||
<td class="field-update_dt nowrap">
|
||||
Jan. 1, 2000, 1:05 a.m.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="action-checkbox">
|
||||
<input class="action-select" name="_selected_action" type="checkbox" value="00000000-0002-0002-0000-000000000000"/>
|
||||
</td>
|
||||
<td class="field-item">
|
||||
<a href="/admin/inventory/itemmodel/00000000-0002-0002-0000-000000000000/change/">
|
||||
main item 2 ›
|
||||
<strong>
|
||||
sub item 2.2
|
||||
</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td class="field-_tagulous_display_kind">
|
||||
</td>
|
||||
<td class="field-_tagulous_display_producer">
|
||||
</td>
|
||||
<td class="field-location nowrap">
|
||||
-
|
||||
</td>
|
||||
<td class="field-received_date nowrap">
|
||||
-
|
||||
</td>
|
||||
<td class="field-update_dt nowrap">
|
||||
Jan. 1, 2000, 1:06 a.m.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="paginator">
|
||||
2 Items
|
||||
6 Items
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -235,17 +314,27 @@
|
|||
Filter
|
||||
</h2>
|
||||
<h3>
|
||||
By Group Items
|
||||
By Limit tree depth
|
||||
</h3>
|
||||
<ul>
|
||||
<li class="selected">
|
||||
<a href="?" title="Automatic">
|
||||
Automatic
|
||||
<a href="?" title="All">
|
||||
All
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="?grouping=no" title="No">
|
||||
No
|
||||
<a href="?level=1" title="Only root">
|
||||
Only root
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="?level=2" title="Root + first sub">
|
||||
Root + first sub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="?level=3" title="Root + first + second sub">
|
||||
Root + first + second sub
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -171,6 +171,7 @@
|
|||
</a>
|
||||
</div>
|
||||
<div class="help">
|
||||
Locations can be nested. Example: The box 12 in cupboard 3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<html dir="ltr" lang="en">
|
||||
<head>
|
||||
<title>
|
||||
Change Item | PyInventory v0.13.1
|
||||
Change Item | PyInventory v0.14.0rc3
|
||||
</title>
|
||||
<link href="/static/admin/css/base.css" rel="stylesheet" type="text/css"/>
|
||||
<link href="/static/admin/css/nav_sidebar.css" rel="stylesheet" type="text/css"/>
|
||||
|
@ -80,7 +80,7 @@
|
|||
<div id="branding">
|
||||
<h1 id="site-name">
|
||||
<a href="/admin/">
|
||||
PyInventory v0.13.1
|
||||
PyInventory v0.14.0rc3
|
||||
</a>
|
||||
</h1>
|
||||
</div>
|
||||
|
@ -116,7 +116,7 @@
|
|||
<a href="/admin/inventory/itemmodel/">
|
||||
Items
|
||||
</a>
|
||||
› - A new Name!
|
||||
› name
|
||||
</div>
|
||||
<div class="main shifted" id="main">
|
||||
<button aria-label="Toggle navigation" class="sticky toggle-nav-sidebar" id="toggle-nav-sidebar">
|
||||
|
@ -175,7 +175,7 @@
|
|||
Change Item
|
||||
</h1>
|
||||
<h2>
|
||||
- A new Name!
|
||||
name
|
||||
</h2>
|
||||
<div id="content-main">
|
||||
<ul class="object-tools">
|
||||
|
@ -367,7 +367,7 @@
|
|||
---------
|
||||
</option>
|
||||
<option value="<removed-UUID>">
|
||||
- name
|
||||
name
|
||||
</option>
|
||||
</select>
|
||||
<a class="related-widget-wrapper-link change-related" data-href-template="/admin/inventory/itemmodel/__fk__/change/?_to_field=id&_popup=1" id="change_id_parent" title="Change selected Item">
|
||||
|
@ -381,6 +381,7 @@
|
|||
</a>
|
||||
</div>
|
||||
<div class="help">
|
||||
Locations can be nested. Example: The box 12 in cupboard 3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Ładowanie…
Reference in New Issue