├── .editorconfig ├── .flake8 ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── .travis.yml ├── AUTHORS ├── LICENSE ├── README.md ├── inventory ├── __init__.py ├── admin │ ├── __init__.py │ ├── base.py │ ├── item.py │ ├── location.py │ ├── memo.py │ └── tagulous_fix.py ├── apps.py ├── checks.py ├── ckeditor_upload.py ├── constants.py ├── context_processors.py ├── forms.py ├── locale │ ├── ca │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── es │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── seed_data.py │ │ └── tree.py ├── middlewares.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20201017_2211.py │ ├── 0003_auto_20201024_1830.py │ ├── 0004_item_user_images.py │ ├── 0005_serve_uploads_by_django_tools.py │ ├── 0006_refactor_image_model.py │ ├── 0007_add_file_attachment.py │ ├── 0008_last_check_datetime.py │ ├── 0009_add_memo.py │ ├── 0010_version_protect_models.py │ ├── 0011_parent_tree1.py │ ├── 0012_parent_tree2.py │ ├── 0013_alter_itemmodel_location.py │ ├── 0014_alter_itemmodel_description_and_more.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── base.py │ ├── item.py │ ├── links.py │ ├── location.py │ └── memo.py ├── parent_tree.py ├── permissions.py ├── request_dict.py ├── signals.py ├── string_utils.py ├── templates │ └── admin │ │ ├── item │ │ └── related_items.html │ │ └── location │ │ └── items.html └── tests │ ├── __init__.py │ ├── fixtures │ ├── __init__.py │ └── users.py │ ├── test_admin_location.py │ ├── test_admin_location_empty_change_list_1.snapshot.html │ ├── test_item_images.py │ ├── test_link_model.py │ ├── test_management_command_seed_data.py │ ├── test_management_command_tree.py │ ├── test_parent_tree.py │ └── test_parent_tree_model.py ├── inventory_project ├── __init__.py ├── __main__.py ├── manage.py ├── middlewares.py ├── publish.py ├── settings │ ├── __init__.py │ ├── local.py │ ├── prod.py │ └── tests.py ├── templates │ └── admin │ │ ├── base_site.html │ │ └── login.html ├── tests │ ├── __init__.py │ ├── fixtures.py │ ├── mocks.py │ ├── playwright_utils.py │ ├── test_admin.py │ ├── test_admin_item.py │ ├── test_admin_item_auto_group_items_1.snapshot.html │ ├── test_admin_item_login_1.snapshot.html │ ├── test_admin_item_normal_user_create_minimal_item_1.snapshot.html │ ├── test_admin_item_normal_user_create_minimal_item_2.snapshot.html │ ├── test_admin_memo.py │ ├── test_admin_memo_normal_user_create_minimal_item_1.snapshot.html │ ├── test_admin_superuser_admin_index_1.snapshot.html │ ├── test_inventory_commands.py │ ├── test_inventory_commands_help_1.snapshot.txt │ ├── test_migrations.py │ ├── test_models_item.py │ ├── test_playwright_admin.py │ ├── test_project_setup.py │ └── test_readme_history.py ├── urls.py └── wsgi.py ├── manage.py ├── pyproject.toml └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{html,css,js}] 13 | insert_final_newline = false 14 | 15 | [*.py] 16 | max_line_length = 119 17 | 18 | [{Makefile,**.mk}] 19 | indent_style = tab 20 | insert_final_newline = false 21 | 22 | [{*.yaml,*.yml}] 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # 2 | # Move to pyproject.toml after: https://github.com/PyCQA/flake8/issues/234 3 | # 4 | [flake8] 5 | exclude = .*, dist, htmlcov, */migrations/* 6 | #ignore = E402 7 | max-line-length = 119 8 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | 2 | 3 | name: tests 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | schedule: 11 | - cron: '0 8 * * *' 12 | 13 | jobs: 14 | test: 15 | name: 'Python ${{ matrix.python-version }}' 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ['3.13', '3.12', '3.11'] 21 | steps: 22 | - name: Checkout 23 | run: | 24 | echo $GITHUB_REF $GITHUB_SHA 25 | git clone https://github.com/$GITHUB_REPOSITORY.git . 26 | git fetch origin $GITHUB_SHA:temporary-ci-branch 27 | git checkout $GITHUB_SHA || (git fetch && git checkout $GITHUB_SHA) 28 | 29 | - name: 'Set up Python ${{ matrix.python-version }}' 30 | uses: actions/setup-python@v5 31 | # https://github.com/marketplace/actions/setup-python 32 | with: 33 | python-version: '${{ matrix.python-version }}' 34 | cache: 'pip' # caching pip dependencies 35 | cache-dependency-path: 'uv.lock' 36 | 37 | - name: 'Bootstrap' 38 | # The first manage.py call will create the .venv 39 | run: | 40 | ./manage.py version 41 | 42 | - name: 'Install Playwright browsers' 43 | run: | 44 | .venv/bin/playwright install 45 | 46 | - name: 'Display all Django commands' 47 | run: | 48 | ./manage.py --help 49 | 50 | - name: 'Run pip-audit' 51 | run: | 52 | ./manage.py pip_audit 53 | 54 | - name: 'Python ${{ matrix.python-version }}' 55 | env: 56 | PYTHONUNBUFFERED: 1 57 | PYTHONWARNINGS: always 58 | run: | 59 | ./manage.py test 60 | 61 | - name: 'Upload coverage report' 62 | uses: codecov/codecov-action@v5 63 | # https://github.com/marketplace/actions/codecov 64 | with: 65 | fail_ci_if_error: false 66 | verbose: true 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.egg-info 3 | __pycache__ 4 | /dist/ 5 | /build/ 6 | /coverage.* 7 | *.orig 8 | 9 | !.github 10 | !.editorconfig 11 | !.flake8 12 | !.gitignore 13 | !.pre-commit-config.yaml 14 | !.pre-commit-hooks.yaml 15 | !.gitkeep 16 | 17 | # for django-dbbackup 18 | /backups/ 19 | !/backups/.gitkeep 20 | 21 | # from test projects: 22 | **/static/* 23 | **/media/* 24 | *.sqlite3 25 | *.json 26 | 27 | # Include "ignored" *.json: 28 | !**/fixtures/*.json 29 | 30 | # Django 31 | # Include all test snapshot files: 32 | !**/*.snapshot.* 33 | secret.txt 34 | 35 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # pre-commit plugin configuration 2 | # See https://pre-commit.com for more information 3 | default_install_hook_types: 4 | - prepare-commit-msg 5 | - post-commit 6 | - post-rewrite 7 | - pre-push 8 | 9 | repos: 10 | - repo: https://github.com/jedie/cli-base-utilities 11 | rev: v0.17.0 12 | hooks: 13 | - id: update-readme-history 14 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | # https://pre-commit.com/#creating-new-hooks 2 | - id: update-readme-history 3 | name: cli-base-utilities 4 | description: >- 5 | Update history in README.md from git log. 6 | entry: "python -m cli_base update-readme-history -v" 7 | language: python 8 | language_version: python3 9 | require_serial: true 10 | pass_filenames: false 11 | always_run: true 12 | verbose: true 13 | stages: [pre-commit, post-rewrite, pre-push] 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | sudo: required 4 | dist: xenial 5 | language: python 6 | cache: pip 7 | 8 | addons: 9 | firefox: latest 10 | chrome: stable 11 | 12 | matrix: 13 | fast_finish: true 14 | include: 15 | - os: linux 16 | python: 3.6 17 | env: TOXENV=py36 18 | - os: linux 19 | python: 3.7 20 | env: TOXENV=py37 21 | - os: linux 22 | python: 3.8 23 | env: TOXENV=py38 24 | # TODO: SQlite errors, e.g.: https://travis-ci.org/github/jedie/PyInventory/jobs/663624080 25 | #- os: linux 26 | # python: pypy3 27 | # TODO: 28 | #- os: osx 29 | # language: generic 30 | 31 | before_install: 32 | # 33 | # install Chromium Browser + Selenium WebDriver for it: 34 | - sudo apt-get update 35 | - sudo apt-get install chromium-browser chromium-chromedriver 36 | # 37 | # install Selenium Firefox WebDriver 'geckodriver': 38 | - wget https://github.com/mozilla/geckodriver/releases/download/v0.20.1/geckodriver-v0.20.1-linux64.tar.gz -O geckodriver.tar.gz 39 | - mkdir $PWD/geckodriver 40 | - tar -xvf geckodriver.tar.gz -C $PWD/geckodriver 41 | - ls -la $PWD/geckodriver 42 | - export PATH=$PATH:$PWD/geckodriver 43 | - geckodriver --version 44 | 45 | install: 46 | - pip3 install poetry 47 | - make install 48 | - poetry run pip freeze 49 | - make tox-listenvs 50 | 51 | script: 52 | - if [ "$TOXENV" == "" ]; then make pytest; fi 53 | - if [ "$TOXENV" != "" ]; then make tox; fi 54 | - if [ "$TOXENV" != "" ]; then make lint; fi 55 | 56 | after_success: 57 | - coveralls 58 | # https://github.com/codecov/codecov-bash 59 | - bash <(curl -s https://codecov.io/bash) 60 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | 2 | PRIMARY AUTHORS are and/or have been (alphabetic order): 3 | 4 | * Diemer, Jens 5 | Main Developer since the first code line. 6 | github.com profile: 7 | openhub.net profile: 8 | Homepage: 9 | 10 | 11 | CONTRIBUTORS are and/or have been (Chronologically sorted from new to old): 12 | * López, Jaume 13 | Translator to Spanish and Catalan 14 | github.com profile: 15 | -------------------------------------------------------------------------------- /inventory/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyInventory 3 | Web based management to catalog things including state and location etc. using Python/Django. 4 | 5 | created 14.20.2020 by Jens Diemer 6 | :copyleft: 2020-2024 by the PyInventory team, see AUTHORS for more details. 7 | :license: GNU GPL v3 or above, see LICENSE for more details. 8 | """ 9 | 10 | # See https://packaging.python.org/en/latest/specifications/version-specifiers/ 11 | __version__ = '0.21.1' 12 | __author__ = 'Jens Diemer ' 13 | -------------------------------------------------------------------------------- /inventory/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from inventory.admin.item import ItemModelAdmin # noqa 2 | from inventory.admin.location import LocationModelAdmin # noqa 3 | from inventory.admin.memo import MemoModelAdmin # noqa 4 | -------------------------------------------------------------------------------- /inventory/admin/base.py: -------------------------------------------------------------------------------- 1 | from adminsortable2.admin import SortableInlineAdminMixin 2 | from django.contrib import admin 3 | from django.utils.html import format_html 4 | from django.utils.translation import gettext_lazy as _ 5 | from reversion_compare.admin import CompareVersionAdmin 6 | 7 | from inventory.forms import OnlyUserRelationsModelForm 8 | 9 | 10 | class UserInlineMixin: 11 | def get_queryset(self, request): 12 | qs = super().get_queryset(request) 13 | 14 | if not request.user.is_superuser: 15 | # Display only own created entries 16 | qs = qs.filter(user=request.user) 17 | 18 | return qs 19 | 20 | 21 | class BaseUserAdmin(CompareVersionAdmin): 22 | form = OnlyUserRelationsModelForm 23 | 24 | def get_changelist(self, request, **kwargs): 25 | self.request = request 26 | self.user = request.user 27 | return super().get_changelist(request, **kwargs) 28 | 29 | def save_model(self, request, obj, form, change): 30 | if obj.user_id is None: 31 | obj.user = request.user 32 | 33 | super().save_model(request, obj, form, change) 34 | 35 | def get_queryset(self, request): 36 | qs = super().get_queryset(request) 37 | qs = qs.select_related( 38 | 'user', 39 | ) 40 | if not request.user.is_superuser: 41 | # Display only own created entries 42 | qs = qs.filter(user=request.user) 43 | 44 | return qs 45 | 46 | def get_list_filter(self, request): 47 | list_filter = self.list_filter 48 | 49 | if request.user.is_superuser: 50 | # Superuser sees entries from all users -> Add "By user" filter 51 | list_filter = list(list_filter) 52 | list_filter.insert(0, 'user') 53 | 54 | return list_filter 55 | 56 | def get_list_display(self, request): 57 | list_display = self.list_display 58 | 59 | if request.user.is_superuser: 60 | # Superuser sees entries from all users -> Display the user in change list 61 | list_display = list(list_display) 62 | list_display.insert(0, 'user') 63 | 64 | return list_display 65 | 66 | 67 | class BaseImageModelInline(UserInlineMixin, SortableInlineAdminMixin, admin.TabularInline): 68 | def preview(self, instance): 69 | return format_html( 70 | ( 71 | '' 73 | '' 74 | ), 75 | url=instance.image.url, 76 | name=instance.name, 77 | ) 78 | 79 | extra = 0 80 | fields = ('position', 'preview', 'image', 'name', 'tags') 81 | readonly_fields = ('preview',) 82 | 83 | 84 | class BaseFileModelInline(UserInlineMixin, SortableInlineAdminMixin, admin.TabularInline): 85 | extra = 0 86 | fields = ('position', 'file', 'name', 'tags') 87 | 88 | 89 | class LimitTreeDepthListFilter(admin.SimpleListFilter): 90 | title = _('Limit tree depth') 91 | parameter_name = 'level' 92 | 93 | def lookups(self, request, model_admin): 94 | return ( 95 | ('1', _('Only root')), 96 | ('2', _('Root + first sub')), 97 | ('3', _('Root + first + second sub')), 98 | ) 99 | 100 | def queryset(self, request, queryset): 101 | level = self.value() 102 | if level: 103 | level = int(level) 104 | return queryset.filter(level__lte=level) 105 | -------------------------------------------------------------------------------- /inventory/admin/item.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import tagulous 4 | from adminsortable2.admin import SortableAdminMixin, SortableInlineAdminMixin 5 | from django.conf import settings 6 | from django.contrib import admin 7 | from django.template.loader import render_to_string 8 | from django.urls import reverse 9 | from django.utils.html import format_html 10 | from django.utils.translation import gettext_lazy as _ 11 | from import_export.admin import ImportExportMixin 12 | from import_export.resources import ModelResource 13 | 14 | from inventory.admin.base import ( 15 | BaseFileModelInline, 16 | BaseImageModelInline, 17 | BaseUserAdmin, 18 | LimitTreeDepthListFilter, 19 | UserInlineMixin, 20 | ) 21 | from inventory.admin.tagulous_fix import TagulousModelAdminFix 22 | from inventory.models import ItemLinkModel, ItemModel 23 | from inventory.models.item import ItemFileModel, ItemImageModel 24 | from inventory.string_utils import ltruncatechars 25 | 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class ItemLinkModelInline(UserInlineMixin, SortableInlineAdminMixin, admin.TabularInline): 31 | model = ItemLinkModel 32 | extra = 0 33 | 34 | 35 | class ItemImageModelInline(BaseImageModelInline): 36 | model = ItemImageModel 37 | 38 | 39 | class ItemFileModelInline(BaseFileModelInline): 40 | model = ItemFileModel 41 | 42 | 43 | class ItemModelResource(ModelResource): 44 | class Meta: 45 | model = ItemModel 46 | 47 | 48 | @admin.register(ItemModel) 49 | class ItemModelAdmin(TagulousModelAdminFix, ImportExportMixin, SortableAdminMixin, BaseUserAdmin): 50 | @admin.display(description=_('Related items')) 51 | def related_items(self, obj): 52 | if obj.pk is None: 53 | # Add a new item -> there are no related items ;) 54 | return '-' 55 | 56 | related_qs = ItemModel.tree_objects.related_objects(instance=obj) 57 | context = { 58 | 'items': related_qs, 59 | 'opts': self.opts, 60 | } 61 | return render_to_string('admin/item/related_items.html', context) 62 | 63 | @admin.display(ordering='path_str', description=_('ItemModel.verbose_name')) 64 | def item(self, obj): 65 | path = obj.path 66 | if len(path) > 1: 67 | prefixes = ' › '.join(path[:-1] + ['']) 68 | prefixes = ltruncatechars(prefixes, max_length=settings.TREE_PATH_STR_MAX_LENGTH) 69 | else: 70 | prefixes = '' 71 | item = path[-1] 72 | url = reverse('admin:inventory_itemmodel_change', args=[obj.pk]) 73 | return format_html( 74 | '{}{}', 75 | url, 76 | prefixes, 77 | item, 78 | ) 79 | 80 | def get_queryset(self, request): 81 | qs = super().get_queryset(request) 82 | qs = qs.prefetch_related( 83 | 'location', 84 | 'kind', 85 | 'producer', 86 | ) 87 | return qs 88 | 89 | def get_max_order(self, request, obj=None): 90 | # Work-a-round for: https://github.com/jrief/django-admin-sortable2/issues/341 91 | return 0 92 | 93 | date_hierarchy = 'create_dt' 94 | list_display = ('producer', 'item', 'kind', 'location', 'received_date', 'update_dt') 95 | ordering = ('path_str',) 96 | list_display_links = () 97 | list_filter = (LimitTreeDepthListFilter, 'kind', 'location', 'producer', 'tags') 98 | search_fields = ('name', 'description', 'kind__name', 'tags__name') 99 | fieldsets = ( 100 | ( 101 | _('Internals'), 102 | { 103 | 'classes': ('collapse',), 104 | 'fields': ( 105 | ('id', 'version'), 106 | 'user', 107 | ), 108 | }, 109 | ), 110 | (_('Meta'), {'classes': ('collapse',), 'fields': ('create_dt', 'update_dt')}), 111 | ( 112 | _('Basic'), 113 | { 114 | 'fields': ( 115 | 'kind', 116 | ('producer', 'name'), 117 | 'description', 118 | 'tags', 119 | 'fcc_id', 120 | 'parent', 121 | 'location', 122 | ) 123 | }, 124 | ), 125 | (_('Related items'), {'classes': ('collapse',), 'fields': ('related_items',)}), 126 | ( 127 | _('Lent'), 128 | { 129 | 'classes': ('collapse',), 130 | 'fields': ( 131 | 'lent_to', 132 | ( 133 | 'lent_from_date', 134 | 'lent_until_date', 135 | ), 136 | ), 137 | }, 138 | ), 139 | ( 140 | _('Received'), 141 | { 142 | 'classes': ('collapse',), 143 | 'fields': (('received_from', 'received_date', 'received_price'),), 144 | }, 145 | ), 146 | ( 147 | _('Handed over'), 148 | { 149 | 'classes': ('collapse',), 150 | 'fields': (('handed_over_to', 'handed_over_date', 'handed_over_price'),), 151 | }, 152 | ), 153 | ) 154 | autocomplete_fields = ('parent', 'location') 155 | readonly_fields = ('id', 'create_dt', 'update_dt', 'user', 'related_items') 156 | inlines = (ItemImageModelInline, ItemFileModelInline, ItemLinkModelInline) 157 | 158 | def get_list_display(self, request): 159 | list_display = list(super().get_list_display(request)) 160 | 161 | # FIXME: SortableAdminMixin.get_list_display() adds this, we didn't need here: 162 | # See: https://github.com/jrief/django-admin-sortable2/issues/363 163 | if '_reorder_' in list_display: 164 | list_display.remove('_reorder_') 165 | 166 | return list_display 167 | 168 | 169 | tagulous.admin.enhance(ItemModel, ItemModelAdmin) 170 | -------------------------------------------------------------------------------- /inventory/admin/location.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.db.models import Count 4 | from django.db.models.options import Options 5 | from django.template.loader import render_to_string 6 | from django.utils.translation import gettext_lazy as _ 7 | from import_export.admin import ImportExportMixin 8 | from import_export.resources import ModelResource 9 | 10 | from inventory.admin.base import BaseUserAdmin, LimitTreeDepthListFilter 11 | from inventory.models import ItemModel, LocationModel 12 | from inventory.string_utils import ltruncatechars 13 | 14 | 15 | class LocationModelResource(ModelResource): 16 | class Meta: 17 | model = LocationModel 18 | 19 | 20 | @admin.register(LocationModel) 21 | class LocationModelAdmin(ImportExportMixin, BaseUserAdmin): 22 | @admin.display(ordering='item_count', description=_('ItemModel.verbose_name_plural')) 23 | def item_count(self, obj): 24 | return obj.item_count 25 | 26 | @admin.display(description=_('ItemModel.verbose_name_plural')) 27 | def items(self, obj): 28 | item_qs = ItemModel.objects.filter(location=obj) 29 | opts: Options = ItemModel._meta 30 | context = { 31 | 'items': item_qs, 32 | 'opts': opts, 33 | } 34 | return render_to_string('admin/location/items.html', context) 35 | 36 | @admin.display(ordering='path_str', description=_('LocationModel.verbose_name')) 37 | def location(self, obj): 38 | text = ' › '.join(obj.path) 39 | text = ltruncatechars(text, max_length=settings.TREE_PATH_STR_MAX_LENGTH) 40 | return text 41 | 42 | def get_queryset(self, request): 43 | qs = super().get_queryset(request) 44 | qs = qs.annotate(item_count=Count('items')) 45 | return qs 46 | 47 | list_display = ('location', 'create_dt', 'update_dt', 'item_count') 48 | fieldsets = ( 49 | ( 50 | _('Internals'), 51 | { 52 | 'classes': ('collapse',), 53 | 'fields': ( 54 | ('id', 'version'), 55 | 'user', 56 | ), 57 | }, 58 | ), 59 | (_('Meta'), {'classes': ('collapse',), 'fields': ('create_dt', 'update_dt')}), 60 | ( 61 | _('Basic'), 62 | { 63 | 'fields': ( 64 | 'name', 65 | 'description', 66 | 'tags', 67 | 'parent', 68 | ) 69 | }, 70 | ), 71 | (_('Items in this Location'), {'fields': ('items',)}), 72 | ) 73 | readonly_fields = ('id', 'create_dt', 'update_dt', 'user', 'item_count', 'items') 74 | list_display_links = ('location',) 75 | list_filter = (LimitTreeDepthListFilter,) 76 | search_fields = ('name', 'description', 'tags__name') 77 | ordering = ('path_str',) 78 | -------------------------------------------------------------------------------- /inventory/admin/memo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import tagulous 4 | from adminsortable2.admin import SortableAdminMixin, SortableInlineAdminMixin 5 | from django.contrib import admin 6 | from django.utils.translation import gettext_lazy as _ 7 | from import_export.admin import ImportExportMixin 8 | from import_export.resources import ModelResource 9 | 10 | from inventory.admin.base import BaseFileModelInline, BaseImageModelInline, BaseUserAdmin, UserInlineMixin 11 | from inventory.admin.tagulous_fix import TagulousModelAdminFix 12 | from inventory.models import MemoLinkModel, MemoModel 13 | from inventory.models.memo import MemoFileModel, MemoImageModel 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class MemoLinkModelInline(UserInlineMixin, SortableInlineAdminMixin, admin.TabularInline): 20 | model = MemoLinkModel 21 | extra = 0 22 | 23 | 24 | class MemoImageModelInline(BaseImageModelInline): 25 | model = MemoImageModel 26 | 27 | 28 | class MemoFileModelInline(BaseFileModelInline): 29 | model = MemoFileModel 30 | 31 | 32 | class MemoModelResource(ModelResource): 33 | class Meta: 34 | model = MemoModel 35 | 36 | 37 | @admin.register(MemoModel) 38 | class MemoModelAdmin(TagulousModelAdminFix, ImportExportMixin, SortableAdminMixin, BaseUserAdmin): 39 | def get_max_order(self, request, obj=None): 40 | # Work-a-round for: https://github.com/jrief/django-admin-sortable2/issues/341 41 | return 0 42 | 43 | date_hierarchy = 'create_dt' 44 | list_display = ('name', 'update_dt') 45 | ordering = ('-update_dt',) 46 | list_display_links = ('name',) 47 | list_filter = ('tags',) 48 | search_fields = ('name', 'memo', 'tags__name') 49 | fieldsets = ( 50 | ( 51 | _('Internals'), 52 | { 53 | 'classes': ('collapse',), 54 | 'fields': ( 55 | ('id', 'version'), 56 | 'user', 57 | ), 58 | }, 59 | ), 60 | (_('Meta'), {'classes': ('collapse',), 'fields': ('create_dt', 'update_dt')}), 61 | ( 62 | _('Basic'), 63 | { 64 | 'fields': ( 65 | 'name', 66 | 'memo', 67 | 'tags', 68 | ) 69 | }, 70 | ), 71 | ) 72 | readonly_fields = ('id', 'create_dt', 'update_dt', 'user') 73 | inlines = (MemoImageModelInline, MemoFileModelInline, MemoLinkModelInline) 74 | 75 | 76 | tagulous.admin.enhance(MemoModel, MemoModelAdmin) 77 | -------------------------------------------------------------------------------- /inventory/admin/tagulous_fix.py: -------------------------------------------------------------------------------- 1 | """ 2 | Work-a-round for: 3 | https://github.com/radiac/django-tagulous/issues/164 4 | """ 5 | from django import forms 6 | from django.contrib.admin.widgets import AutocompleteMixin 7 | from tagulous import settings as tagulous_settings 8 | from tagulous.forms import AdminTagWidget, BaseTagField 9 | from tagulous.models import SingleTagField, TagField 10 | 11 | 12 | class AdminTagWidget2(AdminTagWidget): 13 | @property 14 | def media(self): 15 | # Get the media from the AutocompleteMixin - this will give us Django's 16 | # vendor jQuery and select2 17 | class GetMedia(AutocompleteMixin, forms.Select): 18 | pass 19 | 20 | dependency_media = GetMedia(None, None).media 21 | tagulous_media = forms.Media( 22 | js=tagulous_settings.ADMIN_AUTOCOMPLETE_JS, 23 | css=tagulous_settings.ADMIN_AUTOCOMPLETE_CSS, 24 | ) 25 | all_media = dependency_media + tagulous_media 26 | 27 | return all_media 28 | 29 | 30 | class BaseTagField2(BaseTagField): 31 | widget = AdminTagWidget2 32 | 33 | 34 | class TagulousModelAdminFix: 35 | def __init__(self, *args, **kwargs): 36 | super().__init__(*args, **kwargs) 37 | 38 | self.formfield_overrides[SingleTagField] = { 39 | 'form_class': BaseTagField2, 40 | 'widget': AdminTagWidget2, 41 | } 42 | self.formfield_overrides[TagField] = { 43 | 'form_class': BaseTagField2, 44 | 'widget': AdminTagWidget2, 45 | } 46 | -------------------------------------------------------------------------------- /inventory/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig as BaseAppConfig 2 | 3 | 4 | class AppConfig(BaseAppConfig): 5 | name = 'inventory' 6 | verbose_name = 'Inventory' 7 | 8 | def ready(self): 9 | import inventory.checks # noqa 10 | import inventory.signals # noqa 11 | -------------------------------------------------------------------------------- /inventory/checks.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/checks.py -------------------------------------------------------------------------------- /inventory/ckeditor_upload.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bx_django_utils.filename import clean_filename 4 | from django.utils.crypto import get_random_string 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def get_filename(filename, request): 11 | random_string = get_random_string(length=12) 12 | filename = clean_filename(filename) 13 | filename = f'{random_string}/{filename}' 14 | logger.info(f'Upload filename: {filename!r}') 15 | return filename 16 | -------------------------------------------------------------------------------- /inventory/constants.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/constants.py -------------------------------------------------------------------------------- /inventory/context_processors.py: -------------------------------------------------------------------------------- 1 | from inventory import __version__ 2 | 3 | 4 | def inventory_version_string(request): 5 | return {'inventory_version_string': f'v{__version__}'} 6 | -------------------------------------------------------------------------------- /inventory/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import FieldDoesNotExist 3 | 4 | from inventory.request_dict import get_request_dict 5 | 6 | 7 | class OnlyUserRelationsModelForm(forms.ModelForm): 8 | def __init__(self, *args, **kwargs): 9 | super().__init__(*args, **kwargs) 10 | 11 | # Filter all related fields that has a "user" attribute for the current user 12 | # e.g.: 13 | # The user should only select his own "location" and "items" 14 | 15 | user = get_request_dict()['user'] # get current user via threading.local() 16 | for formfield in self.fields.values(): 17 | if not hasattr(formfield, 'queryset'): 18 | continue 19 | 20 | queryset = formfield.queryset 21 | opts = queryset.model._meta 22 | try: 23 | opts.get_field('user') 24 | except FieldDoesNotExist: 25 | continue 26 | 27 | formfield.queryset = queryset.filter(user=user) 28 | 29 | def save(self, commit=True): 30 | instance = super().save(commit=False) 31 | if instance.user_id is None: 32 | user = get_request_dict()['user'] # get current user via threading.local() 33 | instance.user_id = user.pk 34 | 35 | instance.save() 36 | return instance 37 | -------------------------------------------------------------------------------- /inventory/locale/ca/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/locale/ca/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /inventory/locale/ca/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-09-30 20:48+0200\n" 11 | "PO-Revision-Date: 2022-09-30 20:07+0200\n" 12 | "Last-Translator: Jaume López\n" 13 | "Language-Team: \n" 14 | "Language: ca\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 2.3\n" 20 | 21 | msgid "Limit tree depth" 22 | msgstr "" 23 | 24 | msgid "Only root" 25 | msgstr "" 26 | 27 | msgid "Root + first sub" 28 | msgstr "" 29 | 30 | msgid "Root + first + second sub" 31 | msgstr "" 32 | 33 | msgid "Related items" 34 | msgstr "" 35 | 36 | msgid "ItemModel.verbose_name" 37 | msgstr "Element" 38 | 39 | msgid "Internals" 40 | msgstr "Interns" 41 | 42 | msgid "Meta" 43 | msgstr "Meta" 44 | 45 | msgid "Basic" 46 | msgstr "Bàsic" 47 | 48 | msgid "Lent" 49 | msgstr "Deixats" 50 | 51 | msgid "Received" 52 | msgstr "Rebuts" 53 | 54 | msgid "Handed over" 55 | msgstr "Donats" 56 | 57 | msgid "ItemModel.verbose_name_plural" 58 | msgstr "Elements" 59 | 60 | msgid "LocationModel.verbose_name" 61 | msgstr "Localització" 62 | 63 | msgid "Items in this Location" 64 | msgstr "" 65 | 66 | msgid "BaseModel.id.verbose_name" 67 | msgstr "ID" 68 | 69 | msgid "BaseModel.id.help_text" 70 | msgstr "ID" 71 | 72 | msgid "BaseModel.user.verbose_name" 73 | msgstr "Usuari" 74 | 75 | msgid "BaseModel.user.help_text" 76 | msgstr "" 77 | "L'usuari propietari d'aquesta entrada i pugui gestionar-la(s'assignarà " 78 | "automàticament)" 79 | 80 | msgid "BaseModel.name.verbose_name" 81 | msgstr "Nom" 82 | 83 | msgid "BaseModel.name.help_text" 84 | msgstr "Nom" 85 | 86 | msgid "BaseModel.tags.verbose_name" 87 | msgstr "Etiquetes" 88 | 89 | msgid "BaseModel.tags.help_text" 90 | msgstr " " 91 | 92 | msgid "LocationModel.parent.verbose_name" 93 | msgstr "Parent" 94 | 95 | msgid "LocationModel.parent.help_text" 96 | msgstr "" 97 | "Las ubicaciones pueden estar anidadas. Ejemplo: La caja 12 en el armario 3" 98 | 99 | msgid "BaseItemAttachmentModel.name.verbose_name" 100 | msgstr "Nom" 101 | 102 | msgid "BaseItemAttachmentModel.name.help_text" 103 | msgstr "" 104 | 105 | msgid "ItemModel.kind.verbose_name" 106 | msgstr "Tipus" 107 | 108 | msgid "ItemModel.kind.help_text" 109 | msgstr " " 110 | 111 | msgid "ItemModel.producer.verbose_name" 112 | msgstr "Fabricant" 113 | 114 | msgid "ItemModel.producer.help_text" 115 | msgstr " " 116 | 117 | msgid "ItemModel.description.verbose_name" 118 | msgstr "Descripció" 119 | 120 | msgid "ItemModel.description.help_text" 121 | msgstr " " 122 | 123 | msgid "ItemModel.fcc_id.verbose_name" 124 | msgstr "FCC ID" 125 | 126 | msgid "ItemModel.fcc_id.help_text" 127 | msgstr "Identificador únic del FCC" 128 | 129 | msgid "ItemModel.location.verbose_name" 130 | msgstr "Localització" 131 | 132 | msgid "ItemModel.location.help_text" 133 | msgstr " " 134 | 135 | msgid "ItemModel.lent_to.verbose_name" 136 | msgstr "Deixat" 137 | 138 | msgid "ItemModel.lent_to.help_text" 139 | msgstr " " 140 | 141 | msgid "ItemModel.lent_from_date.verbose_name" 142 | msgstr "Deixat des de" 143 | 144 | msgid "ItemModel.lent_from_date.help_text" 145 | msgstr " " 146 | 147 | msgid "ItemModel.lent_until_date.verbose_name" 148 | msgstr "Deixat fins el" 149 | 150 | msgid "ItemModel.lent_until_date.help_text" 151 | msgstr " " 152 | 153 | msgid "ItemModel.received_from.verbose_name" 154 | msgstr "Rebut des de" 155 | 156 | msgid "ItemModel.received_from.help_text" 157 | msgstr " " 158 | 159 | msgid "ItemModel.received_date.verbose_name" 160 | msgstr "Rebut el" 161 | 162 | msgid "ItemModel.received_date.help_text" 163 | msgstr " " 164 | 165 | msgid "ItemModel.received_price.verbose_name" 166 | msgstr "Preu d'adquisició" 167 | 168 | msgid "ItemModel.received_price.help_text" 169 | msgstr " " 170 | 171 | msgid "ItemModel.handed_over_to.verbose_name" 172 | msgstr "Donat a" 173 | 174 | msgid "ItemModel.handed_over_to.help_text" 175 | msgstr " " 176 | 177 | msgid "ItemModel.handed_over_date.verbose_name" 178 | msgstr "Donat el" 179 | 180 | msgid "ItemModel.handed_over_date.help_text" 181 | msgstr " " 182 | 183 | msgid "ItemModel.handed_over_price.verbose_name" 184 | msgstr "Preu de cessió" 185 | 186 | msgid "ItemModel.handed_over_price.help_text" 187 | msgstr " " 188 | 189 | msgid "ItemLinkModel.verbose_name" 190 | msgstr "Vincle" 191 | 192 | msgid "ItemLinkModel.verbose_name_plural" 193 | msgstr "Vincles" 194 | 195 | msgid "ItemImageModel.image.verbose_name" 196 | msgstr "Imatge" 197 | 198 | msgid "ItemImageModel.image.help_text" 199 | msgstr " " 200 | 201 | msgid "ItemImageModel.verbose_name" 202 | msgstr "Imatges" 203 | 204 | msgid "ItemImageModel.verbose_name_plural" 205 | msgstr "Imatges" 206 | 207 | msgid "ItemFileModel.file.verbose_name" 208 | msgstr "Arxiu" 209 | 210 | msgid "ItemFileModel.file.help_text" 211 | msgstr " " 212 | 213 | msgid "ItemFileModel.verbose_name" 214 | msgstr "Arxiu" 215 | 216 | msgid "ItemFileModel.verbose_name_plural" 217 | msgstr "Arxius" 218 | 219 | msgid "BaseLink.name.verbose_name" 220 | msgstr "Nom" 221 | 222 | msgid "BaseLink.name.help_text" 223 | msgstr " " 224 | 225 | msgid "Link.url.verbose_name" 226 | msgstr "URL" 227 | 228 | msgid "Link.url.help_text" 229 | msgstr " " 230 | 231 | msgid "Link.status_code.verbose_name" 232 | msgstr "Codi d'estat" 233 | 234 | msgid "Link.status_code.help_text" 235 | msgstr " " 236 | 237 | msgid "Link.page_title.verbose_name" 238 | msgstr "Títol de la pàgina" 239 | 240 | msgid "Link.page_title.help_text" 241 | msgstr " " 242 | 243 | msgid "LocationModel.description.verbose_name" 244 | msgstr "Descripció" 245 | 246 | msgid "LocationModel.description.help_text" 247 | msgstr " " 248 | 249 | msgid "LocationModel.verbose_name_plural" 250 | msgstr "Localitzacions" 251 | 252 | msgid "MemoModel.description.verbose_name" 253 | msgstr "Descripció" 254 | 255 | msgid "MemoModel.description.help_text" 256 | msgstr " " 257 | 258 | msgid "MemoModel.verbose_name" 259 | msgstr "Memo" 260 | 261 | msgid "MemoModel.verbose_name_plural" 262 | msgstr "Memos" 263 | 264 | msgid "MemoLinkModel.verbose_name" 265 | msgstr "Vincle" 266 | 267 | msgid "MemoLinkModel.verbose_name_plural" 268 | msgstr "Vincles" 269 | 270 | msgid "MemoImageModel.image.verbose_name" 271 | msgstr "Imatge" 272 | 273 | msgid "MemoImageModel.image.help_text" 274 | msgstr " " 275 | 276 | msgid "MemoImageModel.verbose_name" 277 | msgstr "Imatge" 278 | 279 | msgid "MemoImageModel.verbose_name_plural" 280 | msgstr "Imatges" 281 | 282 | msgid "MemoFileModel.file.verbose_name" 283 | msgstr "Arxiu" 284 | 285 | msgid "MemoFileModel.file.help_text" 286 | msgstr " " 287 | 288 | msgid "MemoFileModel.verbose_name" 289 | msgstr "Arxiu" 290 | 291 | msgid "MemoFileModel.verbose_name_plural" 292 | msgstr "Arxius" 293 | 294 | #, fuzzy 295 | #| msgid "No" 296 | msgid "No." 297 | msgstr "No" 298 | -------------------------------------------------------------------------------- /inventory/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /inventory/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-09-30 20:48+0200\n" 11 | "PO-Revision-Date: 2022-09-30 20:48+0200\n" 12 | "Last-Translator: Jens Diemer\n" 13 | "Language-Team: \n" 14 | "Language: de\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 2.3\n" 20 | 21 | msgid "Limit tree depth" 22 | msgstr "Tiefe des Baumes" 23 | 24 | msgid "Only root" 25 | msgstr "Nur Stamm" 26 | 27 | msgid "Root + first sub" 28 | msgstr "Stamm + erste Untergruppe" 29 | 30 | msgid "Root + first + second sub" 31 | msgstr "Stamm + erste + zweite Untergruppe" 32 | 33 | msgid "Related items" 34 | msgstr "Zugehörige Gegenstände" 35 | 36 | msgid "ItemModel.verbose_name" 37 | msgstr "Gegenstand" 38 | 39 | msgid "Internals" 40 | msgstr "Intern" 41 | 42 | msgid "Meta" 43 | msgstr "" 44 | 45 | msgid "Basic" 46 | msgstr "Basis" 47 | 48 | msgid "Lent" 49 | msgstr "Verleih" 50 | 51 | msgid "Received" 52 | msgstr "Erhalt" 53 | 54 | msgid "Handed over" 55 | msgstr "Abgabe" 56 | 57 | msgid "ItemModel.verbose_name_plural" 58 | msgstr "Gegenstände" 59 | 60 | msgid "LocationModel.verbose_name" 61 | msgstr "Standort" 62 | 63 | msgid "Items in this Location" 64 | msgstr "Gegenstände an diesem Ort:" 65 | 66 | msgid "BaseModel.id.verbose_name" 67 | msgstr "ID" 68 | 69 | msgid "BaseModel.id.help_text" 70 | msgstr " " 71 | 72 | msgid "BaseModel.user.verbose_name" 73 | msgstr "Benutzer" 74 | 75 | msgid "BaseModel.user.help_text" 76 | msgstr "" 77 | "Der Benutzer dem dieser Eintrag gehört und verwalten kann (Wird automatisch " 78 | "gesetzt)" 79 | 80 | msgid "BaseModel.name.verbose_name" 81 | msgstr "Name" 82 | 83 | msgid "BaseModel.name.help_text" 84 | msgstr " " 85 | 86 | msgid "BaseModel.tags.verbose_name" 87 | msgstr "Tags" 88 | 89 | msgid "BaseModel.tags.help_text" 90 | msgstr " " 91 | 92 | msgid "LocationModel.parent.verbose_name" 93 | msgstr "Übergeordneter Standort" 94 | 95 | msgid "LocationModel.parent.help_text" 96 | msgstr "Standorte können verschachtelt werden. Bsp: Der Karton 12 in Schrank 3" 97 | 98 | msgid "BaseItemAttachmentModel.name.verbose_name" 99 | msgstr "Name" 100 | 101 | msgid "BaseItemAttachmentModel.name.help_text" 102 | msgstr "Optionalen Namen (Wird automatisch aus dem Dateinamen gesetzt)" 103 | 104 | msgid "ItemModel.kind.verbose_name" 105 | msgstr "Art" 106 | 107 | msgid "ItemModel.kind.help_text" 108 | msgstr "Type / Sorte / Gattung" 109 | 110 | msgid "ItemModel.producer.verbose_name" 111 | msgstr "Hersteller" 112 | 113 | msgid "ItemModel.producer.help_text" 114 | msgstr " " 115 | 116 | msgid "ItemModel.description.verbose_name" 117 | msgstr "Beschreibung" 118 | 119 | msgid "ItemModel.description.help_text" 120 | msgstr " " 121 | 122 | msgid "ItemModel.fcc_id.verbose_name" 123 | msgstr "FCC ID" 124 | 125 | msgid "ItemModel.fcc_id.help_text" 126 | msgstr "Eindeutige Nummer der FCC" 127 | 128 | msgid "ItemModel.location.verbose_name" 129 | msgstr "Standort" 130 | 131 | msgid "ItemModel.location.help_text" 132 | msgstr "Wo ist dieser Gegenstand eingelagert?" 133 | 134 | msgid "ItemModel.lent_to.verbose_name" 135 | msgstr "Verliehen an" 136 | 137 | msgid "ItemModel.lent_to.help_text" 138 | msgstr " " 139 | 140 | msgid "ItemModel.lent_from_date.verbose_name" 141 | msgstr "Verleih Abgabe Datum" 142 | 143 | msgid "ItemModel.lent_from_date.help_text" 144 | msgstr "Zeitpunkt ab dem dieser Gegenstand verliehen wurde" 145 | 146 | msgid "ItemModel.lent_until_date.verbose_name" 147 | msgstr "Verliehen bis" 148 | 149 | msgid "ItemModel.lent_until_date.help_text" 150 | msgstr "Bis wann sollte der Gegenstand wieder zurück sein?" 151 | 152 | msgid "ItemModel.received_from.verbose_name" 153 | msgstr "Erhalten von" 154 | 155 | msgid "ItemModel.received_from.help_text" 156 | msgstr "Von wem wurde dieser Gegenstand erhalten?" 157 | 158 | msgid "ItemModel.received_date.verbose_name" 159 | msgstr "Erhalten am" 160 | 161 | msgid "ItemModel.received_date.help_text" 162 | msgstr "Wann wurde dieser Gegenstand erhalten?" 163 | 164 | msgid "ItemModel.received_price.verbose_name" 165 | msgstr "Preis" 166 | 167 | msgid "ItemModel.received_price.help_text" 168 | msgstr "Welcher Preis wurde für diesen Gegenstand gezahlt?" 169 | 170 | msgid "ItemModel.handed_over_to.verbose_name" 171 | msgstr "Abgabe an" 172 | 173 | msgid "ItemModel.handed_over_to.help_text" 174 | msgstr "An wem wurde dieser Gegenstand abgegeben?" 175 | 176 | msgid "ItemModel.handed_over_date.verbose_name" 177 | msgstr "Abgabedatum" 178 | 179 | msgid "ItemModel.handed_over_date.help_text" 180 | msgstr "Zeitpunkt der Abgabe" 181 | 182 | msgid "ItemModel.handed_over_price.verbose_name" 183 | msgstr "Abgabepreis" 184 | 185 | msgid "ItemModel.handed_over_price.help_text" 186 | msgstr "Wurde bei der Abgabe Geld eingenommen?" 187 | 188 | msgid "ItemLinkModel.verbose_name" 189 | msgstr "Link" 190 | 191 | msgid "ItemLinkModel.verbose_name_plural" 192 | msgstr "Links" 193 | 194 | msgid "ItemImageModel.image.verbose_name" 195 | msgstr "Bild" 196 | 197 | msgid "ItemImageModel.image.help_text" 198 | msgstr " " 199 | 200 | msgid "ItemImageModel.verbose_name" 201 | msgstr "Bild" 202 | 203 | msgid "ItemImageModel.verbose_name_plural" 204 | msgstr "Bilder" 205 | 206 | msgid "ItemFileModel.file.verbose_name" 207 | msgstr "Datei" 208 | 209 | msgid "ItemFileModel.file.help_text" 210 | msgstr " " 211 | 212 | msgid "ItemFileModel.verbose_name" 213 | msgstr "Datei" 214 | 215 | msgid "ItemFileModel.verbose_name_plural" 216 | msgstr "Dateien" 217 | 218 | msgid "BaseLink.name.verbose_name" 219 | msgstr "Name" 220 | 221 | msgid "BaseLink.name.help_text" 222 | msgstr " " 223 | 224 | msgid "Link.url.verbose_name" 225 | msgstr "URL" 226 | 227 | msgid "Link.url.help_text" 228 | msgstr " " 229 | 230 | msgid "Link.status_code.verbose_name" 231 | msgstr "Status-Code" 232 | 233 | msgid "Link.status_code.help_text" 234 | msgstr "Der Server meldete diesen Status-Code beim letzten abruf zurück." 235 | 236 | msgid "Link.page_title.verbose_name" 237 | msgstr "Seitentitel" 238 | 239 | msgid "Link.page_title.help_text" 240 | msgstr "Der Seiten-Titel wird automatisch ermittelt." 241 | 242 | msgid "LocationModel.description.verbose_name" 243 | msgstr "Beschreibung" 244 | 245 | msgid "LocationModel.description.help_text" 246 | msgstr " " 247 | 248 | msgid "LocationModel.verbose_name_plural" 249 | msgstr "Standorte" 250 | 251 | msgid "MemoModel.description.verbose_name" 252 | msgstr "Beschreibung" 253 | 254 | msgid "MemoModel.description.help_text" 255 | msgstr " " 256 | 257 | msgid "MemoModel.verbose_name" 258 | msgstr "Memo" 259 | 260 | msgid "MemoModel.verbose_name_plural" 261 | msgstr "Memos" 262 | 263 | msgid "MemoLinkModel.verbose_name" 264 | msgstr "Link" 265 | 266 | msgid "MemoLinkModel.verbose_name_plural" 267 | msgstr "Links" 268 | 269 | msgid "MemoImageModel.image.verbose_name" 270 | msgstr "Bild" 271 | 272 | msgid "MemoImageModel.image.help_text" 273 | msgstr " " 274 | 275 | msgid "MemoImageModel.verbose_name" 276 | msgstr "Bild" 277 | 278 | msgid "MemoImageModel.verbose_name_plural" 279 | msgstr "Bilder" 280 | 281 | msgid "MemoFileModel.file.verbose_name" 282 | msgstr "Datei" 283 | 284 | msgid "MemoFileModel.file.help_text" 285 | msgstr " " 286 | 287 | msgid "MemoFileModel.verbose_name" 288 | msgstr "Datei" 289 | 290 | msgid "MemoFileModel.verbose_name_plural" 291 | msgstr "Dateien" 292 | 293 | msgid "No." 294 | msgstr "Nr." 295 | -------------------------------------------------------------------------------- /inventory/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /inventory/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-09-30 20:48+0200\n" 11 | "PO-Revision-Date: 2021-10-09 19:36+0200\n" 12 | "Last-Translator: Jens Diemer\n" 13 | "Language-Team: \n" 14 | "Language: en\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 2.3\n" 20 | 21 | msgid "Limit tree depth" 22 | msgstr "" 23 | 24 | msgid "Only root" 25 | msgstr "" 26 | 27 | msgid "Root + first sub" 28 | msgstr "" 29 | 30 | msgid "Root + first + second sub" 31 | msgstr "" 32 | 33 | msgid "Related items" 34 | msgstr "" 35 | 36 | msgid "ItemModel.verbose_name" 37 | msgstr "Item" 38 | 39 | msgid "Internals" 40 | msgstr "" 41 | 42 | msgid "Meta" 43 | msgstr "" 44 | 45 | msgid "Basic" 46 | msgstr "" 47 | 48 | msgid "Lent" 49 | msgstr "" 50 | 51 | msgid "Received" 52 | msgstr "" 53 | 54 | msgid "Handed over" 55 | msgstr "" 56 | 57 | msgid "ItemModel.verbose_name_plural" 58 | msgstr "Items" 59 | 60 | msgid "LocationModel.verbose_name" 61 | msgstr "Location" 62 | 63 | msgid "Items in this Location" 64 | msgstr "" 65 | 66 | msgid "BaseModel.id.verbose_name" 67 | msgstr "ID" 68 | 69 | msgid "BaseModel.id.help_text" 70 | msgstr "ID" 71 | 72 | msgid "BaseModel.user.verbose_name" 73 | msgstr "User" 74 | 75 | msgid "BaseModel.user.help_text" 76 | msgstr "" 77 | "The user who is the owner of this entry and can manage it (will be set " 78 | "automatically)" 79 | 80 | msgid "BaseModel.name.verbose_name" 81 | msgstr "Name" 82 | 83 | msgid "BaseModel.name.help_text" 84 | msgstr "Name" 85 | 86 | msgid "BaseModel.tags.verbose_name" 87 | msgstr "Tags" 88 | 89 | msgid "BaseModel.tags.help_text" 90 | msgstr " " 91 | 92 | msgid "LocationModel.parent.verbose_name" 93 | msgstr "Parent" 94 | 95 | msgid "LocationModel.parent.help_text" 96 | msgstr "Locations can be nested. Example: The box 12 in cupboard 3" 97 | 98 | msgid "BaseItemAttachmentModel.name.verbose_name" 99 | msgstr "Name" 100 | 101 | msgid "BaseItemAttachmentModel.name.help_text" 102 | msgstr "" 103 | 104 | msgid "ItemModel.kind.verbose_name" 105 | msgstr "Kind" 106 | 107 | msgid "ItemModel.kind.help_text" 108 | msgstr " " 109 | 110 | msgid "ItemModel.producer.verbose_name" 111 | msgstr "Producer" 112 | 113 | msgid "ItemModel.producer.help_text" 114 | msgstr " " 115 | 116 | msgid "ItemModel.description.verbose_name" 117 | msgstr "Description" 118 | 119 | msgid "ItemModel.description.help_text" 120 | msgstr " " 121 | 122 | msgid "ItemModel.fcc_id.verbose_name" 123 | msgstr "FCC ID" 124 | 125 | msgid "ItemModel.fcc_id.help_text" 126 | msgstr "Unique number from the FCC" 127 | 128 | msgid "ItemModel.location.verbose_name" 129 | msgstr "Location" 130 | 131 | msgid "ItemModel.location.help_text" 132 | msgstr " " 133 | 134 | msgid "ItemModel.lent_to.verbose_name" 135 | msgstr "Lent to" 136 | 137 | msgid "ItemModel.lent_to.help_text" 138 | msgstr " " 139 | 140 | msgid "ItemModel.lent_from_date.verbose_name" 141 | msgstr "Lent from date" 142 | 143 | msgid "ItemModel.lent_from_date.help_text" 144 | msgstr " " 145 | 146 | msgid "ItemModel.lent_until_date.verbose_name" 147 | msgstr "Lent until date" 148 | 149 | msgid "ItemModel.lent_until_date.help_text" 150 | msgstr " " 151 | 152 | msgid "ItemModel.received_from.verbose_name" 153 | msgstr "Received from" 154 | 155 | msgid "ItemModel.received_from.help_text" 156 | msgstr " " 157 | 158 | msgid "ItemModel.received_date.verbose_name" 159 | msgstr "Received date" 160 | 161 | msgid "ItemModel.received_date.help_text" 162 | msgstr " " 163 | 164 | msgid "ItemModel.received_price.verbose_name" 165 | msgstr "Received price" 166 | 167 | msgid "ItemModel.received_price.help_text" 168 | msgstr " " 169 | 170 | msgid "ItemModel.handed_over_to.verbose_name" 171 | msgstr "Handed over to" 172 | 173 | msgid "ItemModel.handed_over_to.help_text" 174 | msgstr " " 175 | 176 | msgid "ItemModel.handed_over_date.verbose_name" 177 | msgstr "Handed over date" 178 | 179 | msgid "ItemModel.handed_over_date.help_text" 180 | msgstr " " 181 | 182 | msgid "ItemModel.handed_over_price.verbose_name" 183 | msgstr "Handed over price" 184 | 185 | msgid "ItemModel.handed_over_price.help_text" 186 | msgstr " " 187 | 188 | msgid "ItemLinkModel.verbose_name" 189 | msgstr "Link" 190 | 191 | msgid "ItemLinkModel.verbose_name_plural" 192 | msgstr "Links" 193 | 194 | msgid "ItemImageModel.image.verbose_name" 195 | msgstr "Image" 196 | 197 | msgid "ItemImageModel.image.help_text" 198 | msgstr " " 199 | 200 | msgid "ItemImageModel.verbose_name" 201 | msgstr "Image" 202 | 203 | msgid "ItemImageModel.verbose_name_plural" 204 | msgstr "Images" 205 | 206 | msgid "ItemFileModel.file.verbose_name" 207 | msgstr "File" 208 | 209 | msgid "ItemFileModel.file.help_text" 210 | msgstr " " 211 | 212 | msgid "ItemFileModel.verbose_name" 213 | msgstr "File" 214 | 215 | msgid "ItemFileModel.verbose_name_plural" 216 | msgstr "Files" 217 | 218 | msgid "BaseLink.name.verbose_name" 219 | msgstr "Name" 220 | 221 | msgid "BaseLink.name.help_text" 222 | msgstr " " 223 | 224 | msgid "Link.url.verbose_name" 225 | msgstr "URL" 226 | 227 | msgid "Link.url.help_text" 228 | msgstr " " 229 | 230 | msgid "Link.status_code.verbose_name" 231 | msgstr "Status code" 232 | 233 | msgid "Link.status_code.help_text" 234 | msgstr " " 235 | 236 | msgid "Link.page_title.verbose_name" 237 | msgstr "Page title" 238 | 239 | msgid "Link.page_title.help_text" 240 | msgstr " " 241 | 242 | msgid "LocationModel.description.verbose_name" 243 | msgstr "Description" 244 | 245 | msgid "LocationModel.description.help_text" 246 | msgstr " " 247 | 248 | msgid "LocationModel.verbose_name_plural" 249 | msgstr "Locations" 250 | 251 | msgid "MemoModel.description.verbose_name" 252 | msgstr "Description" 253 | 254 | msgid "MemoModel.description.help_text" 255 | msgstr " " 256 | 257 | msgid "MemoModel.verbose_name" 258 | msgstr "Memo" 259 | 260 | msgid "MemoModel.verbose_name_plural" 261 | msgstr "Memos" 262 | 263 | msgid "MemoLinkModel.verbose_name" 264 | msgstr "Link" 265 | 266 | msgid "MemoLinkModel.verbose_name_plural" 267 | msgstr "Links" 268 | 269 | msgid "MemoImageModel.image.verbose_name" 270 | msgstr "Image" 271 | 272 | msgid "MemoImageModel.image.help_text" 273 | msgstr " " 274 | 275 | msgid "MemoImageModel.verbose_name" 276 | msgstr "Image" 277 | 278 | msgid "MemoImageModel.verbose_name_plural" 279 | msgstr "Images" 280 | 281 | msgid "MemoFileModel.file.verbose_name" 282 | msgstr "File" 283 | 284 | msgid "MemoFileModel.file.help_text" 285 | msgstr " " 286 | 287 | msgid "MemoFileModel.verbose_name" 288 | msgstr "File" 289 | 290 | msgid "MemoFileModel.verbose_name_plural" 291 | msgstr "Files" 292 | 293 | msgid "No." 294 | msgstr "" 295 | -------------------------------------------------------------------------------- /inventory/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /inventory/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-09-30 20:48+0200\n" 11 | "PO-Revision-Date: 2021-10-09 19:36+0200\n" 12 | "Last-Translator: Jaume López\n" 13 | "Language-Team: \n" 14 | "Language: es\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 2.3\n" 20 | 21 | msgid "Limit tree depth" 22 | msgstr "" 23 | 24 | msgid "Only root" 25 | msgstr "" 26 | 27 | msgid "Root + first sub" 28 | msgstr "" 29 | 30 | msgid "Root + first + second sub" 31 | msgstr "" 32 | 33 | msgid "Related items" 34 | msgstr "" 35 | 36 | msgid "ItemModel.verbose_name" 37 | msgstr "Elemento" 38 | 39 | msgid "Internals" 40 | msgstr "Internos" 41 | 42 | msgid "Meta" 43 | msgstr "Meta" 44 | 45 | msgid "Basic" 46 | msgstr "Básico" 47 | 48 | msgid "Lent" 49 | msgstr "Dejados" 50 | 51 | msgid "Received" 52 | msgstr "Recibidos" 53 | 54 | msgid "Handed over" 55 | msgstr "Cedidos" 56 | 57 | msgid "ItemModel.verbose_name_plural" 58 | msgstr "Elementos" 59 | 60 | msgid "LocationModel.verbose_name" 61 | msgstr "Localización" 62 | 63 | msgid "Items in this Location" 64 | msgstr "" 65 | 66 | msgid "BaseModel.id.verbose_name" 67 | msgstr "ID" 68 | 69 | msgid "BaseModel.id.help_text" 70 | msgstr "ID" 71 | 72 | msgid "BaseModel.user.verbose_name" 73 | msgstr "Usuario" 74 | 75 | msgid "BaseModel.user.help_text" 76 | msgstr "" 77 | "El usuario propietario de esta entrada y pueda gestionarla(se asignará " 78 | "automáticamente)" 79 | 80 | msgid "BaseModel.name.verbose_name" 81 | msgstr "Nombre" 82 | 83 | msgid "BaseModel.name.help_text" 84 | msgstr "Nombre" 85 | 86 | msgid "BaseModel.tags.verbose_name" 87 | msgstr "Etiquetas" 88 | 89 | msgid "BaseModel.tags.help_text" 90 | msgstr " " 91 | 92 | msgid "LocationModel.parent.verbose_name" 93 | msgstr "Pariente" 94 | 95 | msgid "LocationModel.parent.help_text" 96 | msgstr "" 97 | "Las localizaciones pueden ser sumadas.Exemple: La caja 12 del estante 3" 98 | 99 | msgid "BaseItemAttachmentModel.name.verbose_name" 100 | msgstr "Nombre" 101 | 102 | msgid "BaseItemAttachmentModel.name.help_text" 103 | msgstr "" 104 | 105 | msgid "ItemModel.kind.verbose_name" 106 | msgstr "Tipos" 107 | 108 | msgid "ItemModel.kind.help_text" 109 | msgstr " " 110 | 111 | msgid "ItemModel.producer.verbose_name" 112 | msgstr "Fabricante" 113 | 114 | msgid "ItemModel.producer.help_text" 115 | msgstr " " 116 | 117 | msgid "ItemModel.description.verbose_name" 118 | msgstr "Descripción" 119 | 120 | msgid "ItemModel.description.help_text" 121 | msgstr " " 122 | 123 | msgid "ItemModel.fcc_id.verbose_name" 124 | msgstr "FCC ID" 125 | 126 | msgid "ItemModel.fcc_id.help_text" 127 | msgstr "Identificador único del FCC" 128 | 129 | msgid "ItemModel.location.verbose_name" 130 | msgstr "Localización" 131 | 132 | msgid "ItemModel.location.help_text" 133 | msgstr " " 134 | 135 | msgid "ItemModel.lent_to.verbose_name" 136 | msgstr "Dejado" 137 | 138 | msgid "ItemModel.lent_to.help_text" 139 | msgstr " " 140 | 141 | msgid "ItemModel.lent_from_date.verbose_name" 142 | msgstr "Dejado desde" 143 | 144 | msgid "ItemModel.lent_from_date.help_text" 145 | msgstr " " 146 | 147 | msgid "ItemModel.lent_until_date.verbose_name" 148 | msgstr "Dejado hasta el" 149 | 150 | msgid "ItemModel.lent_until_date.help_text" 151 | msgstr " " 152 | 153 | msgid "ItemModel.received_from.verbose_name" 154 | msgstr "Recibido desde el" 155 | 156 | msgid "ItemModel.received_from.help_text" 157 | msgstr " " 158 | 159 | msgid "ItemModel.received_date.verbose_name" 160 | msgstr "Recibido el" 161 | 162 | msgid "ItemModel.received_date.help_text" 163 | msgstr " " 164 | 165 | msgid "ItemModel.received_price.verbose_name" 166 | msgstr "Precio de adquisición" 167 | 168 | msgid "ItemModel.received_price.help_text" 169 | msgstr " " 170 | 171 | msgid "ItemModel.handed_over_to.verbose_name" 172 | msgstr "Cedido a" 173 | 174 | msgid "ItemModel.handed_over_to.help_text" 175 | msgstr " " 176 | 177 | msgid "ItemModel.handed_over_date.verbose_name" 178 | msgstr "Cedido el" 179 | 180 | msgid "ItemModel.handed_over_date.help_text" 181 | msgstr " " 182 | 183 | msgid "ItemModel.handed_over_price.verbose_name" 184 | msgstr "Precio de cesión" 185 | 186 | msgid "ItemModel.handed_over_price.help_text" 187 | msgstr " " 188 | 189 | msgid "ItemLinkModel.verbose_name" 190 | msgstr "Vínculo" 191 | 192 | msgid "ItemLinkModel.verbose_name_plural" 193 | msgstr "Vínculos" 194 | 195 | msgid "ItemImageModel.image.verbose_name" 196 | msgstr "Imagen" 197 | 198 | msgid "ItemImageModel.image.help_text" 199 | msgstr " " 200 | 201 | msgid "ItemImageModel.verbose_name" 202 | msgstr "Imágenes" 203 | 204 | msgid "ItemImageModel.verbose_name_plural" 205 | msgstr "Imágenes" 206 | 207 | msgid "ItemFileModel.file.verbose_name" 208 | msgstr "Archivo" 209 | 210 | msgid "ItemFileModel.file.help_text" 211 | msgstr " " 212 | 213 | msgid "ItemFileModel.verbose_name" 214 | msgstr "Archivo" 215 | 216 | msgid "ItemFileModel.verbose_name_plural" 217 | msgstr "Archivos" 218 | 219 | msgid "BaseLink.name.verbose_name" 220 | msgstr "Nombre" 221 | 222 | msgid "BaseLink.name.help_text" 223 | msgstr " " 224 | 225 | msgid "Link.url.verbose_name" 226 | msgstr "URL" 227 | 228 | msgid "Link.url.help_text" 229 | msgstr " " 230 | 231 | msgid "Link.status_code.verbose_name" 232 | msgstr "Código de estado" 233 | 234 | msgid "Link.status_code.help_text" 235 | msgstr " " 236 | 237 | msgid "Link.page_title.verbose_name" 238 | msgstr "Título de la página" 239 | 240 | msgid "Link.page_title.help_text" 241 | msgstr " " 242 | 243 | msgid "LocationModel.description.verbose_name" 244 | msgstr "Descripción" 245 | 246 | msgid "LocationModel.description.help_text" 247 | msgstr " " 248 | 249 | msgid "LocationModel.verbose_name_plural" 250 | msgstr "Localitzaciones" 251 | 252 | msgid "MemoModel.description.verbose_name" 253 | msgstr "Descripción" 254 | 255 | msgid "MemoModel.description.help_text" 256 | msgstr " " 257 | 258 | msgid "MemoModel.verbose_name" 259 | msgstr "Memo" 260 | 261 | msgid "MemoModel.verbose_name_plural" 262 | msgstr "Memos" 263 | 264 | msgid "MemoLinkModel.verbose_name" 265 | msgstr "Vínculo" 266 | 267 | msgid "MemoLinkModel.verbose_name_plural" 268 | msgstr "Vínculos" 269 | 270 | msgid "MemoImageModel.image.verbose_name" 271 | msgstr "Imagen" 272 | 273 | msgid "MemoImageModel.image.help_text" 274 | msgstr " " 275 | 276 | msgid "MemoImageModel.verbose_name" 277 | msgstr "Imagen" 278 | 279 | msgid "MemoImageModel.verbose_name_plural" 280 | msgstr "Imágenes" 281 | 282 | msgid "MemoFileModel.file.verbose_name" 283 | msgstr "Archivo" 284 | 285 | msgid "MemoFileModel.file.help_text" 286 | msgstr " " 287 | 288 | msgid "MemoFileModel.verbose_name" 289 | msgstr "Archivo" 290 | 291 | msgid "MemoFileModel.verbose_name_plural" 292 | msgstr "Archivos" 293 | 294 | #, fuzzy 295 | #| msgid "No" 296 | msgid "No." 297 | msgstr "No" 298 | -------------------------------------------------------------------------------- /inventory/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/management/__init__.py -------------------------------------------------------------------------------- /inventory/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/management/commands/__init__.py -------------------------------------------------------------------------------- /inventory/management/commands/seed_data.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import logging 3 | from collections import Counter 4 | 5 | from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX 6 | from django.contrib.auth.models import User 7 | from django.core.management.base import BaseCommand 8 | 9 | from inventory.models import ItemModel, LocationModel 10 | 11 | 12 | SEED_DATA_USER_PREFIX = 'seed-data-user-' 13 | 14 | 15 | class SetupLogger: 16 | def __init__(self, level): 17 | self.level = level 18 | 19 | def __enter__(self): 20 | self.old_level = logging.root.manager.disable 21 | logging.disable(self.level) 22 | 23 | def __exit__(self, exit_type, exit_value, exit_traceback): 24 | logging.disable(self.old_level) 25 | 26 | 27 | def iter_location_chain(user, location_count): 28 | room_no = itertools.count(start=1) 29 | cupboard_no = itertools.count(start=1) 30 | drawer_no = itertools.count(start=1) 31 | 32 | location_no = 0 33 | while True: 34 | room = LocationModel.objects.create(user=user, name=f'Room {next(room_no)}') 35 | room.full_clean() 36 | for _ in range(2): 37 | cupboard = LocationModel.objects.create(user=user, name=f'Cupboard {next(cupboard_no)}', parent=room) 38 | cupboard.full_clean() 39 | for _ in range(2): 40 | drawer = LocationModel.objects.create(user=user, name=f'Drawer {next(drawer_no)}', parent=cupboard) 41 | drawer.full_clean() 42 | yield drawer 43 | location_no += 1 44 | if location_no >= location_count: 45 | return 46 | 47 | 48 | class ItemCreator: 49 | def __init__(self): 50 | self.equipment_no = itertools.count(start=1) 51 | self.item_no = itertools.count(start=1) 52 | self.part_no = itertools.count(start=1) 53 | 54 | self.part_per_location = Counter() 55 | 56 | def create_items(self, user, location, item_count): 57 | assert user 58 | assert location 59 | while True: 60 | equipment = ItemModel.objects.create( 61 | user=user, 62 | location=location, 63 | name=f'Equipment {next(self.equipment_no):03}', 64 | ) 65 | equipment.full_clean() 66 | yield equipment 67 | 68 | while True: 69 | item = ItemModel.objects.create( 70 | user=user, 71 | location=location, 72 | name=f'Item {next(self.item_no):03}', 73 | parent=equipment, 74 | ) 75 | item.full_clean() 76 | yield item 77 | 78 | while True: 79 | part = ItemModel.objects.create( 80 | user=user, 81 | location=location, 82 | name=f'Part {next(self.part_no):03}', 83 | parent=item, 84 | ) 85 | part.full_clean() 86 | yield part 87 | self.part_per_location[location] += 1 88 | if self.part_per_location[location] >= item_count: 89 | return 90 | 91 | 92 | class Command(BaseCommand): 93 | help = 'Fill database with example data' 94 | 95 | def add_arguments(self, parser): 96 | parser.add_argument('--user-count', type=int, default=3, choices=range(1, 10), help='User count') 97 | parser.add_argument('--location-count', type=int, default=3, choices=range(1, 20), help='Location count') 98 | parser.add_argument('--item-count', type=int, default=4, choices=range(1, 40), help='Item count') 99 | 100 | def handle(self, **options): 101 | self.stdout.write(self.help) 102 | 103 | user_count = options['user_count'] 104 | location_count = options['location_count'] 105 | item_count = options['item_count'] 106 | 107 | verbosity = options['verbosity'] 108 | if verbosity > 2: 109 | log_level = logging.DEBUG 110 | else: 111 | log_level = logging.WARNING 112 | 113 | with SetupLogger(level=log_level): 114 | existing_users = User.objects.filter(username__startswith=SEED_DATA_USER_PREFIX) 115 | for user in existing_users: 116 | self.stdout.write(f'Clean data from user {user}...') 117 | info = user.delete() 118 | self.stdout.write(f'done: {info}') 119 | 120 | item_creator = ItemCreator() 121 | 122 | for user_no in range(1, user_count + 1): 123 | self.stdout.write('_' * 100) 124 | user = User.objects.create_user( 125 | username=f'{SEED_DATA_USER_PREFIX}{user_no}', 126 | email=f'{SEED_DATA_USER_PREFIX}{user_no}@test.tld', 127 | password=f'{UNUSABLE_PASSWORD_PREFIX} no password', 128 | ) 129 | self.stdout.write(f'Create seed data for user {user}') 130 | 131 | for location in iter_location_chain(user, location_count): 132 | for item in item_creator.create_items(user, location, item_count): 133 | self.stdout.write(f'{location} | {item}') 134 | 135 | self.stdout.write('\nSeed data created.') 136 | -------------------------------------------------------------------------------- /inventory/management/commands/tree.py: -------------------------------------------------------------------------------- 1 | import time 2 | from argparse import OPTIONAL 3 | 4 | from django.apps import apps 5 | from django.core.management.base import BaseCommand 6 | 7 | 8 | class PrintDuration: 9 | def __init__(self, stdout): 10 | self.stdout = stdout 11 | 12 | def __enter__(self): 13 | self.start_time = time.monotonic() 14 | 15 | def __exit__(self, exc_type, exc_val, exc_tb): 16 | duration = (time.monotonic() - self.start_time) * 1000 17 | self.stdout.write(f'(Done in: {duration:.1f}ms)') 18 | 19 | 20 | class Command(BaseCommand): 21 | help = 'Repair tree information' 22 | 23 | def add_arguments(self, parser): 24 | parser.add_argument( 25 | 'model_name', 26 | metavar='model_name', 27 | nargs=OPTIONAL, 28 | default='itemmodel', 29 | choices=['itemmodel', 'locationmodel'], 30 | help='Model Name (default: "%(default)s")', 31 | ) 32 | 33 | def handle(self, *args, **options): 34 | self.stdout.write() 35 | self.stdout.write('=' * 100) 36 | self.stdout.write(self.help) 37 | self.stdout.write('-' * 100) 38 | 39 | model_name = options['model_name'] 40 | ModelClass = apps.get_model(app_label='inventory', model_name=model_name) 41 | 42 | self.print_info(ModelClass, text='Old information about model:') 43 | 44 | self.stdout.write('_' * 100) 45 | self.stdout.write(f'Clean tree information on model: {ModelClass._meta.verbose_name!r}') 46 | with PrintDuration(self.stdout): 47 | ModelClass.objects.update(path=None, path_str=None, level=None) 48 | 49 | self.stdout.write('_' * 100) 50 | self.stdout.write(f'Repair tree model: {ModelClass._meta.verbose_name!r}') 51 | with PrintDuration(self.stdout): 52 | ModelClass.tree_objects.update_tree_info() 53 | 54 | self.print_info(ModelClass, text='New information about model:') 55 | 56 | def print_info(self, ModelClass, text): 57 | self.stdout.write('_' * 100) 58 | self.stdout.write(f'{text} {ModelClass._meta.verbose_name!r}') 59 | with PrintDuration(self.stdout): 60 | data = ModelClass.objects.values('level', 'path_str', 'path', 'name') 61 | for entry in data: 62 | self.stdout.write(repr(entry)) 63 | -------------------------------------------------------------------------------- /inventory/middlewares.py: -------------------------------------------------------------------------------- 1 | from inventory.request_dict import clear_request_dict, get_request_dict 2 | 3 | 4 | class RequestDictMiddleware: 5 | """ 6 | Make the "current user" information available everywhere via threading.local() 7 | Access e.g.: 8 | user = get_request_dict()['user'] 9 | """ 10 | 11 | def __init__(self, get_response): 12 | self.get_response = get_response 13 | 14 | def __call__(self, request): 15 | get_request_dict().update(user=request.user) 16 | 17 | response = self.get_response(request) 18 | 19 | clear_request_dict() 20 | 21 | return response 22 | -------------------------------------------------------------------------------- /inventory/migrations/0002_auto_20201017_2211.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.16 on 2020-10-17 20:11 2 | 3 | import tagulous.models.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('inventory', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='itemlinkmodel', 15 | name='tags', 16 | field=tagulous.models.fields.TagField( 17 | _set_tag_meta=True, 18 | blank=True, 19 | case_sensitive=False, 20 | force_lowercase=False, 21 | help_text='BaseModel.tags.help_text', 22 | max_count=10, 23 | space_delimiter=False, 24 | to='inventory.Tagulous_ItemLinkModel_tags', 25 | verbose_name='BaseModel.tags.verbose_name', 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name='itemmodel', 30 | name='kind', 31 | field=tagulous.models.fields.TagField( 32 | _set_tag_meta=True, 33 | case_sensitive=False, 34 | force_lowercase=False, 35 | help_text='ItemModel.kind.help_text', 36 | max_count=3, 37 | space_delimiter=False, 38 | to='inventory.Tagulous_ItemModel_kind', 39 | verbose_name='ItemModel.kind.verbose_name', 40 | ), 41 | ), 42 | migrations.AlterField( 43 | model_name='itemmodel', 44 | name='producer', 45 | field=tagulous.models.fields.TagField( 46 | _set_tag_meta=True, 47 | blank=True, 48 | case_sensitive=False, 49 | force_lowercase=False, 50 | help_text='ItemModel.producer.help_text', 51 | max_count=1, 52 | space_delimiter=False, 53 | to='inventory.Tagulous_ItemModel_producer', 54 | verbose_name='ItemModel.producer.verbose_name', 55 | ), 56 | ), 57 | migrations.AlterField( 58 | model_name='itemmodel', 59 | name='tags', 60 | field=tagulous.models.fields.TagField( 61 | _set_tag_meta=True, 62 | blank=True, 63 | case_sensitive=False, 64 | force_lowercase=False, 65 | help_text='BaseModel.tags.help_text', 66 | max_count=10, 67 | space_delimiter=False, 68 | to='inventory.Tagulous_ItemModel_tags', 69 | verbose_name='BaseModel.tags.verbose_name', 70 | ), 71 | ), 72 | migrations.AlterField( 73 | model_name='locationmodel', 74 | name='tags', 75 | field=tagulous.models.fields.TagField( 76 | _set_tag_meta=True, 77 | blank=True, 78 | case_sensitive=False, 79 | force_lowercase=False, 80 | help_text='BaseModel.tags.help_text', 81 | max_count=10, 82 | space_delimiter=False, 83 | to='inventory.Tagulous_LocationModel_tags', 84 | verbose_name='BaseModel.tags.verbose_name', 85 | ), 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /inventory/migrations/0003_auto_20201024_1830.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.16 on 2020-10-24 16:30 2 | 3 | 4 | from django.db import migrations 5 | from django.db.models import TextField 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ('inventory', '0002_auto_20201017_2211'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='locationmodel', 16 | name='description', 17 | field=TextField( 18 | blank=True, 19 | help_text='LocationModel.description.help_text', 20 | null=True, 21 | verbose_name='LocationModel.description.verbose_name', 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /inventory/migrations/0004_item_user_images.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2020-11-15 11:09 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | import tagulous.models.fields 7 | import tagulous.models.models 8 | from django.conf import settings 9 | from django.db import migrations, models 10 | 11 | import inventory.models.item 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ('inventory', '0003_auto_20201024_1830'), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='Tagulous_ItemImageModel_tags', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('name', models.CharField(max_length=255, unique=True)), 26 | ('slug', models.SlugField()), 27 | ( 28 | 'count', 29 | models.IntegerField(default=0, help_text='Internal counter of how many times this tag is in use'), 30 | ), 31 | ( 32 | 'protected', 33 | models.BooleanField(default=False, help_text='Will not be deleted when the count reaches 0'), 34 | ), 35 | ], 36 | options={ 37 | 'ordering': ('name',), 38 | 'abstract': False, 39 | 'unique_together': {('slug',)}, 40 | }, 41 | bases=(tagulous.models.models.BaseTagModel, models.Model), 42 | ), 43 | migrations.CreateModel( 44 | name='ItemImageModel', 45 | fields=[ 46 | ( 47 | 'create_dt', 48 | models.DateTimeField( 49 | blank=True, 50 | editable=False, 51 | help_text='ModelTimetrackingMixin.create_dt.help_text', 52 | null=True, 53 | verbose_name='ModelTimetrackingMixin.create_dt.verbose_name', 54 | ), 55 | ), 56 | ( 57 | 'update_dt', 58 | models.DateTimeField( 59 | blank=True, 60 | editable=False, 61 | help_text='ModelTimetrackingMixin.update_dt.help_text', 62 | null=True, 63 | verbose_name='ModelTimetrackingMixin.update_dt.verbose_name', 64 | ), 65 | ), 66 | ( 67 | 'id', 68 | models.UUIDField( 69 | default=uuid.uuid4, 70 | editable=False, 71 | help_text='BaseModel.id.help_text', 72 | primary_key=True, 73 | serialize=False, 74 | verbose_name='BaseModel.id.verbose_name', 75 | ), 76 | ), 77 | ( 78 | 'image', 79 | models.ImageField( 80 | help_text='ItemImageModel.image.help_text', 81 | upload_to=inventory.models.item.user_directory_path, 82 | verbose_name='ItemImageModel.image.verbose_name', 83 | ), 84 | ), 85 | ( 86 | 'name', 87 | models.CharField( 88 | blank=True, 89 | help_text='ItemImageModel.name.help_text', 90 | max_length=255, 91 | null=True, 92 | verbose_name='ItemImageModel.name.verbose_name', 93 | ), 94 | ), 95 | ('position', models.PositiveSmallIntegerField(default=0)), 96 | ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.ItemModel')), 97 | ( 98 | 'tags', 99 | tagulous.models.fields.TagField( 100 | _set_tag_meta=True, 101 | blank=True, 102 | case_sensitive=False, 103 | force_lowercase=False, 104 | help_text='BaseModel.tags.help_text', 105 | max_count=10, 106 | space_delimiter=False, 107 | to='inventory.Tagulous_ItemImageModel_tags', 108 | verbose_name='BaseModel.tags.verbose_name', 109 | ), 110 | ), 111 | ( 112 | 'user', 113 | models.ForeignKey( 114 | editable=False, 115 | help_text='BaseModel.user.help_text', 116 | on_delete=django.db.models.deletion.CASCADE, 117 | related_name='+', 118 | to=settings.AUTH_USER_MODEL, 119 | verbose_name='BaseModel.user.verbose_name', 120 | ), 121 | ), 122 | ], 123 | options={ 124 | 'verbose_name': 'ItemImageModel.verbose_name', 125 | 'verbose_name_plural': 'ItemImageModel.verbose_name_plural', 126 | 'ordering': ('position',), 127 | }, 128 | ), 129 | ] 130 | -------------------------------------------------------------------------------- /inventory/migrations/0005_serve_uploads_by_django_tools.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2020-12-06 14:07 2 | import os 3 | from pathlib import Path 4 | 5 | from django.conf import settings 6 | from django.db import migrations 7 | from django.utils import timezone 8 | 9 | 10 | class Tee: 11 | def __init__(self, f): 12 | self.f = f 13 | 14 | def __enter__(self): 15 | return self 16 | 17 | def __call__(self, line): 18 | if not isinstance(line, str): 19 | line = str(line) 20 | print(line) 21 | self.f.write(line) 22 | self.f.write('\n') 23 | 24 | def __exit__(self, exc_type, exc_val, exc_tb): 25 | pass 26 | 27 | 28 | def forward_code(apps, schema_editor): 29 | ItemImageModel = apps.get_model('inventory', 'ItemImageModel') 30 | if ItemImageModel.objects.count() == 0: 31 | # No image uploaded, yet -> nothing to do 32 | return 33 | 34 | print() 35 | log_file_path = Path(settings.MEDIA_ROOT, 'migrate.log') 36 | print('Generate log file here:', log_file_path) 37 | with log_file_path.open('w+') as log, Tee(log) as log: 38 | log('-' * 100) 39 | log(timezone.now()) 40 | 41 | from django_tools.serve_media_app.models import generate_media_path 42 | 43 | qs = ItemImageModel.objects.all() 44 | 45 | for instance in qs: 46 | log('_' * 100) 47 | log(f'Migrate {instance}') 48 | user = instance.user 49 | image = instance.image 50 | 51 | file_path = Path(str(image.file)) 52 | log(f'Old path: {file_path}') 53 | 54 | media_path = generate_media_path(user, filename=file_path.name) 55 | 56 | new_file_path = Path(settings.MEDIA_ROOT, media_path) 57 | log(f'New path: {new_file_path}') 58 | 59 | os.makedirs(new_file_path.parent, exist_ok=True) 60 | os.link(file_path, new_file_path) 61 | 62 | instance.image = media_path 63 | instance.save(update_fields=('image',)) 64 | 65 | log('All new path created via hardlinks!') 66 | log('Old path can be deleted.') 67 | 68 | 69 | class Migration(migrations.Migration): 70 | dependencies = [ 71 | ('inventory', '0004_item_user_images'), 72 | ('serve_media_app', '0001_initial'), 73 | ] 74 | 75 | operations = [ 76 | migrations.RunPython(forward_code, reverse_code=migrations.RunPython.noop), 77 | ] 78 | -------------------------------------------------------------------------------- /inventory/migrations/0006_refactor_image_model.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.20 on 2021-04-28 15:44 2 | 3 | import tagulous.models.models 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('inventory', '0005_serve_uploads_by_django_tools'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='itemimagemodel', 15 | name='name', 16 | field=models.CharField( 17 | blank=True, 18 | help_text='BaseItemAttachmentModel.name.help_text', 19 | max_length=255, 20 | null=True, 21 | verbose_name='BaseItemAttachmentModel.name.verbose_name', 22 | ), 23 | ), 24 | migrations.CreateModel( 25 | name='Tagulous_BaseItemAttachmentModel_tags', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('name', models.CharField(max_length=255, unique=True)), 29 | ('slug', models.SlugField()), 30 | ( 31 | 'count', 32 | models.IntegerField(default=0, help_text='Internal counter of how many times this tag is in use'), 33 | ), 34 | ( 35 | 'protected', 36 | models.BooleanField(default=False, help_text='Will not be deleted when the count reaches 0'), 37 | ), 38 | ], 39 | options={ 40 | 'ordering': ('name',), 41 | 'abstract': False, 42 | 'unique_together': {('slug',)}, 43 | }, 44 | bases=(tagulous.models.models.BaseTagModel, models.Model), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /inventory/migrations/0007_add_file_attachment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.20 on 2021-04-28 15:54 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | import django_tools.serve_media_app.models 7 | import tagulous.models.fields 8 | import tagulous.models.models 9 | from django.conf import settings 10 | from django.db import migrations, models 11 | 12 | 13 | class Migration(migrations.Migration): 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ('inventory', '0006_refactor_image_model'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Tagulous_ItemFileModel_tags', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('name', models.CharField(max_length=255, unique=True)), 25 | ('slug', models.SlugField()), 26 | ( 27 | 'count', 28 | models.IntegerField(default=0, help_text='Internal counter of how many times this tag is in use'), 29 | ), 30 | ( 31 | 'protected', 32 | models.BooleanField(default=False, help_text='Will not be deleted when the count reaches 0'), 33 | ), 34 | ], 35 | options={ 36 | 'ordering': ('name',), 37 | 'abstract': False, 38 | 'unique_together': {('slug',)}, 39 | }, 40 | bases=(tagulous.models.models.BaseTagModel, models.Model), 41 | ), 42 | migrations.CreateModel( 43 | name='ItemFileModel', 44 | fields=[ 45 | ( 46 | 'create_dt', 47 | models.DateTimeField( 48 | blank=True, 49 | editable=False, 50 | help_text='ModelTimetrackingMixin.create_dt.help_text', 51 | null=True, 52 | verbose_name='ModelTimetrackingMixin.create_dt.verbose_name', 53 | ), 54 | ), 55 | ( 56 | 'update_dt', 57 | models.DateTimeField( 58 | blank=True, 59 | editable=False, 60 | help_text='ModelTimetrackingMixin.update_dt.help_text', 61 | null=True, 62 | verbose_name='ModelTimetrackingMixin.update_dt.verbose_name', 63 | ), 64 | ), 65 | ( 66 | 'id', 67 | models.UUIDField( 68 | default=uuid.uuid4, 69 | editable=False, 70 | help_text='BaseModel.id.help_text', 71 | primary_key=True, 72 | serialize=False, 73 | verbose_name='BaseModel.id.verbose_name', 74 | ), 75 | ), 76 | ( 77 | 'name', 78 | models.CharField( 79 | blank=True, 80 | help_text='BaseItemAttachmentModel.name.help_text', 81 | max_length=255, 82 | null=True, 83 | verbose_name='BaseItemAttachmentModel.name.verbose_name', 84 | ), 85 | ), 86 | ('position', models.PositiveSmallIntegerField(default=0)), 87 | ( 88 | 'file', 89 | models.FileField( 90 | help_text='ItemFileModel.file.help_text', 91 | upload_to=django_tools.serve_media_app.models.user_directory_path, 92 | verbose_name='ItemFileModel.file.verbose_name', 93 | ), 94 | ), 95 | ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.ItemModel')), 96 | ( 97 | 'tags', 98 | tagulous.models.fields.TagField( 99 | _set_tag_meta=True, 100 | blank=True, 101 | case_sensitive=False, 102 | force_lowercase=False, 103 | help_text='BaseModel.tags.help_text', 104 | max_count=10, 105 | space_delimiter=False, 106 | to='inventory.Tagulous_ItemFileModel_tags', 107 | verbose_name='BaseModel.tags.verbose_name', 108 | ), 109 | ), 110 | ( 111 | 'user', 112 | models.ForeignKey( 113 | editable=False, 114 | help_text='BaseModel.user.help_text', 115 | on_delete=django.db.models.deletion.CASCADE, 116 | related_name='+', 117 | to=settings.AUTH_USER_MODEL, 118 | verbose_name='BaseModel.user.verbose_name', 119 | ), 120 | ), 121 | ], 122 | options={ 123 | 'verbose_name': 'ItemFileModel.verbose_name', 124 | 'verbose_name_plural': 'ItemFileModel.verbose_name_plural', 125 | 'ordering': ('position',), 126 | }, 127 | ), 128 | ] 129 | -------------------------------------------------------------------------------- /inventory/migrations/0008_last_check_datetime.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-10-09 14:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('inventory', '0007_add_file_attachment'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='itemlinkmodel', 14 | name='last_check', 15 | field=models.DateTimeField( 16 | blank=True, 17 | editable=False, 18 | help_text='Link.url.help_text', 19 | null=True, 20 | verbose_name='Link.url.verbose_name', 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /inventory/migrations/0010_version_protect_models.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.13 on 2021-11-22 17:47 2 | 3 | import django_tools.model_version_protect.models 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('inventory', '0009_add_memo'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='itemmodel', 15 | name='version', 16 | field=django_tools.model_version_protect.models.VersionModelField( 17 | default=0, 18 | help_text='Internal version number of this entry. Used to protect the overwriting of an older entry.', 19 | ), 20 | ), 21 | migrations.AddField( 22 | model_name='locationmodel', 23 | name='version', 24 | field=django_tools.model_version_protect.models.VersionModelField( 25 | default=0, 26 | help_text='Internal version number of this entry. Used to protect the overwriting of an older entry.', 27 | ), 28 | ), 29 | migrations.AddField( 30 | model_name='memomodel', 31 | name='version', 32 | field=django_tools.model_version_protect.models.VersionModelField( 33 | default=0, 34 | help_text='Internal version number of this entry. Used to protect the overwriting of an older entry.', 35 | ), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /inventory/migrations/0011_parent_tree1.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-21 18:36 2 | 3 | import django.db.models.deletion 4 | import tagulous.models.models 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ('inventory', '0010_version_protect_models'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='itemmodel', 16 | name='level', 17 | field=models.PositiveSmallIntegerField(blank=True, editable=False, null=True), 18 | ), 19 | migrations.AddField( 20 | model_name='itemmodel', 21 | name='path', 22 | field=models.JSONField(blank=True, editable=False, null=True), 23 | ), 24 | migrations.AddField( 25 | model_name='itemmodel', 26 | name='path_str', 27 | field=models.TextField(blank=True, editable=False, null=True), 28 | ), 29 | migrations.AddField( 30 | model_name='locationmodel', 31 | name='level', 32 | field=models.PositiveSmallIntegerField(blank=True, editable=False, null=True), 33 | ), 34 | migrations.AddField( 35 | model_name='locationmodel', 36 | name='path', 37 | field=models.JSONField(blank=True, editable=False, null=True), 38 | ), 39 | migrations.AddField( 40 | model_name='locationmodel', 41 | name='path_str', 42 | field=models.TextField(blank=True, editable=False, null=True), 43 | ), 44 | migrations.AlterField( 45 | model_name='itemmodel', 46 | name='parent', 47 | field=models.ForeignKey( 48 | blank=True, 49 | help_text='LocationModel.parent.help_text', 50 | null=True, 51 | on_delete=django.db.models.deletion.SET_NULL, 52 | to='inventory.itemmodel', 53 | verbose_name='LocationModel.parent.verbose_name', 54 | ), 55 | ), 56 | migrations.CreateModel( 57 | name='Tagulous_BaseParentTreeModel_tags', 58 | fields=[ 59 | ( 60 | 'id', 61 | models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 62 | ), 63 | ('name', models.CharField(max_length=255, unique=True)), 64 | ('slug', models.SlugField()), 65 | ( 66 | 'count', 67 | models.IntegerField(default=0, help_text='Internal counter of how many times this tag is in use'), 68 | ), 69 | ( 70 | 'protected', 71 | models.BooleanField(default=False, help_text='Will not be deleted when the count reaches 0'), 72 | ), 73 | ], 74 | options={ 75 | 'ordering': ('name',), 76 | 'abstract': False, 77 | 'unique_together': {('slug',)}, 78 | }, 79 | bases=(tagulous.models.models.BaseTagModel, models.Model), 80 | ), 81 | migrations.AlterModelOptions( 82 | name='itemmodel', 83 | options={ 84 | 'ordering': ('path_str',), 85 | 'verbose_name': 'ItemModel.verbose_name', 86 | 'verbose_name_plural': 'ItemModel.verbose_name_plural', 87 | }, 88 | ), 89 | migrations.AlterModelOptions( 90 | name='locationmodel', 91 | options={ 92 | 'ordering': ('path_str',), 93 | 'verbose_name': 'LocationModel.verbose_name', 94 | 'verbose_name_plural': 'LocationModel.verbose_name_plural', 95 | }, 96 | ), 97 | ] 98 | -------------------------------------------------------------------------------- /inventory/migrations/0012_parent_tree2.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-07-21 18:36 2 | from django.core import management 3 | from django.db import migrations 4 | 5 | from inventory.management.commands import tree 6 | 7 | 8 | def forward_code(apps, schema_editor): 9 | management.call_command(tree.Command(), model_name='itemmodel') 10 | management.call_command(tree.Command(), model_name='locationmodel') 11 | 12 | 13 | class Migration(migrations.Migration): 14 | dependencies = [ 15 | ('inventory', '0011_parent_tree1'), 16 | ] 17 | 18 | operations = [ 19 | migrations.RunPython(forward_code, reverse_code=migrations.RunPython.noop), 20 | ] 21 | -------------------------------------------------------------------------------- /inventory/migrations/0013_alter_itemmodel_location.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-30 18:30 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('inventory', '0012_parent_tree2'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='itemmodel', 15 | name='location', 16 | field=models.ForeignKey( 17 | blank=True, 18 | help_text='ItemModel.location.help_text', 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | related_name='items', 22 | to='inventory.locationmodel', 23 | verbose_name='ItemModel.location.verbose_name', 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /inventory/migrations/0014_alter_itemmodel_description_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.1 on 2024-09-05 17:46 2 | 3 | import tinymce.models 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('inventory', '0013_alter_itemmodel_location'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='itemmodel', 16 | name='description', 17 | field=tinymce.models.HTMLField( 18 | blank=True, 19 | help_text='ItemModel.description.help_text', 20 | null=True, 21 | verbose_name='ItemModel.description.verbose_name', 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name='locationmodel', 26 | name='description', 27 | field=tinymce.models.HTMLField( 28 | blank=True, 29 | help_text='LocationModel.description.help_text', 30 | null=True, 31 | verbose_name='LocationModel.description.verbose_name', 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name='memomodel', 36 | name='memo', 37 | field=tinymce.models.HTMLField( 38 | blank=True, 39 | help_text='MemoModel.description.help_text', 40 | null=True, 41 | verbose_name='MemoModel.description.verbose_name', 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /inventory/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/migrations/__init__.py -------------------------------------------------------------------------------- /inventory/models/__init__.py: -------------------------------------------------------------------------------- 1 | from inventory.models.item import ItemFileModel, ItemImageModel, ItemLinkModel, ItemModel # noqa 2 | from inventory.models.location import LocationModel # noqa 3 | from inventory.models.memo import MemoFileModel, MemoImageModel, MemoLinkModel, MemoModel # noqa 4 | -------------------------------------------------------------------------------- /inventory/models/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import time 4 | import unicodedata 5 | import uuid 6 | 7 | import tagulous.models 8 | from bx_django_utils.models.timetracking import TimetrackingBaseModel 9 | from django.conf import settings 10 | from django.db import models 11 | from django.db.models import QuerySet 12 | from django.utils.translation import gettext_lazy as _ 13 | 14 | from inventory.parent_tree import ValuesListTree 15 | from inventory.string_utils import ltruncatechars 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class BaseModel(TimetrackingBaseModel): 22 | id = models.UUIDField( 23 | primary_key=True, 24 | default=uuid.uuid4, 25 | editable=False, 26 | verbose_name=_('BaseModel.id.verbose_name'), 27 | help_text=_('BaseModel.id.help_text'), 28 | ) 29 | user = models.ForeignKey( # "Owner" of this entry 30 | settings.AUTH_USER_MODEL, 31 | related_name='+', 32 | on_delete=models.CASCADE, 33 | editable=False, # Must be set automatically and never changed 34 | verbose_name=_('BaseModel.user.verbose_name'), 35 | help_text=_('BaseModel.user.help_text'), 36 | ) 37 | name = models.CharField( 38 | max_length=255, verbose_name=_('BaseModel.name.verbose_name'), help_text=_('BaseModel.name.help_text') 39 | ) 40 | tags = tagulous.models.TagField( 41 | blank=True, 42 | case_sensitive=False, 43 | force_lowercase=False, 44 | space_delimiter=False, 45 | max_count=10, 46 | verbose_name=_('BaseModel.tags.verbose_name'), 47 | help_text=_('BaseModel.tags.help_text'), 48 | ) 49 | 50 | def __str__(self): 51 | return self.name 52 | 53 | class Meta: 54 | abstract = True 55 | 56 | 57 | def nomalize_text(text): 58 | """ 59 | >>> nomalize_text('Foo Bar 1 §$% äö-üß +') 60 | 'foobar1aou' 61 | """ 62 | text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii') 63 | text = text.lower() 64 | text = re.sub(r'[^\w]', '', text) 65 | return text 66 | 67 | 68 | def generate_path_str(path): 69 | """ 70 | >>> generate_path_str(['Foo', 'B a r', '1 §$% äö-üß +']) 71 | 'foo 0 bar 0 1aou' 72 | """ 73 | # The choice of the separator is very important for the correct sorting by the database! 74 | # Use 0, because this character is used for sorting and is the first character in the charset. 75 | # The spaces are only visual separators ;) 76 | return ' 0 '.join(nomalize_text(part) for part in path) 77 | 78 | 79 | class ParentTreeModelManager(models.Manager): 80 | def update_tree_info(self) -> None: 81 | start_time = time.monotonic() 82 | 83 | values = self.all().values('pk', 'name', 'parent__pk', 'path') 84 | tree = ValuesListTree(values=values) 85 | tree_path = tree.get_tree_path() 86 | logger.debug('Tree path: %r', tree_path) 87 | update_path_info = tree.get_update_path_info() 88 | 89 | duration = (time.monotonic() - start_time) * 1000 90 | logger.info('Get update_path_info: %r in %ims', update_path_info, duration) 91 | 92 | if not update_path_info: 93 | logger.info('No tree path changed, ok') 94 | else: 95 | start_time = time.monotonic() 96 | 97 | entries = self.filter(pk__in=update_path_info.keys()) 98 | for entry in entries: 99 | path = update_path_info[entry.pk] 100 | entry.path = path 101 | entry.path_str = generate_path_str(path) 102 | entry.level = len(path) 103 | 104 | self.bulk_update(entries, ['path', 'path_str', 'level']) 105 | 106 | duration = (time.monotonic() - start_time) * 1000 107 | logger.info('Update %i entries in %ims', len(entries), duration) 108 | 109 | def related_objects(self, instance: 'BaseParentTreeModel') -> QuerySet: 110 | """ 111 | Returns a QuerySet with relation section of the tree 112 | """ 113 | path = instance.path 114 | if path is None: 115 | # Not saved -> Can't have related objects ;) 116 | return self.none() 117 | 118 | root_entry = path[0] 119 | qs = self.all() 120 | qs = qs.filter(path__0=root_entry) 121 | return qs 122 | 123 | 124 | class BaseParentTreeModel(BaseModel): 125 | path = models.JSONField( 126 | blank=True, 127 | null=True, 128 | editable=False, 129 | ) 130 | path_str = models.TextField( 131 | blank=True, 132 | null=True, 133 | editable=False, 134 | ) 135 | level = models.PositiveSmallIntegerField( 136 | blank=True, 137 | null=True, 138 | editable=False, 139 | ) 140 | parent = models.ForeignKey( 141 | 'self', 142 | on_delete=models.SET_NULL, 143 | blank=True, 144 | null=True, 145 | verbose_name=_('LocationModel.parent.verbose_name'), 146 | help_text=_('LocationModel.parent.help_text'), 147 | ) 148 | 149 | objects = models.Manager() 150 | tree_objects = ParentTreeModelManager() 151 | 152 | def save(self, **kwargs): 153 | if not self.path: 154 | if self.parent: 155 | path = self.parent.path 156 | if path: 157 | self.path = [*path, self.name] 158 | else: 159 | self.path = [self.name] 160 | self.path_str = generate_path_str(self.path) 161 | self.level = len(self.path) 162 | logger.info('Init path with: %r', self.path) 163 | 164 | self.full_clean() 165 | super().save(**kwargs) 166 | self.__class__.tree_objects.update_tree_info() 167 | 168 | def __str__(self): 169 | if self.path: 170 | text = ' › '.join(self.path) 171 | text = ltruncatechars(text, max_length=settings.TREE_PATH_STR_MAX_LENGTH) 172 | return text 173 | 174 | return self.name 175 | 176 | class Meta: 177 | abstract = True 178 | 179 | 180 | class BaseAttachmentModel(BaseModel): 181 | """ 182 | Base model to store files or images to Items 183 | """ 184 | 185 | name = models.CharField( 186 | null=True, 187 | blank=True, 188 | max_length=255, 189 | verbose_name=_('BaseItemAttachmentModel.name.verbose_name'), 190 | help_text=_('BaseItemAttachmentModel.name.help_text'), 191 | ) 192 | position = models.PositiveSmallIntegerField( 193 | # Note: Will be set in admin via adminsortable2 194 | # The JavaScript which performs the sorting is 1-indexed ! 195 | default=0, 196 | blank=False, 197 | null=False, 198 | ) 199 | 200 | def __str__(self): 201 | return self.name 202 | 203 | def full_clean(self, *, parent_instance, **kwargs): 204 | if self.user_id is None: 205 | # inherit owner of this link from parent model instance 206 | self.user_id = parent_instance.user_id 207 | 208 | return super().full_clean(**kwargs) 209 | 210 | class Meta: 211 | abstract = True 212 | 213 | 214 | class BaseItemAttachmentModel(BaseAttachmentModel): 215 | """ 216 | Base model to store files or images to Items 217 | """ 218 | 219 | item = models.ForeignKey('ItemModel', on_delete=models.CASCADE) 220 | 221 | def full_clean(self, **kwargs): 222 | return super().full_clean(parent_instance=self.item, **kwargs) 223 | 224 | class Meta: 225 | abstract = True 226 | 227 | 228 | class BaseMemoAttachmentModel(BaseAttachmentModel): 229 | """ 230 | Base model to store files or images to Memos 231 | """ 232 | 233 | memo = models.ForeignKey('MemoModel', on_delete=models.CASCADE) 234 | 235 | def full_clean(self, **kwargs): 236 | return super().full_clean(parent_instance=self.memo, **kwargs) 237 | 238 | class Meta: 239 | abstract = True 240 | -------------------------------------------------------------------------------- /inventory/models/item.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | import tagulous.models 5 | from bx_django_utils.filename import clean_filename 6 | from django.db import models 7 | from django.urls import reverse 8 | from django.utils.translation import gettext_lazy as _ 9 | from django_tools.model_version_protect.models import VersionProtectBaseModel 10 | from django_tools.serve_media_app.models import user_directory_path 11 | from tinymce.models import HTMLField 12 | 13 | from inventory.models.base import BaseItemAttachmentModel, BaseParentTreeModel 14 | from inventory.models.links import BaseLink 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class ItemQuerySet(models.QuerySet): 21 | def sort(self): 22 | return self.order_by('kind', 'producer', 'name') 23 | 24 | 25 | class ItemModel(BaseParentTreeModel, VersionProtectBaseModel): 26 | """ 27 | A Item that can be described and store somewhere ;) 28 | """ 29 | 30 | objects = ItemQuerySet.as_manager() 31 | 32 | kind = tagulous.models.TagField( 33 | case_sensitive=False, 34 | force_lowercase=False, 35 | space_delimiter=False, 36 | max_count=3, 37 | verbose_name=_('ItemModel.kind.verbose_name'), 38 | help_text=_('ItemModel.kind.help_text'), 39 | ) 40 | producer = tagulous.models.TagField( 41 | blank=True, 42 | case_sensitive=False, 43 | force_lowercase=False, 44 | space_delimiter=False, 45 | max_count=1, 46 | verbose_name=_('ItemModel.producer.verbose_name'), 47 | help_text=_('ItemModel.producer.help_text'), 48 | ) 49 | description = HTMLField( 50 | blank=True, 51 | null=True, 52 | verbose_name=_('ItemModel.description.verbose_name'), 53 | help_text=_('ItemModel.description.help_text'), 54 | ) 55 | fcc_id = models.CharField( 56 | max_length=20, 57 | blank=True, 58 | null=True, 59 | verbose_name=_('ItemModel.fcc_id.verbose_name'), 60 | help_text=_('ItemModel.fcc_id.help_text'), 61 | ) 62 | location = models.ForeignKey( 63 | 'inventory.LocationModel', 64 | blank=True, 65 | null=True, 66 | on_delete=models.SET_NULL, 67 | related_name='items', 68 | verbose_name=_('ItemModel.location.verbose_name'), 69 | help_text=_('ItemModel.location.help_text'), 70 | ) 71 | 72 | # ________________________________________________________________________ 73 | # lent 74 | 75 | lent_to = models.CharField( 76 | max_length=64, 77 | blank=True, 78 | null=True, 79 | verbose_name=_('ItemModel.lent_to.verbose_name'), 80 | help_text=_('ItemModel.lent_to.help_text'), 81 | ) 82 | lent_from_date = models.DateField( 83 | blank=True, 84 | null=True, 85 | verbose_name=_('ItemModel.lent_from_date.verbose_name'), 86 | help_text=_('ItemModel.lent_from_date.help_text'), 87 | ) 88 | lent_until_date = models.DateField( 89 | blank=True, 90 | null=True, 91 | verbose_name=_('ItemModel.lent_until_date.verbose_name'), 92 | help_text=_('ItemModel.lent_until_date.help_text'), 93 | ) 94 | 95 | # ________________________________________________________________________ 96 | # received 97 | 98 | received_from = models.CharField( 99 | max_length=64, 100 | blank=True, 101 | null=True, 102 | verbose_name=_('ItemModel.received_from.verbose_name'), 103 | help_text=_('ItemModel.received_from.help_text'), 104 | ) 105 | received_date = models.DateField( 106 | blank=True, 107 | null=True, 108 | verbose_name=_('ItemModel.received_date.verbose_name'), 109 | help_text=_('ItemModel.received_date.help_text'), 110 | ) 111 | received_price = models.DecimalField( 112 | decimal_places=2, 113 | max_digits=6, # up to 9999 with a resolution of 2 decimal places 114 | blank=True, 115 | null=True, 116 | verbose_name=_('ItemModel.received_price.verbose_name'), 117 | help_text=_('ItemModel.received_price.help_text'), 118 | ) 119 | 120 | # ________________________________________________________________________ 121 | # handed over 122 | 123 | handed_over_to = models.CharField( 124 | max_length=64, 125 | blank=True, 126 | null=True, 127 | verbose_name=_('ItemModel.handed_over_to.verbose_name'), 128 | help_text=_('ItemModel.handed_over_to.help_text'), 129 | ) 130 | handed_over_date = models.DateField( 131 | blank=True, 132 | null=True, 133 | verbose_name=_('ItemModel.handed_over_date.verbose_name'), 134 | help_text=_('ItemModel.handed_over_date.help_text'), 135 | ) 136 | handed_over_price = models.DecimalField( 137 | decimal_places=2, 138 | max_digits=6, # up to 9999 with a resolution of 2 decimal places 139 | blank=True, 140 | null=True, 141 | verbose_name=_('ItemModel.handed_over_price.verbose_name'), 142 | help_text=_('ItemModel.handed_over_price.help_text'), 143 | ) 144 | 145 | def local_admin_link(self): 146 | url = reverse('admin:inventory_itemmodel_change', args=[self.id]) 147 | return url 148 | 149 | def verbose_name(self): 150 | parts = [str(part) for part in (self.kind, self.producer, self.name)] 151 | return ' - '.join(part for part in parts if part) 152 | 153 | class Meta: 154 | ordering = ('path_str',) 155 | verbose_name = _('ItemModel.verbose_name') 156 | verbose_name_plural = _('ItemModel.verbose_name_plural') 157 | 158 | 159 | class ItemLinkModel(BaseLink): 160 | item = models.ForeignKey(ItemModel, on_delete=models.CASCADE) 161 | 162 | def full_clean(self, **kwargs): 163 | if self.user_id is None: 164 | # inherit owner of this link from item instance 165 | self.user_id = self.item.user_id 166 | return super().full_clean(**kwargs) 167 | 168 | class Meta: 169 | verbose_name = _('ItemLinkModel.verbose_name') 170 | verbose_name_plural = _('ItemLinkModel.verbose_name_plural') 171 | ordering = ('position',) 172 | 173 | 174 | class ItemImageModel(BaseItemAttachmentModel): 175 | """ 176 | Store images to Items 177 | """ 178 | 179 | image = models.ImageField( 180 | upload_to=user_directory_path, 181 | verbose_name=_('ItemImageModel.image.verbose_name'), 182 | help_text=_('ItemImageModel.image.help_text'), 183 | ) 184 | 185 | def __str__(self): 186 | return self.name or self.image.name 187 | 188 | def full_clean(self, **kwargs): 189 | # Set name by image filename: 190 | if not self.name: 191 | filename = Path(self.image.name).name 192 | self.name = clean_filename(filename) 193 | 194 | return super().full_clean(**kwargs) 195 | 196 | class Meta: 197 | verbose_name = _('ItemImageModel.verbose_name') 198 | verbose_name_plural = _('ItemImageModel.verbose_name_plural') 199 | ordering = ('position',) 200 | 201 | 202 | class ItemFileModel(BaseItemAttachmentModel): 203 | """ 204 | Store files to Items 205 | """ 206 | 207 | file = models.FileField( 208 | upload_to=user_directory_path, 209 | verbose_name=_('ItemFileModel.file.verbose_name'), 210 | help_text=_('ItemFileModel.file.help_text'), 211 | ) 212 | 213 | def __str__(self): 214 | return self.name or self.file.name 215 | 216 | def full_clean(self, **kwargs): 217 | # Set name by filename: 218 | if not self.name: 219 | filename = Path(self.file.name).name 220 | self.name = clean_filename(filename) 221 | 222 | return super().full_clean(**kwargs) 223 | 224 | class Meta: 225 | verbose_name = _('ItemFileModel.verbose_name') 226 | verbose_name_plural = _('ItemFileModel.verbose_name_plural') 227 | ordering = ('position',) 228 | -------------------------------------------------------------------------------- /inventory/models/links.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import re 4 | 5 | import requests 6 | from django.db import models 7 | from django.template.defaultfilters import striptags 8 | from django.utils import timezone 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | from inventory.models.base import BaseModel 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class BaseLink(BaseModel): 18 | name = models.CharField( 19 | max_length=255, 20 | blank=True, 21 | null=True, 22 | verbose_name=_('BaseLink.name.verbose_name'), 23 | help_text=_('BaseLink.name.help_text'), 24 | ) 25 | url = models.URLField(verbose_name=_('Link.url.verbose_name'), help_text=_('Link.url.help_text')) 26 | last_check = models.DateTimeField( 27 | blank=True, 28 | null=True, 29 | editable=False, 30 | verbose_name=_('Link.url.verbose_name'), 31 | help_text=_('Link.url.help_text'), 32 | ) 33 | status_code = models.PositiveSmallIntegerField( 34 | blank=True, 35 | null=True, 36 | editable=False, 37 | verbose_name=_('Link.status_code.verbose_name'), 38 | help_text=_('Link.status_code.help_text'), 39 | ) 40 | page_title = models.CharField( 41 | max_length=255, 42 | blank=True, 43 | null=True, 44 | editable=False, 45 | verbose_name=_('Link.page_title.verbose_name'), 46 | help_text=_('Link.page_title.help_text'), 47 | ) 48 | 49 | position = models.PositiveSmallIntegerField( 50 | # Note: Will be set in admin via adminsortable2 51 | # The JavaScript which performs the sorting is 1-indexed ! 52 | default=0, 53 | blank=False, 54 | null=False, 55 | ) 56 | 57 | def update_response_info(self): 58 | if self.name: 59 | logger.debug('Skip link request: because we have a name: %r', self.name) 60 | return 61 | 62 | if self.last_check: 63 | delta = timezone.now() - self.last_check 64 | logger.debug('Last check is %s ago.', delta) 65 | if delta < datetime.timedelta(minutes=1): 66 | logger.info('Skip request for: %r', self.url) 67 | return 68 | 69 | try: 70 | r = requests.get(url=self.url, allow_redirects=True, timeout=10) 71 | except Exception as err: 72 | logger.exception('Error get %s: %s', self.url, err) 73 | self.status_code = None 74 | self.page_title = None 75 | return 76 | 77 | logger.debug('%r: %r', self.url, r.headers) 78 | 79 | self.last_check = timezone.now() 80 | self.status_code = r.status_code 81 | 82 | if r.status_code == 200: 83 | titles = re.findall(r'(.+?)', r.text) 84 | if not titles: 85 | logger.warning('No title found in %r', self.url) 86 | else: 87 | title = titles[0] 88 | logger.info('Found title: %r', title) 89 | 90 | self.page_title = striptags(title) # TODO: remove with a better clean method! 91 | if not self.name: 92 | logger.debug('set name to: %r', self.page_title) 93 | self.name = self.page_title 94 | 95 | def full_clean(self, **kwargs): 96 | if self.url is not None: 97 | self.update_response_info() 98 | return super().full_clean(**kwargs) 99 | 100 | def __str__(self): 101 | return self.url 102 | 103 | class Meta: 104 | abstract = True 105 | -------------------------------------------------------------------------------- /inventory/models/location.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from django_tools.model_version_protect.models import VersionProtectBaseModel 3 | from tinymce.models import HTMLField 4 | 5 | from inventory.models.base import BaseParentTreeModel 6 | 7 | 8 | class LocationModel(BaseParentTreeModel, VersionProtectBaseModel): 9 | """ 10 | A Storage for items. 11 | """ 12 | 13 | description = HTMLField( 14 | blank=True, 15 | null=True, 16 | verbose_name=_('LocationModel.description.verbose_name'), 17 | help_text=_('LocationModel.description.help_text'), 18 | ) 19 | 20 | class Meta: 21 | ordering = ('path_str',) 22 | verbose_name = _('LocationModel.verbose_name') 23 | verbose_name_plural = _('LocationModel.verbose_name_plural') 24 | -------------------------------------------------------------------------------- /inventory/models/memo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from bx_django_utils.filename import clean_filename 5 | from django.db import models 6 | from django.urls import reverse 7 | from django.utils.translation import gettext_lazy as _ 8 | from django_tools.model_version_protect.models import VersionProtectBaseModel 9 | from django_tools.serve_media_app.models import user_directory_path 10 | from tinymce.models import HTMLField 11 | 12 | from inventory.models.base import BaseMemoAttachmentModel, BaseModel 13 | from inventory.models.links import BaseLink 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class MemoModel(BaseModel, VersionProtectBaseModel): 20 | """ 21 | A Memo to hold some information independent of items/location 22 | """ 23 | 24 | memo = HTMLField( 25 | blank=True, 26 | null=True, 27 | verbose_name=_('MemoModel.description.verbose_name'), 28 | help_text=_('MemoModel.description.help_text'), 29 | ) 30 | 31 | def local_admin_link(self): 32 | url = reverse('admin:inventory_memomodel_change', args=[self.id]) 33 | return url 34 | 35 | class Meta: 36 | verbose_name = _('MemoModel.verbose_name') 37 | verbose_name_plural = _('MemoModel.verbose_name_plural') 38 | 39 | 40 | class MemoLinkModel(BaseLink): 41 | memo = models.ForeignKey(MemoModel, on_delete=models.CASCADE) 42 | 43 | def full_clean(self, **kwargs): 44 | if self.user_id is None: 45 | # inherit owner of this link from item instance 46 | self.user_id = self.memo.user_id 47 | return super().full_clean(**kwargs) 48 | 49 | class Meta: 50 | verbose_name = _('MemoLinkModel.verbose_name') 51 | verbose_name_plural = _('MemoLinkModel.verbose_name_plural') 52 | ordering = ('position',) 53 | 54 | 55 | class MemoImageModel(BaseMemoAttachmentModel): 56 | """ 57 | Store images to Memos 58 | """ 59 | 60 | image = models.ImageField( 61 | upload_to=user_directory_path, 62 | verbose_name=_('MemoImageModel.image.verbose_name'), 63 | help_text=_('MemoImageModel.image.help_text'), 64 | ) 65 | 66 | def __str__(self): 67 | return self.name or self.image.name 68 | 69 | def full_clean(self, **kwargs): 70 | # Set name by image filename: 71 | if not self.name: 72 | filename = Path(self.image.name).name 73 | self.name = clean_filename(filename) 74 | 75 | return super().full_clean(**kwargs) 76 | 77 | class Meta: 78 | verbose_name = _('MemoImageModel.verbose_name') 79 | verbose_name_plural = _('MemoImageModel.verbose_name_plural') 80 | ordering = ('position',) 81 | 82 | 83 | class MemoFileModel(BaseMemoAttachmentModel): 84 | """ 85 | Store files to Memos 86 | """ 87 | 88 | file = models.FileField( 89 | upload_to=user_directory_path, 90 | verbose_name=_('MemoFileModel.file.verbose_name'), 91 | help_text=_('MemoFileModel.file.help_text'), 92 | ) 93 | 94 | def __str__(self): 95 | return self.name or self.file.name 96 | 97 | def full_clean(self, **kwargs): 98 | # Set name by filename: 99 | if not self.name: 100 | filename = Path(self.file.name).name 101 | self.name = clean_filename(filename) 102 | 103 | return super().full_clean(**kwargs) 104 | 105 | class Meta: 106 | verbose_name = _('MemoFileModel.verbose_name') 107 | verbose_name_plural = _('MemoFileModel.verbose_name_plural') 108 | ordering = ('position',) 109 | -------------------------------------------------------------------------------- /inventory/parent_tree.py: -------------------------------------------------------------------------------- 1 | class TreeNode: 2 | def __init__(self, pk, name, current_path): 3 | self.pk = pk 4 | self.name = name 5 | self.current_path = current_path 6 | self.parent_node = None 7 | self.path = None 8 | 9 | def _set_parent(self, parent_node): 10 | self.parent_node = parent_node 11 | 12 | def _get_path(self): 13 | if self.parent_node: 14 | parent_path = self.parent_node._get_path() 15 | return [*parent_path, self.name] 16 | else: 17 | return [self.name] 18 | 19 | def _set_tree_info(self): 20 | self.path = self._get_path() 21 | 22 | @property 23 | def path_string(self): 24 | return ' / '.join(self.path) 25 | 26 | def __str__(self): 27 | return f'pk:{self.pk} name:"{self.name}" path:"{self.path_string}"' 28 | 29 | def __repr__(self): 30 | return f'' 31 | 32 | 33 | class ValuesListTree: 34 | def __init__(self, values): 35 | nodes = {} 36 | 37 | # init all nodes: 38 | for entry in values: 39 | pk = entry['pk'] 40 | name = entry['name'] 41 | current_path = entry['path'] 42 | nodes[pk] = TreeNode(pk=pk, name=name, current_path=current_path) 43 | 44 | # Set parents: 45 | for entry in values: 46 | parent_pk = entry['parent__pk'] 47 | if parent_pk: 48 | pk = entry['pk'] 49 | node = nodes[pk] 50 | parent_node = nodes[parent_pk] 51 | node._set_parent(parent_node=parent_node) 52 | 53 | # Set tree info: 54 | nodes = list(nodes.values()) 55 | for node in nodes: 56 | node._set_tree_info() 57 | 58 | # Oder by hierarchy: 59 | nodes.sort(key=lambda x: x.path) 60 | 61 | self.nodes = nodes 62 | 63 | def get_tree_path(self): 64 | return [node.path_string for node in self.nodes] 65 | 66 | def get_update_path_info(self): 67 | update_info = {} 68 | for node in self.nodes: 69 | if node.current_path != node.path: 70 | update_info[node.pk] = node.path 71 | return update_info 72 | -------------------------------------------------------------------------------- /inventory/permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group, Permission 2 | from django.contrib.contenttypes.models import ContentType 3 | 4 | from inventory.models import ( 5 | ItemFileModel, 6 | ItemImageModel, 7 | ItemLinkModel, 8 | ItemModel, 9 | LocationModel, 10 | MemoFileModel, 11 | MemoImageModel, 12 | MemoLinkModel, 13 | MemoModel, 14 | ) 15 | 16 | 17 | NORMAL_USER_GROUP_NAME = 'normal user' 18 | 19 | 20 | def get_permissions(*models): 21 | content_types = [] 22 | for model in models: 23 | content_types.append(ContentType.objects.get_for_model(model)) 24 | 25 | return Permission.objects.filter(content_type__in=content_types) 26 | 27 | 28 | def get_or_create_normal_user_group(): 29 | """ 30 | Will be called by: 31 | inventory.signals.post_migrate_callback() 32 | """ 33 | return Group.objects.get_or_create(name=NORMAL_USER_GROUP_NAME) 34 | 35 | 36 | def setup_normal_user_permissions(normal_user_group): 37 | """ 38 | Setup PyInventory "normal user" permissions. 39 | Will be called by: 40 | inventory.signals.post_migrate_callback() 41 | """ 42 | assert normal_user_group.name == NORMAL_USER_GROUP_NAME 43 | permissions = get_permissions( 44 | ItemFileModel, 45 | ItemImageModel, 46 | ItemLinkModel, 47 | ItemModel, 48 | LocationModel, 49 | MemoFileModel, 50 | MemoImageModel, 51 | MemoLinkModel, 52 | MemoModel, 53 | ) 54 | existing_permissions = normal_user_group.permissions.all() 55 | 56 | if set(permissions) != set(existing_permissions): 57 | normal_user_group.permissions.set(permissions) 58 | return True 59 | 60 | return False 61 | -------------------------------------------------------------------------------- /inventory/request_dict.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | 4 | __request_dict = threading.local() 5 | 6 | 7 | def get_request_dict(): 8 | try: 9 | return __request_dict.context 10 | except AttributeError: 11 | __request_dict.context = {} 12 | return __request_dict.context 13 | 14 | 15 | def clear_request_dict(): 16 | __request_dict.context = {} 17 | -------------------------------------------------------------------------------- /inventory/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_migrate 2 | from django.dispatch import receiver 3 | 4 | from inventory.permissions import get_or_create_normal_user_group, setup_normal_user_permissions 5 | 6 | 7 | @receiver(post_migrate) 8 | def post_migrate_callback(sender, **kwargs): 9 | normal_user_group, created = get_or_create_normal_user_group() 10 | if created: 11 | print(f'User group {normal_user_group} created') 12 | 13 | updated = setup_normal_user_permissions(normal_user_group) 14 | if updated: 15 | print(f'Update permissions for {normal_user_group}') 16 | -------------------------------------------------------------------------------- /inventory/string_utils.py: -------------------------------------------------------------------------------- 1 | def ltruncatechars(text, max_length, truncate='…'): 2 | """ 3 | >>> ltruncatechars('1234567890', max_length=10) 4 | '1234567890' 5 | >>> ltruncatechars('1234567890', max_length=5) 6 | '…7890' 7 | >>> ltruncatechars('1234567890', max_length=6) 8 | '…67890' 9 | >>> ltruncatechars('1234567890', max_length=6, truncate='...') 10 | '...890' 11 | """ 12 | if len(text) > max_length: 13 | length = max_length - len(truncate) 14 | text = truncate + text[-length:] 15 | return text 16 | -------------------------------------------------------------------------------- /inventory/templates/admin/item/related_items.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% for obj in items %} 12 | 13 | 14 | 15 | 16 | {% endfor %} 17 | 18 |
{% trans "No." %}{% trans "ItemModel.verbose_name_plural" %}
{{ forloop.counter }}{{ obj }}
-------------------------------------------------------------------------------- /inventory/templates/admin/location/items.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% for obj in items %} 12 | 13 | 14 | 15 | 16 | {% endfor %} 17 | 18 |
{% trans "No." %}{% trans "ItemModel.verbose_name_plural" %}
{{ forloop.counter }}{{ obj }}
-------------------------------------------------------------------------------- /inventory/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest.util 3 | from pathlib import Path 4 | 5 | import django 6 | from bx_py_utils.test_utils.deny_requests import deny_any_real_request 7 | from manage_django_project.config import project_info 8 | from rich import print 9 | from typeguard import install_import_hook 10 | 11 | import inventory 12 | 13 | 14 | # Check type annotations via typeguard in all tests: 15 | install_import_hook(packages=('inventory', 'inventory_project')) 16 | 17 | 18 | PROJECT_ROOT = Path(inventory.__file__).parent.parent 19 | 20 | 21 | def pre_configure_tests() -> None: 22 | print(f'Configure unittests via "load_tests Protocol" from {Path(__file__).relative_to(Path.cwd())}') 23 | 24 | # Hacky way to display more "assert"-Context in failing tests: 25 | _MIN_MAX_DIFF = unittest.util._MAX_LENGTH - unittest.util._MIN_DIFF_LEN 26 | unittest.util._MAX_LENGTH = int(os.environ.get('UNITTEST_MAX_LENGTH', 300)) 27 | unittest.util._MIN_DIFF_LEN = unittest.util._MAX_LENGTH - _MIN_MAX_DIFF 28 | 29 | # Deny any request via docket/urllib3 because tests they should mock all requests: 30 | deny_any_real_request() 31 | 32 | 33 | def init_django4unittests() -> None: 34 | """ 35 | Make it possible to run tests via: 36 | .venv/bin/python -m unittest 37 | by setup Django with test settings 38 | """ 39 | project_info.initialize() 40 | 41 | DJANGO_SETTINGS_MODULE: str = project_info.config.test_settings 42 | print(f'Force {DJANGO_SETTINGS_MODULE=}') 43 | os.environ['DJANGO_SETTINGS_MODULE'] = DJANGO_SETTINGS_MODULE 44 | 45 | django.setup() 46 | 47 | 48 | def load_tests(loader, tests, pattern): 49 | """ 50 | Use unittest "load_tests Protocol" as a hook to setup test environment before running tests. 51 | https://docs.python.org/3/library/unittest.html#load-tests-protocol 52 | """ 53 | pre_configure_tests() 54 | init_django4unittests() 55 | return loader.discover(start_dir=Path(__file__).parent, pattern=pattern) 56 | -------------------------------------------------------------------------------- /inventory/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /inventory/tests/fixtures/users.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from model_bakery import baker 3 | 4 | from inventory.permissions import get_or_create_normal_user_group 5 | 6 | 7 | def get_normal_pyinventory_user(**baker_kwargs): 8 | pyinventory_user_group = get_or_create_normal_user_group()[0] 9 | pyinventory_user = baker.make(User, is_staff=True, is_active=True, is_superuser=False, **baker_kwargs) 10 | pyinventory_user.groups.set([pyinventory_user_group]) 11 | return pyinventory_user 12 | -------------------------------------------------------------------------------- /inventory/tests/test_admin_location.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from bx_django_utils.test_utils.html_assertion import HtmlAssertionMixin, assert_html_response_snapshot 4 | from django.template.defaulttags import CsrfTokenNode, NowNode 5 | from django.test import TestCase, override_settings 6 | 7 | from inventory_project.tests.fixtures import get_normal_user 8 | from inventory_project.tests.mocks import MockInventoryVersionString 9 | 10 | 11 | @override_settings(SECURE_SSL_REDIRECT=False) 12 | class AdminTestCase(HtmlAssertionMixin, TestCase): 13 | @classmethod 14 | def setUpTestData(cls): 15 | cls.normaluser = get_normal_user() 16 | 17 | def test_empty_change_list(self): 18 | self.client.force_login(self.normaluser) 19 | with mock.patch.object(NowNode, 'render', return_value='MockedNowNode'), mock.patch.object( 20 | CsrfTokenNode, 'render', return_value='MockedCsrfTokenNode' 21 | ), MockInventoryVersionString(): 22 | response = self.client.get( 23 | path='/admin/inventory/locationmodel/', 24 | ) 25 | assert response.status_code == 200 26 | self.assert_html_parts( 27 | response, 28 | parts=( 29 | 'Select Location to change | PyInventory vMockedVersionString', 30 | 'Add Location', 31 | '

0 Locations

', 32 | ), 33 | ) 34 | assert_html_response_snapshot(response=response, validate=False) 35 | -------------------------------------------------------------------------------- /inventory/tests/test_admin_location_empty_change_list_1.snapshot.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Select Location to change 4 |

5 |
6 | 28 |
29 |
30 |
31 | 41 |
42 |
43 | MockedCsrfTokenNode 44 |

45 | 0 Locations 46 |

47 |
48 |
49 | 88 |
89 |
90 |
91 |
-------------------------------------------------------------------------------- /inventory/tests/test_item_images.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from unittest import mock 3 | 4 | from django.http import FileResponse 5 | from django.test import TestCase, override_settings 6 | from django_tools.serve_media_app.models import UserMediaTokenModel 7 | from model_bakery import baker 8 | 9 | from inventory.models import ItemImageModel 10 | from inventory.tests.fixtures.users import get_normal_pyinventory_user 11 | 12 | 13 | @override_settings(SECURE_SSL_REDIRECT=True) 14 | class ItemImagesTestCase(TestCase): 15 | def test_basics(self): 16 | with mock.patch('secrets.token_urlsafe', return_value='user1token'): 17 | pyinventory_user1 = get_normal_pyinventory_user(id=1) 18 | 19 | with mock.patch('secrets.token_urlsafe', return_value='user2token'): 20 | pyinventory_user2 = get_normal_pyinventory_user(id=2) 21 | 22 | token1_instance = UserMediaTokenModel.objects.get(user=pyinventory_user1) 23 | assert repr(token1_instance) == (f"") 24 | token2_instance = UserMediaTokenModel.objects.get(user=pyinventory_user2) 25 | assert repr(token2_instance) == (f"") 26 | 27 | with tempfile.TemporaryDirectory() as temp: 28 | with override_settings(MEDIA_ROOT=temp): 29 | with mock.patch('secrets.token_urlsafe', return_value='12345678901234567890'): 30 | image_instance = baker.make(ItemImageModel, user=pyinventory_user1, _create_files=True) 31 | 32 | assert image_instance.image is not None 33 | url = image_instance.image.url 34 | assert url == '/media/user1token/12345678901234567890/mock_img.jpeg' 35 | 36 | # HTTP -> HTTPS redirect: 37 | response = self.client.get('/media/user1token/12345678901234567890/mock_img.jpeg', secure=False) 38 | self.assertRedirects( 39 | response, 40 | status_code=301, 41 | expected_url='https://testserver/media/user1token/12345678901234567890/mock_img.jpeg', 42 | fetch_redirect_response=False, 43 | ) 44 | 45 | # Anonymous has no access: 46 | response = self.client.get( 47 | '/media/user1token/12345678901234567890/mock_img.jpeg', 48 | secure=True, 49 | ) 50 | assert response.status_code == 403 51 | 52 | # Can't access with wrong user: 53 | self.client.force_login(pyinventory_user2) 54 | response = self.client.get( 55 | '/media/user1token/12345678901234567890/mock_img.jpeg', 56 | secure=True, 57 | ) 58 | assert response.status_code == 403 59 | 60 | # Can access with the right user: 61 | self.client.force_login(pyinventory_user1) 62 | response = self.client.get( 63 | '/media/user1token/12345678901234567890/mock_img.jpeg', 64 | secure=True, 65 | ) 66 | assert response.status_code == 200 67 | assert isinstance(response, FileResponse) 68 | assert response.getvalue() == image_instance.image.open('rb').read() 69 | 70 | # Test whats happen, if token was deleted 71 | UserMediaTokenModel.objects.all().delete() 72 | response = self.client.get( 73 | '/media/user1token/12345678901234567890/mock_img.jpeg', 74 | secure=True, 75 | ) 76 | assert response.status_code == 400 # SuspiciousOperation -> HttpResponseBadRequest 77 | -------------------------------------------------------------------------------- /inventory/tests/test_link_model.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | from unittest.mock import patch 4 | 5 | import requests_mock 6 | from bx_django_utils.test_utils.datetime import MockDatetimeGenerator 7 | from bx_py_utils.test_utils.datetime import parse_dt 8 | from django.test import TestCase 9 | from django.utils import timezone 10 | from model_bakery import baker 11 | 12 | from inventory.models import ItemLinkModel, ItemModel 13 | 14 | 15 | class ItemLinkModelTestCase(TestCase): 16 | def test_set_name_by_request(self): 17 | with self.assertLogs('django_tools'): 18 | item = baker.make(ItemModel) 19 | 20 | link = ItemLinkModel(item=item, url='http://test.tld/foo/bar') 21 | 22 | offset = datetime.timedelta(seconds=30) 23 | with patch.object(timezone, 'now', MockDatetimeGenerator(offset)): 24 | with requests_mock.Mocker() as m: 25 | m.get('http://test.tld/foo/bar', text='No title') 26 | 27 | assert link.last_check is None 28 | with self.assertLogs('inventory.models.links', level=logging.WARNING) as logs: 29 | link.full_clean() 30 | assert link.page_title is None 31 | assert link.name is None 32 | assert link.last_check == parse_dt('2000-01-01T00:00:30+0000') 33 | 34 | logs = logs.output 35 | assert logs == ["WARNING:inventory.models.links:No title found in 'http://test.tld/foo/bar'"] 36 | 37 | # We should not create request on every admin save call 38 | 39 | with self.assertLogs('inventory.models.links', level=logging.DEBUG) as logs: 40 | link.full_clean() 41 | assert link.page_title is None 42 | assert link.name is None 43 | 44 | logs = logs.output 45 | assert logs == [ 46 | 'DEBUG:inventory.models.links:Last check is 0:00:30 ago.', 47 | "INFO:inventory.models.links:Skip request for: 'http://test.tld/foo/bar'", 48 | ] 49 | 50 | # Next try after 1 Min 51 | 52 | m.get('http://test.tld/foo/bar', text='A <boom>Title</boom>!') 53 | with self.assertLogs('inventory.models.links', level=logging.INFO) as logs: 54 | link.full_clean() 55 | assert link.page_title == 'A Title!' 56 | assert link.name == 'A Title!' 57 | 58 | logs = logs.output 59 | assert logs == ["INFO:inventory.models.links:Found title: 'A Title!'"] 60 | 61 | # Don't make requests, if we have a link name! 62 | 63 | with requests_mock.Mocker(): 64 | with self.assertLogs('inventory.models.links', level=logging.DEBUG) as logs: 65 | link.full_clean() 66 | assert link.page_title == 'A Title!' 67 | assert link.name == 'A Title!' 68 | 69 | logs = logs.output 70 | assert logs == [ 71 | ("DEBUG:inventory.models.links:Skip link request:" " because we have a name: 'A Title!'") 72 | ] 73 | -------------------------------------------------------------------------------- /inventory/tests/test_management_command_seed_data.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import io 3 | 4 | from django.contrib.auth.models import User 5 | from django.core import management 6 | from django.test import TestCase 7 | 8 | from inventory.management.commands import seed_data 9 | from inventory.models import ItemModel, LocationModel 10 | 11 | 12 | class ManagementCommandTestCase(TestCase): 13 | def test_seed_data_command(self): 14 | output = io.StringIO() 15 | 16 | management.call_command(seed_data.Command(), user_count=2, location_count=2, item_count=2, stdout=output) 17 | assert User.objects.count() == 2 18 | assert LocationModel.objects.count() == 8 19 | assert ItemModel.objects.count() == 16 20 | 21 | output = output.getvalue() 22 | reference = inspect.cleandoc( 23 | ''' 24 | Fill database with example data 25 | ____________________________________________________________________________________________________ 26 | Create seed data for user seed-data-user-1 27 | Room 1 › Cupboard 1 › Drawer 1 | Equipment 001 28 | Room 1 › Cupboard 1 › Drawer 1 | Equipment 001 › Item 001 29 | Room 1 › Cupboard 1 › Drawer 1 | Equipment 001 › Item 001 › Part 001 30 | Room 1 › Cupboard 1 › Drawer 1 | Equipment 001 › Item 001 › Part 002 31 | Room 1 › Cupboard 1 › Drawer 2 | Equipment 002 32 | Room 1 › Cupboard 1 › Drawer 2 | Equipment 002 › Item 002 33 | Room 1 › Cupboard 1 › Drawer 2 | Equipment 002 › Item 002 › Part 003 34 | Room 1 › Cupboard 1 › Drawer 2 | Equipment 002 › Item 002 › Part 004 35 | ____________________________________________________________________________________________________ 36 | Create seed data for user seed-data-user-2 37 | Room 1 › Cupboard 1 › Drawer 1 | Equipment 003 38 | Room 1 › Cupboard 1 › Drawer 1 | Equipment 003 › Item 003 39 | Room 1 › Cupboard 1 › Drawer 1 | Equipment 003 › Item 003 › Part 005 40 | Room 1 › Cupboard 1 › Drawer 1 | Equipment 003 › Item 003 › Part 006 41 | Room 1 › Cupboard 1 › Drawer 2 | Equipment 004 42 | Room 1 › Cupboard 1 › Drawer 2 | Equipment 004 › Item 004 43 | Room 1 › Cupboard 1 › Drawer 2 | Equipment 004 › Item 004 › Part 007 44 | Room 1 › Cupboard 1 › Drawer 2 | Equipment 004 › Item 004 › Part 008 45 | 46 | Seed data created. 47 | ''' 48 | ) 49 | assert output.strip() == reference.strip() 50 | -------------------------------------------------------------------------------- /inventory/tests/test_management_command_tree.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from django.core import management 4 | from django.test import TestCase 5 | from model_bakery import baker 6 | 7 | from inventory.management.commands import tree 8 | from inventory.models import ItemModel 9 | 10 | 11 | class ManagementCommandTestCase(TestCase): 12 | def test_tree_command(self): 13 | baker.make(ItemModel, name='Foo Bar') 14 | ItemModel.objects.update(path_str='OLD', path=['OLD']) 15 | 16 | output = io.StringIO() 17 | 18 | management.call_command(tree.Command(), stdout=output) 19 | 20 | output = output.getvalue() 21 | self.assertIn('Repair tree information', output) 22 | 23 | self.assertIn("Old information about model: 'Item'", output) 24 | self.assertIn("{'level': 1, 'path_str': 'OLD', 'path': ['OLD'], 'name': 'Foo Bar'}", output) 25 | 26 | self.assertIn("New information about model: 'Item'", output) 27 | self.assertIn("{'level': 1, 'path_str': 'foobar', 'path': ['Foo Bar'], 'name': 'Foo Bar'}", output) 28 | -------------------------------------------------------------------------------- /inventory/tests/test_parent_tree.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from inventory.parent_tree import ValuesListTree 4 | 5 | 6 | def test_values_list_tree(): 7 | values_list = [ 8 | (1, '1.', None), 9 | (2, '1.1.', 1), 10 | (3, '1.1.1', 2), 11 | (4, '1.1.2', 2), 12 | (5, '1.2.', 1), 13 | (6, '2.', None), 14 | ] 15 | random.shuffle(values_list) 16 | values = [{'pk': entry[0], 'name': entry[1], 'parent__pk': entry[2], 'path': ''} for entry in values_list] 17 | tree = ValuesListTree(values) 18 | 19 | tree_path = tree.get_tree_path() 20 | assert tree_path == [ 21 | '1.', 22 | '1. / 1.1.', 23 | '1. / 1.1. / 1.1.1', 24 | '1. / 1.1. / 1.1.2', 25 | '1. / 1.2.', 26 | '2.', 27 | ] 28 | update_path_info = tree.get_update_path_info() 29 | assert update_path_info == { 30 | 1: ['1.'], 31 | 2: ['1.', '1.1.'], 32 | 3: ['1.', '1.1.', '1.1.1'], 33 | 4: ['1.', '1.1.', '1.1.2'], 34 | 5: ['1.', '1.2.'], 35 | 6: ['2.'], 36 | } 37 | 38 | node_three = tree.nodes[2] 39 | assert str(node_three) == 'pk:3 name:"1.1.1" path:"1. / 1.1. / 1.1.1"' 40 | assert repr(node_three) == '' 41 | -------------------------------------------------------------------------------- /inventory/tests/test_parent_tree_model.py: -------------------------------------------------------------------------------- 1 | from bx_django_utils.test_utils.assert_queries import AssertQueries 2 | from django.db.models import QuerySet 3 | from django.test import TestCase 4 | 5 | from inventory.admin import ItemModelAdmin, LocationModelAdmin 6 | from inventory.models import ItemModel, LocationModel 7 | from inventory_project.tests.fixtures import get_normal_user 8 | 9 | 10 | class TreeModelTests(TestCase): 11 | @classmethod 12 | def setUpTestData(cls): 13 | cls.normaluser = get_normal_user() 14 | 15 | def test_parent_tree_model(self): 16 | for main_item_no in range(1, 3): 17 | main_item = ItemModel.objects.create( 18 | user=self.normaluser, 19 | name=f'{main_item_no}.', 20 | ) 21 | main_item.full_clean() 22 | 23 | for sub_item_no in range(1, 3): 24 | sub_item = ItemModel.objects.create( 25 | parent=main_item, 26 | user=self.normaluser, 27 | name=f'{main_item_no}.{sub_item_no}.', 28 | ) 29 | sub_item.full_clean() 30 | 31 | for sub_sub_item_no in range(1, 3): 32 | sub_sub_item = ItemModel.objects.create( 33 | parent=sub_item, 34 | user=self.normaluser, 35 | name=f'{main_item_no}.{sub_item_no}.{sub_sub_item_no}.', 36 | ) 37 | sub_sub_item.full_clean() 38 | 39 | data = list(ItemModel.objects.values_list('level', 'path_str', 'name')) 40 | assert data == [ 41 | (1, '1', '1.'), 42 | (2, '1 0 11', '1.1.'), 43 | (3, '1 0 11 0 111', '1.1.1.'), 44 | (3, '1 0 11 0 112', '1.1.2.'), 45 | (2, '1 0 12', '1.2.'), 46 | (3, '1 0 12 0 121', '1.2.1.'), 47 | (3, '1 0 12 0 122', '1.2.2.'), 48 | (1, '2', '2.'), 49 | (2, '2 0 21', '2.1.'), 50 | (3, '2 0 21 0 211', '2.1.1.'), 51 | (3, '2 0 21 0 212', '2.1.2.'), 52 | (2, '2 0 22', '2.2.'), 53 | (3, '2 0 22 0 221', '2.2.1.'), 54 | (3, '2 0 22 0 222', '2.2.2.'), 55 | ] 56 | 57 | item_2_1 = ItemModel.objects.get(name='2.1.') 58 | 59 | related_qs = ItemModel.tree_objects.related_objects(instance=item_2_1) 60 | data = list(related_qs.values_list('name', flat=True)) 61 | assert data == ['2.', '2.1.', '2.1.1.', '2.1.2.', '2.2.', '2.2.1.', '2.2.2.'] 62 | 63 | item_2_1.name = 'NEW 2.1. Name' 64 | with AssertQueries() as queries: 65 | item_2_1.save() 66 | 67 | data = list(ItemModel.objects.values_list('level', 'path_str', 'name')) 68 | assert data == [ 69 | (1, '1', '1.'), 70 | (2, '1 0 11', '1.1.'), 71 | (3, '1 0 11 0 111', '1.1.1.'), 72 | (3, '1 0 11 0 112', '1.1.2.'), 73 | (2, '1 0 12', '1.2.'), 74 | (3, '1 0 12 0 121', '1.2.1.'), 75 | (3, '1 0 12 0 122', '1.2.2.'), 76 | (1, '2', '2.'), 77 | (2, '2 0 22', '2.2.'), 78 | (3, '2 0 22 0 221', '2.2.1.'), 79 | (3, '2 0 22 0 222', '2.2.2.'), 80 | (2, '2 0 new21name', 'NEW 2.1. Name'), 81 | (3, '2 0 new21name 0 211', '2.1.1.'), 82 | (3, '2 0 new21name 0 212', '2.1.2.'), 83 | ] 84 | 85 | itemmodel_count = 1 # full_clean(): Check if parent exists 86 | itemmodel_count += 1 # VersionProtectBaseModel: Check version 87 | itemmodel_count += 1 # VersionProtectBaseModel: Save new version 88 | itemmodel_count += 1 # Get info for tree update 89 | itemmodel_count += 1 # Fetch the items to update 90 | itemmodel_count += 1 # Bulk update save 91 | 92 | queries.assert_queries( 93 | table_counts={ 94 | 'inventory_itemmodel': itemmodel_count, 95 | 'auth_user': 1, # full_clean(): Check if user exists 96 | }, 97 | double_tables=False, 98 | duplicated=True, 99 | similar=True, 100 | ) 101 | 102 | def test_related_objects(self): 103 | item = ItemModel() 104 | qs = ItemModel.tree_objects.related_objects(instance=item) 105 | assert isinstance(qs, QuerySet) 106 | assert qs.query.is_empty() is True 107 | 108 | def test_parent_tree_model_ordering(self): 109 | assert LocationModel._meta.ordering == ('path_str',) 110 | assert LocationModelAdmin.ordering == ('path_str',) 111 | 112 | assert ItemModel._meta.ordering == ('path_str',) 113 | assert ItemModelAdmin.ordering == ('path_str',) 114 | 115 | def create(name, parent=None): 116 | instance = ItemModel.objects.create(user=self.normaluser, name=name, parent=parent) 117 | instance.full_clean() 118 | return instance 119 | 120 | # Create a "Special" case for the correct ordering: 121 | # 1. all "PC-1" entries 122 | # 2. all "PC1640" entries 123 | # 124 | # The correct order depends on the seperator, here: " 0 " 125 | 126 | pc1 = create(name='PC-1') 127 | pc1640 = create(name='PC1640 SD') 128 | create(name='FZ-502 Rev A 5.25″ Floppy', parent=pc1) 129 | create(name='1,44MB / 3.5" Floppy FD-235HF- 3800-U', parent=pc1) 130 | create(name='PC 1640ECD', parent=pc1640) 131 | 132 | data = list(ItemModel.objects.values_list('level', 'path_str', 'name')) 133 | assert data == [ 134 | (1, 'pc1', 'PC-1'), 135 | (2, 'pc1 0 144mb35floppyfd235hf3800u', '1,44MB / 3.5" Floppy FD-235HF- 3800-U'), 136 | (2, 'pc1 0 fz502reva525floppy', 'FZ-502 Rev A 5.25″ Floppy'), 137 | (1, 'pc1640sd', 'PC1640 SD'), 138 | (2, 'pc1640sd 0 pc1640ecd', 'PC 1640ECD'), 139 | ] 140 | -------------------------------------------------------------------------------- /inventory_project/__init__.py: -------------------------------------------------------------------------------- 1 | import inventory 2 | 3 | 4 | # Just the same version as the real project: 5 | __version__ = inventory.__version__ 6 | -------------------------------------------------------------------------------- /inventory_project/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Allow your-cool-package to be executable 3 | through `python -m inventory`. 4 | """ 5 | from manage_django_project.manage import execute_django_from_command_line 6 | 7 | 8 | def main(): 9 | """ 10 | entrypoint installed via pyproject.toml and [project.scripts] section. 11 | Must be set in ./manage.py and PROJECT_SHELL_SCRIPT 12 | """ 13 | execute_django_from_command_line() 14 | 15 | 16 | if __name__ == '__main__': 17 | main() 18 | -------------------------------------------------------------------------------- /inventory_project/manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from django import __version__ as django_version 5 | 6 | from inventory import __version__ 7 | 8 | 9 | def main(argv): 10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'inventory_project.settings.local') 11 | 12 | if '--version' not in argv: 13 | print(f'PyInventory v{__version__} (Django v{django_version})', file=sys.stderr) 14 | print(f'DJANGO_SETTINGS_MODULE={os.environ["DJANGO_SETTINGS_MODULE"]!r}', file=sys.stderr) 15 | 16 | try: 17 | from django.core.management import execute_from_command_line 18 | except ImportError as exc: 19 | raise ImportError( 20 | 'Couldn\'t import Django. Are you sure it\'s installed and ' 21 | 'available on your PYTHONPATH environment variable? Did you ' 22 | 'forget to activate a virtual environment?' 23 | ) from exc 24 | try: 25 | execute_from_command_line(argv) 26 | except Exception as err: 27 | from bx_py_utils.error_handling import print_exc_plus 28 | 29 | print_exc_plus(err) 30 | raise 31 | 32 | 33 | def start_test_server(): 34 | """ 35 | Entrypoint for "[tool.poetry.scripts]" script started by devshell command. 36 | """ 37 | main(argv=[__file__, "run_testserver"] + sys.argv[1:]) 38 | 39 | 40 | if __name__ == '__main__': 41 | main(argv=sys.argv) 42 | -------------------------------------------------------------------------------- /inventory_project/middlewares.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from debug_toolbar.middleware import show_toolbar 5 | from django.conf import settings 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def djdt_show(request): 12 | """ 13 | Determining whether the Django Debug Toolbar should show or not. 14 | """ 15 | if not settings.DEBUG: 16 | return False 17 | 18 | if Path('/.dockerenv').exists(): 19 | # We run in a docker container 20 | # skip the `request.META['REMOTE_ADDR'] in settings.INTERNAL_IPS` check. 21 | return True 22 | 23 | return show_toolbar(request) 24 | -------------------------------------------------------------------------------- /inventory_project/publish.py: -------------------------------------------------------------------------------- 1 | from poetry_publish.publish import poetry_publish 2 | from poetry_publish.utils.subprocess_utils import verbose_check_call 3 | 4 | # https://github.com/jedie/PyInventory 5 | import inventory 6 | from inventory_project import PACKAGE_ROOT 7 | 8 | 9 | def publish(): 10 | """ 11 | Publish to PyPi 12 | Call this via: 13 | $ poetry run publish 14 | """ 15 | verbose_check_call('make', 'pytest') # don't publish if tests fail 16 | verbose_check_call('make', 'fix-code-style') # don't publish if code style wrong 17 | 18 | poetry_publish( 19 | package_root=PACKAGE_ROOT, 20 | version=inventory.__version__, 21 | ) 22 | -------------------------------------------------------------------------------- /inventory_project/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory_project/settings/__init__.py -------------------------------------------------------------------------------- /inventory_project/settings/local.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: E405 2 | 3 | """ 4 | Django settings for local development 5 | """ 6 | 7 | import os as __os 8 | import sys as __sys 9 | 10 | from inventory_project.settings.prod import * # noqa 11 | 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | # Disable secure settings from prod.py: 17 | CSRF_COOKIE_SECURE = False 18 | SESSION_COOKIE_SECURE = False 19 | SECURE_PROXY_SSL_HEADER = None 20 | SECURE_SSL_REDIRECT = False 21 | SECURE_HSTS_PRELOAD = False 22 | 23 | # Serve static/media files for local development: 24 | SERVE_FILES = True 25 | 26 | 27 | # Disable caches: 28 | CACHES = {'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}} 29 | 30 | # Required for the debug toolbar to be displayed: 31 | INTERNAL_IPS = ('127.0.0.1', '0.0.0.0', 'localhost') 32 | 33 | ALLOWED_HOSTS = INTERNAL_IPS 34 | 35 | DATABASES = { 36 | 'default': { 37 | 'ENGINE': 'django.db.backends.sqlite3', 38 | 'NAME': str(BASE_PATH / 'inventory-database.sqlite3'), 39 | # https://docs.djangoproject.com/en/dev/ref/databases/#database-is-locked-errors 40 | 'timeout': 30, 41 | } 42 | } 43 | print(f'Use Database: {DATABASES["default"]["NAME"]!r}', file=__sys.stderr) 44 | 45 | # _____________________________________________________________________________ 46 | 47 | if __os.environ.get('AUTOLOGIN') != '0': 48 | # Auto login for dev. server: 49 | MIDDLEWARE = MIDDLEWARE.copy() 50 | MIDDLEWARE += ['django_tools.middlewares.local_auto_login.AlwaysLoggedInAsSuperUserMiddleware'] 51 | 52 | # _____________________________________________________________________________ 53 | # Manage Django Project 54 | 55 | INSTALLED_APPS.append('manage_django_project') 56 | 57 | # _____________________________________________________________________________ 58 | # Django-Debug-Toolbar 59 | 60 | 61 | INSTALLED_APPS.append('debug_toolbar') 62 | MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware') 63 | 64 | DEBUG_TOOLBAR_PATCH_SETTINGS = True 65 | from debug_toolbar.settings import CONFIG_DEFAULTS as DEBUG_TOOLBAR_CONFIG # noqa 66 | 67 | 68 | # Disable some more panels that will slow down the page: 69 | DEBUG_TOOLBAR_CONFIG['DISABLE_PANELS'].add('debug_toolbar.panels.sql.SQLPanel') 70 | DEBUG_TOOLBAR_CONFIG['DISABLE_PANELS'].add('debug_toolbar.panels.cache.CachePanel') 71 | 72 | # don't load jquery from ajax.googleapis.com, just use django's version: 73 | DEBUG_TOOLBAR_CONFIG['JQUERY_URL'] = STATIC_URL + 'admin/js/vendor/jquery/jquery.min.js' 74 | 75 | DEBUG_TOOLBAR_CONFIG['SHOW_TEMPLATE_CONTEXT'] = True 76 | DEBUG_TOOLBAR_CONFIG['SHOW_COLLAPSED'] = True # Show toolbar collapsed by default. 77 | DEBUG_TOOLBAR_CONFIG['SHOW_TOOLBAR_CALLBACK'] = 'inventory_project.middlewares.djdt_show' 78 | -------------------------------------------------------------------------------- /inventory_project/settings/prod.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base Django settings 3 | """ 4 | 5 | import logging 6 | from pathlib import Path as __Path 7 | 8 | 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | 12 | ############################################################################### 13 | 14 | # Build paths relative to the project root: 15 | BASE_PATH = __Path(__file__).parent.parent.parent 16 | print(f'BASE_PATH:{BASE_PATH}') 17 | assert __Path(BASE_PATH, 'inventory_project').is_dir() 18 | 19 | ############################################################################### 20 | # PyInventory: 21 | 22 | # Max length of Item/Location "path name" in change list: 23 | TREE_PATH_STR_MAX_LENGTH = 70 24 | 25 | ############################################################################### 26 | 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = False 30 | 31 | # Serve static/media files by Django? 32 | # In production the Webserver should serve this! 33 | SERVE_FILES = False 34 | 35 | 36 | # SECURITY WARNING: keep the secret key used in production secret! 37 | __SECRET_FILE = __Path(BASE_PATH, 'secret.txt').resolve() 38 | if not __SECRET_FILE.is_file(): 39 | print(f'Generate {__SECRET_FILE}') 40 | from secrets import token_urlsafe as __token_urlsafe 41 | 42 | __SECRET_FILE.write_text(__token_urlsafe(128)) 43 | 44 | SECRET_KEY = __SECRET_FILE.read_text().strip() 45 | 46 | 47 | # Application definition 48 | 49 | INSTALLED_APPS = [ 50 | 'django.contrib.admin', 51 | 'django.contrib.auth', 52 | 'django.contrib.contenttypes', 53 | 'django.contrib.sessions', 54 | 'django.contrib.messages', 55 | 'django.contrib.staticfiles', 56 | 'bx_django_utils', # https://github.com/boxine/bx_django_utils 57 | 'import_export', # https://github.com/django-import-export/django-import-export 58 | 'dbbackup', # https://github.com/django-dbbackup/django-dbbackup 59 | 'tinymce', # https://github.com/jazzband/django-tinymce/ 60 | 'reversion', # https://github.com/etianen/django-reversion 61 | 'reversion_compare', # https://github.com/jedie/django-reversion-compare 62 | 'tagulous', # https://github.com/radiac/django-tagulous 63 | 'adminsortable2', # https://github.com/jrief/django-admin-sortable2 64 | # https://github.com/jedie/django-tools/tree/master/django_tools/serve_media_app 65 | 'django_tools.serve_media_app.apps.UserMediaFilesConfig', 66 | # https://github.com/jedie/django-tools/tree/master/django_tools/model_version_protect 67 | 'django_tools.model_version_protect.apps.ModelVersionProtectConfig', 68 | # 69 | 'inventory.apps.AppConfig', 70 | ] 71 | 72 | ROOT_URLCONF = 'inventory_project.urls' 73 | WSGI_APPLICATION = 'inventory_project.wsgi.application' 74 | 75 | AUTHENTICATION_BACKENDS = [ 76 | 'django.contrib.auth.backends.ModelBackend', 77 | ] 78 | 79 | MIDDLEWARE = [ 80 | 'django.contrib.sessions.middleware.SessionMiddleware', 81 | 'django.middleware.locale.LocaleMiddleware', 82 | 'django.middleware.common.CommonMiddleware', 83 | 'django.middleware.csrf.CsrfViewMiddleware', 84 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 85 | 'inventory.middlewares.RequestDictMiddleware', 86 | 'django.contrib.messages.middleware.MessageMiddleware', 87 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 88 | 'django.middleware.security.SecurityMiddleware', 89 | ] 90 | 91 | __TEMPLATE_DIR = __Path(BASE_PATH, 'inventory_project', 'templates') 92 | assert __TEMPLATE_DIR.is_dir(), f'Directory not exists: {__TEMPLATE_DIR}' 93 | TEMPLATES = [ 94 | { 95 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 96 | "DIRS": [str(__TEMPLATE_DIR)], 97 | 'APP_DIRS': True, 98 | 'OPTIONS': { 99 | 'context_processors': [ 100 | 'django.contrib.auth.context_processors.auth', 101 | 'django.contrib.messages.context_processors.messages', 102 | 'django.template.context_processors.i18n', 103 | 'django.template.context_processors.debug', 104 | 'django.template.context_processors.request', 105 | 'django.template.context_processors.media', 106 | 'django.template.context_processors.csrf', 107 | 'django.template.context_processors.tz', 108 | 'django.template.context_processors.static', 109 | 'inventory.context_processors.inventory_version_string', 110 | ], 111 | }, 112 | }, 113 | ] 114 | 115 | # _____________________________________________________________________________ 116 | 117 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 118 | 119 | # _____________________________________________________________________________ 120 | 121 | # Mark CSRF cookie as "secure" -> browsers sent cookie only with an HTTPS connection: 122 | CSRF_COOKIE_SECURE = True 123 | 124 | # Mark session cookie as "secure" -> browsers sent cookie only with an HTTPS connection: 125 | SESSION_COOKIE_SECURE = True 126 | 127 | # HTTP header/value combination that signifies a request is secure 128 | # Your nginx.conf must set "X-Forwarded-Protocol" proxy header! 129 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') 130 | 131 | # SecurityMiddleware should redirects all non-HTTPS requests to HTTPS: 132 | SECURE_SSL_REDIRECT = True 133 | 134 | # SecurityMiddleware should preload directive to the HTTP Strict Transport Security header: 135 | SECURE_HSTS_PRELOAD = True 136 | 137 | # Instruct modern browsers to refuse to connect to your domain name via an insecure connection: 138 | SECURE_HSTS_SECONDS = 3600 139 | 140 | # SecurityMiddleware should add the "includeSubDomains" directive to the Strict-Transport-Security 141 | # header: All subdomains of your domain should be served exclusively via SSL! 142 | SECURE_HSTS_INCLUDE_SUBDOMAINS = True 143 | 144 | # _____________________________________________________________________________ 145 | # Internationalization 146 | 147 | LANGUAGE_CODE = 'en' 148 | 149 | LANGUAGES = [('ca', _('Catalan')), ('de', _('German')), ('en', _('English')), ('es', _('Spanish'))] 150 | USE_I18N = True 151 | TIME_ZONE = 'Europe/Paris' 152 | USE_TZ = True 153 | 154 | # _____________________________________________________________________________ 155 | # Static files (CSS, JavaScript, Images) 156 | 157 | STATIC_URL = '/static/' 158 | STATIC_ROOT = str(__Path(BASE_PATH, 'static')) 159 | 160 | MEDIA_URL = '/media/' 161 | MEDIA_ROOT = str(__Path(BASE_PATH, 'media')) 162 | 163 | # _____________________________________________________________________________ 164 | # Cache Backend 165 | 166 | CACHES = { 167 | 'default': { 168 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 169 | 'LOCATION': 'unique-snowflake', 170 | } 171 | } 172 | 173 | # _____________________________________________________________________________ 174 | # Django-dbbackup 175 | 176 | DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage' 177 | DBBACKUP_STORAGE_OPTIONS = {'location': str(__Path(BASE_PATH, 'backups'))} 178 | 179 | # _____________________________________________________________________________ 180 | # django-tinymce 181 | 182 | TINYMCE_DEFAULT_CONFIG = { 183 | # https://www.tiny.cloud/docs/tinymce/latest/editor-size-options/ 184 | 'height': 500, 185 | 'width': '90%', 186 | 'resize': 'both', 187 | # 188 | # https://www.tiny.cloud/docs/tinymce/latest/menus-configuration-options/ 189 | 'menubar': 'edit view insert format tools table help', 190 | 'browser_spellcheck': True, 191 | # 192 | # https://www.tiny.cloud/docs/tinymce/latest/plugins/ 193 | 'plugins': ( 194 | 'advlist,autolink,lists,link,image,charmap,preview,anchor,' 195 | 'searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,' 196 | 'help,wordcount' 197 | ), 198 | # 199 | # https://www.tiny.cloud/docs/tinymce/latest/available-toolbar-buttons/ 200 | 'toolbar': ( 201 | 'undo redo | blocks | ' 202 | 'bold italic backcolor | alignleft aligncenter ' 203 | 'alignright alignjustify | bullist numlist outdent indent | ' 204 | 'removeformat' 205 | ), 206 | } 207 | 208 | 209 | # _____________________________________________________________________________ 210 | # http://radiac.net/projects/django-tagulous/documentation/installation/#settings 211 | 212 | TAGULOUS_NAME_MAX_LENGTH = 255 213 | TAGULOUS_SLUG_MAX_LENGTH = 50 214 | TAGULOUS_LABEL_MAX_LENGTH = TAGULOUS_NAME_MAX_LENGTH 215 | TAGULOUS_SLUG_TRUNCATE_UNIQUE = 5 216 | TAGULOUS_SLUG_ALLOW_UNICODE = False 217 | 218 | SERIALIZATION_MODULES = { 219 | 'xml': 'tagulous.serializers.xml_serializer', 220 | 'json': 'tagulous.serializers.json', 221 | 'python': 'tagulous.serializers.python', 222 | 'yaml': 'tagulous.serializers.pyyaml', 223 | } 224 | 225 | # _____________________________________________________________________________ 226 | # cut 'pathname' in log output 227 | 228 | old_factory = logging.getLogRecordFactory() 229 | 230 | 231 | def cut_path(pathname, max_length): 232 | if len(pathname) <= max_length: 233 | return pathname 234 | return f'...{pathname[-(max_length - 3):]}' 235 | 236 | 237 | def record_factory(*args, **kwargs): 238 | record = old_factory(*args, **kwargs) 239 | record.cut_path = cut_path(record.pathname, 30) 240 | return record 241 | 242 | 243 | logging.setLogRecordFactory(record_factory) 244 | 245 | # ----------------------------------------------------------------------------- 246 | 247 | LOGGING = { 248 | 'version': 1, 249 | 'disable_existing_loggers': True, 250 | 'formatters': { 251 | 'colored': { # https://github.com/borntyping/python-colorlog 252 | '()': 'colorlog.ColoredFormatter', 253 | 'format': '%(log_color)s%(asctime)s %(levelname)8s %(cut_path)s:%(lineno)-3s %(message)s', 254 | } 255 | }, 256 | 'handlers': {'console': {'class': 'colorlog.StreamHandler', 'formatter': 'colored'}}, 257 | 'loggers': { 258 | '': {'handlers': ['console'], 'level': 'DEBUG', 'propagate': False}, 259 | 'django': {'handlers': ['console'], 'level': 'INFO', 'propagate': False}, 260 | 'django_tools': {'handlers': ['console'], 'level': 'INFO', 'propagate': False}, 261 | 'inventory': {'handlers': ['console'], 'level': 'DEBUG', 'propagate': False}, 262 | }, 263 | } 264 | -------------------------------------------------------------------------------- /inventory_project/settings/tests.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: E405 2 | """ 3 | Settings used to run tests 4 | """ 5 | import os 6 | 7 | from inventory_project.settings.prod import * # noqa 8 | 9 | 10 | ALLOWED_HOSTS = ['testserver'] 11 | 12 | 13 | # _____________________________________________________________________________ 14 | # Manage Django Project 15 | 16 | INSTALLED_APPS.append('manage_django_project') 17 | 18 | # _____________________________________________________________________________ 19 | 20 | 21 | DATABASES = { 22 | 'default': { 23 | 'ENGINE': 'django.db.backends.sqlite3', 24 | 'NAME': ':memory:', 25 | } 26 | } 27 | 28 | SECRET_KEY = 'No individual secret for tests ;)' 29 | 30 | DEBUG = True 31 | 32 | # Speedup tests by change the Password hasher: 33 | PASSWORD_HASHERS = ('django.contrib.auth.hashers.MD5PasswordHasher',) 34 | 35 | # _____________________________________________________________________________ 36 | 37 | 38 | # All tests should use django-override-storage! 39 | # Set root to not existing path, so that wrong tests will fail: 40 | STATIC_ROOT = '/not/exists/static/' 41 | MEDIA_ROOT = '/not/exists/media/' 42 | 43 | 44 | # _____________________________________________________________________________ 45 | # Playwright 46 | # Avoid django.core.exceptions.SynchronousOnlyOperation. Playwright uses an event loop, 47 | # even when using he sync API. Django only checks whether _any_ event loop is running, 48 | # but not if _itself_ is running in an even loop. 49 | # see https://github.com/microsoft/playwright-python/issues/439#issuecomment-763339612. 50 | os.environ.setdefault('DJANGO_ALLOW_ASYNC_UNSAFE', '1') 51 | -------------------------------------------------------------------------------- /inventory_project/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | {% load i18n static %} 3 | 4 | {% block extrahead %}{{ block.super }} 5 | 6 | 7 | 8 | {% endblock %} 9 | 10 | {% block title %}{{ title }} | PyInventory {{ inventory_version_string }}{% endblock %} 11 | 12 | {% block branding %} 13 |

14 | PyInventory {{ inventory_version_string }} 15 |

16 | {% endblock %} 17 | 18 | {% block nav-global %}{% endblock %} 19 | 20 | {% block footer %}{% endblock %} -------------------------------------------------------------------------------- /inventory_project/templates/admin/login.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/login.html" %} 2 | {% load i18n static %} 3 | 4 | {% block branding %}{# remove branding #}{% endblock %} 5 | 6 | {% block extrastyle %}{{ block.super }} 7 | 8 | {% endblock %} 9 | 10 | {% block content %} 11 | 12 | {% csrf_token %} 13 |
14 | {{ form.as_p }} 15 |
16 |
17 | 18 | 19 | 20 |
21 | 30 | {% endblock %} 31 | 32 | {% block footer %}{# remove footer #}{% endblock %} -------------------------------------------------------------------------------- /inventory_project/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedie/PyInventory/f29d28900bf6cfdf2f0e14b484361eed7be4a44a/inventory_project/tests/__init__.py -------------------------------------------------------------------------------- /inventory_project/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | from django.contrib.auth.models import User 4 | from model_bakery import baker 5 | from PIL import Image 6 | 7 | from inventory.permissions import get_or_create_normal_user_group 8 | 9 | 10 | def get_normal_user(): 11 | user = baker.make( 12 | User, 13 | id=1, 14 | username='NormalUser', 15 | is_staff=True, 16 | is_active=True, 17 | is_superuser=False, 18 | ) 19 | assert user.user_permissions.count() == 0 20 | group = get_or_create_normal_user_group()[0] 21 | user.groups.set([group]) 22 | user.full_clean() 23 | return user 24 | 25 | 26 | class TempImageFile: 27 | def __init__(self, prefix='test_image', format='png', size=(1, 1)): 28 | self.format = format 29 | self.image_size = size 30 | self.temp = tempfile.NamedTemporaryFile(prefix=prefix, suffix=f'.{format}') 31 | 32 | def __enter__(self): 33 | self.temp_file = self.temp.__enter__() 34 | pil_image = Image.new('RGB', self.image_size) 35 | pil_image.save(self.temp_file, format=self.format) 36 | self.temp_file.seek(0) 37 | return self.temp_file 38 | 39 | def __exit__(self, exc_type, exc_val, exc_tb): 40 | self.temp_file.__exit__(exc_type, exc_val, exc_tb) 41 | -------------------------------------------------------------------------------- /inventory_project/tests/mocks.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from bx_py_utils.test_utils.context_managers import MassContextManager 4 | 5 | from inventory import context_processors 6 | 7 | 8 | class MockInventoryVersionString(MassContextManager): 9 | def __init__(self): 10 | self.mocks = [ 11 | mock.patch.object(context_processors, '__version__', self), 12 | ] 13 | 14 | def __repr__(self): 15 | return 'MockedVersionString' 16 | -------------------------------------------------------------------------------- /inventory_project/tests/playwright_utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import User 3 | from django.test.client import Client 4 | from playwright.sync_api import Page 5 | 6 | 7 | def login(page: Page, client: Client, url: str, user: User) -> None: 8 | """ 9 | Helper to fast login, without using the login page. 10 | """ 11 | # Create a session by using Django's test login: 12 | client.force_login(user=user) 13 | session_cookie = client.cookies[settings.SESSION_COOKIE_NAME] 14 | assert session_cookie 15 | 16 | # Inject the session Cookie to playwright browser: 17 | cookie_object = { 18 | 'name': session_cookie.key, 19 | 'value': session_cookie.value, 20 | 'url': url, 21 | } 22 | page.context.add_cookies([cookie_object]) 23 | -------------------------------------------------------------------------------- /inventory_project/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from bx_django_utils.test_utils.html_assertion import HtmlAssertionMixin, assert_html_response_snapshot 2 | from django.contrib.auth.models import User 3 | from django.test import TestCase 4 | from model_bakery import baker 5 | 6 | from inventory import __version__ 7 | 8 | 9 | class AdminAnonymousTests(HtmlAssertionMixin, TestCase): 10 | """ 11 | Anonymous will be redirected to the login page. 12 | """ 13 | 14 | def test_login_en(self): 15 | response = self.client.get('/admin/', secure=True, headers={"accept-language": 'en'}) 16 | self.assertRedirects(response, expected_url='/admin/login/?next=/admin/', fetch_redirect_response=False) 17 | 18 | def test_login_de(self): 19 | response = self.client.get('/admin/', secure=True, headers={"accept-language": 'de'}) 20 | self.assertRedirects(response, expected_url='/admin/login/?next=/admin/', fetch_redirect_response=False) 21 | 22 | 23 | class AdminLoggedinTests(HtmlAssertionMixin, TestCase): 24 | """ 25 | Some basics test with the django admin 26 | """ 27 | 28 | @classmethod 29 | def setUpTestData(cls): 30 | cls.superuser = baker.make(User, username='superuser', is_staff=True, is_active=True, is_superuser=True) 31 | cls.staffuser = baker.make(User, username='staff_test_user', is_staff=True, is_active=True, is_superuser=False) 32 | 33 | def test_staff_admin_index(self): 34 | self.client.force_login(self.staffuser) 35 | 36 | response = self.client.get("/admin/", secure=True, headers={"accept-language": "en"}) 37 | self.assert_html_parts( 38 | response, 39 | parts=( 40 | f"Site administration | PyInventory v{__version__}", 41 | "

Site administration

", 42 | "staff_test_user", 43 | "

You don’t have permission to view or edit anything.

", 44 | ), 45 | ) 46 | self.assertTemplateUsed(response, template_name="admin/index.html") 47 | 48 | def test_superuser_admin_index(self): 49 | self.client.force_login(self.superuser) 50 | response = self.client.get("/admin/", secure=True, headers={"accept-language": "en"}) 51 | self.assert_html_parts( 52 | response, 53 | parts=( 54 | "inventory", 55 | "superuser", 56 | "Site administration", 57 | "/admin/auth/group/add/", 58 | "/admin/auth/user/add/", 59 | ), 60 | ) 61 | self.assertTemplateUsed(response, template_name="admin/index.html") 62 | assert_html_response_snapshot(response, validate=False) 63 | -------------------------------------------------------------------------------- /inventory_project/tests/test_admin_item_login_1.snapshot.html: -------------------------------------------------------------------------------- 1 |
2 | 5 | MockedCsrfTokenNode 6 |
7 |

8 | 11 | 12 |

13 |

14 | 17 | 18 |

19 |
20 |
21 | 24 | 26 | 29 |
30 | 39 |
40 |
-------------------------------------------------------------------------------- /inventory_project/tests/test_admin_memo.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from bx_django_utils.test_utils.html_assertion import HtmlAssertionMixin, assert_html_response_snapshot 4 | from django.contrib.auth.models import User 5 | from django.template.defaulttags import CsrfTokenNode, NowNode 6 | from django.test import TestCase, override_settings 7 | from django_tools.unittest_utils.mockup import ImageDummy 8 | from model_bakery import baker 9 | from override_storage import locmem_stats_override_storage 10 | from reversion.models import Revision 11 | 12 | from inventory.models import MemoImageModel, MemoModel 13 | from inventory.permissions import get_or_create_normal_user_group 14 | from inventory_project.tests.mocks import MockInventoryVersionString 15 | 16 | 17 | class AdminAnonymousTests(TestCase): 18 | def test_login(self): 19 | response = self.client.get('/admin/inventory/memomodel/add/', secure=True, headers={"accept-language": 'en'}) 20 | self.assertRedirects( 21 | response, 22 | expected_url='/admin/login/?next=/admin/inventory/memomodel/add/', 23 | fetch_redirect_response=False, 24 | ) 25 | 26 | 27 | @override_settings(SECURE_SSL_REDIRECT=False) 28 | class AdminTestCase(HtmlAssertionMixin, TestCase): 29 | @classmethod 30 | def setUpTestData(cls): 31 | cls.normaluser = baker.make(User, username='NormalUser', is_staff=True, is_active=True, is_superuser=False) 32 | assert cls.normaluser.user_permissions.count() == 0 33 | group = get_or_create_normal_user_group()[0] 34 | cls.normaluser.groups.set([group]) 35 | 36 | def test_normal_user_create_minimal_item(self): 37 | self.client.force_login(self.normaluser) 38 | 39 | with mock.patch.object(NowNode, 'render', return_value='MockedNowNode'), mock.patch.object( 40 | CsrfTokenNode, 'render', return_value='MockedCsrfTokenNode' 41 | ), MockInventoryVersionString(): 42 | response = self.client.get('/admin/inventory/memomodel/add/') 43 | assert response.status_code == 200 44 | self.assert_html_parts(response, parts=('Add Memo | PyInventory vMockedVersionString',)) 45 | assert_html_response_snapshot(response=response, validate=False) 46 | 47 | assert MemoModel.objects.count() == 0 48 | 49 | response = self.client.post( 50 | path='/admin/inventory/memomodel/add/', 51 | data={ 52 | 'version': 0, # VersionProtectBaseModel field 53 | 'name': 'The Memo Name', 54 | 'memo': 'This is a test Memo', 55 | 'memoimagemodel_set-TOTAL_FORMS': '0', 56 | 'memoimagemodel_set-INITIAL_FORMS': '0', 57 | 'memoimagemodel_set-MIN_NUM_FORMS': '0', 58 | 'memoimagemodel_set-MAX_NUM_FORMS': '1000', 59 | 'memoimagemodel_set-__prefix__-position': '0', 60 | 'memofilemodel_set-TOTAL_FORMS': '0', 61 | 'memofilemodel_set-INITIAL_FORMS': '0', 62 | 'memofilemodel_set-MIN_NUM_FORMS': '0', 63 | 'memofilemodel_set-MAX_NUM_FORMS': '1000', 64 | 'memofilemodel_set-__prefix__-position': '0', 65 | 'memolinkmodel_set-TOTAL_FORMS': '0', 66 | 'memolinkmodel_set-INITIAL_FORMS': '0', 67 | 'memolinkmodel_set-MIN_NUM_FORMS': '0', 68 | 'memolinkmodel_set-MAX_NUM_FORMS': '1000', 69 | 'memolinkmodel_set-__prefix__-position': '0', 70 | '_save': 'Save', 71 | }, 72 | ) 73 | assert response.status_code == 302, response.content.decode('utf-8') # Form error? 74 | self.assertRedirects(response, expected_url='/admin/inventory/memomodel/') 75 | 76 | data = list(MemoModel.objects.values_list('name', 'memo')) 77 | assert data == [('The Memo Name', 'This is a test Memo')] 78 | 79 | item = MemoModel.objects.first() 80 | 81 | self.assert_messages( 82 | response, 83 | expected_messages=[ 84 | f'The Memo “The Memo Name”' 85 | ' was added successfully.' 86 | ], 87 | ) 88 | 89 | assert item.user_id == self.normaluser.pk 90 | 91 | # django-revision integration: 92 | comments = list(Revision.objects.order_by('date_created').values_list('comment', flat=True)) 93 | self.assertEqual(comments, ['Added.']) 94 | 95 | def test_new_item_with_image(self): 96 | """ 97 | https://github.com/jedie/PyInventory/issues/33 98 | """ 99 | self.client.force_login(self.normaluser) 100 | 101 | img = ImageDummy(width=1, height=1, format='png').in_memory_image_file(filename='test.png') 102 | 103 | with locmem_stats_override_storage() as storage_stats: 104 | response = self.client.post( 105 | path='/admin/inventory/memomodel/add/', 106 | data={ 107 | 'version': 0, # VersionProtectBaseModel field 108 | 'name': 'The Memo Name', 109 | 'memo': 'This is a test Memo', 110 | 'memoimagemodel_set-TOTAL_FORMS': '1', 111 | 'memoimagemodel_set-INITIAL_FORMS': '0', 112 | 'memoimagemodel_set-MIN_NUM_FORMS': '0', 113 | 'memoimagemodel_set-MAX_NUM_FORMS': '1000', 114 | 'memoimagemodel_set-0-position': '0', 115 | 'memoimagemodel_set-__prefix__-position': '0', 116 | 'memoimagemodel_set-0-image': img, 117 | 'memofilemodel_set-TOTAL_FORMS': '0', 118 | 'memofilemodel_set-INITIAL_FORMS': '0', 119 | 'memofilemodel_set-MIN_NUM_FORMS': '0', 120 | 'memofilemodel_set-MAX_NUM_FORMS': '1000', 121 | 'memofilemodel_set-__prefix__-position': '0', 122 | 'memolinkmodel_set-TOTAL_FORMS': '0', 123 | 'memolinkmodel_set-INITIAL_FORMS': '0', 124 | 'memolinkmodel_set-MIN_NUM_FORMS': '0', 125 | 'memolinkmodel_set-MAX_NUM_FORMS': '1000', 126 | 'memolinkmodel_set-__prefix__-position': '0', 127 | '_save': 'Save', 128 | }, 129 | ) 130 | assert response.status_code == 302, response.content.decode('utf-8') # Form error? 131 | memo = MemoModel.objects.first() or MemoModel() 132 | self.assert_messages( 133 | response, 134 | expected_messages=[ 135 | f'The Memo “The Memo Name”' 136 | ' was added successfully.' 137 | ], 138 | ) 139 | self.assertRedirects(response, expected_url='/admin/inventory/memomodel/') 140 | 141 | data = list(MemoModel.objects.values_list('name', 'memo')) 142 | assert data == [('The Memo Name', 'This is a test Memo')] 143 | 144 | assert memo.user_id == self.normaluser.pk 145 | 146 | assert MemoImageModel.objects.count() == 1 147 | image = MemoImageModel.objects.first() 148 | assert image.name == 'test.png' 149 | assert image.memo == memo 150 | assert image.user_id == self.normaluser.pk 151 | 152 | # Test image file should be stored: 153 | self.assertEqual(storage_stats.save_cnt, 1) 154 | -------------------------------------------------------------------------------- /inventory_project/tests/test_admin_superuser_admin_index_1.snapshot.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Site administration 4 |

5 |
6 |
7 | 8 | 13 | 14 | 19 | 24 | 29 | 30 | 31 | 36 | 41 | 46 | 47 |
9 | 10 | Authentication and Authorization 11 | 12 |
15 | 16 | Groups 17 | 18 | 20 | 21 | Add 22 | 23 | 25 | 26 | Change 27 | 28 |
32 | 33 | Users 34 | 35 | 37 | 38 | Add 39 | 40 | 42 | 43 | Change 44 | 45 |
48 |
49 |
50 | 51 | 56 | 57 | 62 | 67 | 72 | 73 | 74 | 79 | 84 | 89 | 90 | 91 | 96 | 101 | 106 | 107 |
52 | 53 | Inventory 54 | 55 |
58 | 59 | Items 60 | 61 | 63 | 64 | Add 65 | 66 | 68 | 69 | Change 70 | 71 |
75 | 76 | Locations 77 | 78 | 80 | 81 | Add 82 | 83 | 85 | 86 | Change 87 | 88 |
92 | 93 | Memos 94 | 95 | 97 | 98 | Add 99 | 100 | 102 | 103 | Change 104 | 105 |
108 |
109 |
110 | 123 |
124 |
-------------------------------------------------------------------------------- /inventory_project/tests/test_inventory_commands.py: -------------------------------------------------------------------------------- 1 | from bx_py_utils.test_utils.snapshot import assert_text_snapshot 2 | from manage_django_project.tests.cmd2_test_utils import BaseShellTestCase 3 | 4 | 5 | class PyInventoryDevShellTestCase(BaseShellTestCase): 6 | def test_help(self): 7 | stdout, stderr = self.execute(command='help') 8 | self.assertEqual(stderr, '') 9 | self.assertIn('Documented commands', stdout) 10 | 11 | # Django commands: 12 | self.assertIn('django.core', stdout) 13 | self.assertIn('makemessages', stdout) 14 | self.assertIn('makemigrations', stdout) 15 | 16 | # manage_django_project: 17 | self.assertIn('manage_django_project', stdout) 18 | self.assertIn('run_dev_server', stdout) 19 | 20 | # Own commands: 21 | self.assertIn('inventory', stdout) 22 | self.assertIn('seed_data', stdout) 23 | self.assertIn('tree', stdout) 24 | 25 | assert_text_snapshot(got=stdout) 26 | -------------------------------------------------------------------------------- /inventory_project/tests/test_inventory_commands_help_1.snapshot.txt: -------------------------------------------------------------------------------- 1 | 2 | Documented commands (use 'help -v' for verbose/'help ' for details): 3 | 4 | adminsortable2 5 | ============== 6 | reorder 7 | 8 | dbbackup 9 | ======== 10 | dbbackup dbrestore listbackups mediabackup mediarestore 11 | 12 | django.contrib.auth 13 | =================== 14 | changepassword createsuperuser 15 | 16 | django.contrib.contenttypes 17 | =========================== 18 | remove_stale_contenttypes 19 | 20 | django.contrib.sessions 21 | ======================= 22 | clearsessions 23 | 24 | django.contrib.staticfiles 25 | ========================== 26 | collectstatic findstatic runserver 27 | 28 | django.core 29 | =========== 30 | check flush optimizemigration squashmigrations 31 | compilemessages inspectdb sendtestemail startapp 32 | createcachetable loaddata showmigrations startproject 33 | dbshell makemessages sqlflush test 34 | diffsettings makemigrations sqlmigrate testserver 35 | dumpdata migrate sqlsequencereset 36 | 37 | import_export 38 | ============= 39 | export import 40 | 41 | inventory 42 | ========= 43 | seed_data tree 44 | 45 | manage_django_project 46 | ===================== 47 | code_style nox project_info update_req 48 | coverage pip_audit publish update_test_snapshot_files 49 | install playwright run_dev_server 50 | 51 | reversion 52 | ========= 53 | createinitialrevisions deleterevisions 54 | 55 | tagulous 56 | ======== 57 | initial_tags 58 | 59 | Uncategorized 60 | ============= 61 | alias help history macro quit set shortcuts 62 | -------------------------------------------------------------------------------- /inventory_project/tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from django.core import management 4 | from django.core.management.commands import makemigrations 5 | from django.test import TestCase, override_settings 6 | 7 | 8 | class TestMigrations(TestCase): 9 | databases = [ 10 | 'default', 11 | ] 12 | 13 | @override_settings(MIGRATION_MODULES={}) 14 | def test_missing_migrations(self): 15 | output = io.StringIO() 16 | try: 17 | management.call_command( 18 | makemigrations.Command(), dry_run=True, check_changes=True, verbosity=1, stdout=output 19 | ) 20 | except SystemExit as err: 21 | if err.code != 0: 22 | raise AssertionError(output.getvalue()) 23 | -------------------------------------------------------------------------------- /inventory_project/tests/test_models_item.py: -------------------------------------------------------------------------------- 1 | from django.forms import CharField, modelform_factory 2 | from django.test import TestCase 3 | from tinymce.models import HTMLField 4 | from tinymce.widgets import TinyMCE 5 | 6 | from inventory.models import ItemModel 7 | 8 | 9 | class ItemModelTestCase(TestCase): 10 | def test_item_description_model_field(self): 11 | item = ItemModel() 12 | opts = item._meta 13 | model_description_field = opts.get_field('description') 14 | self.assertIsInstance(model_description_field, HTMLField) 15 | 16 | def test_item_description_form_fieldr(self): 17 | ItemForm = modelform_factory(ItemModel, fields=('description',)) 18 | form = ItemForm() 19 | form_field = form.fields['description'] 20 | self.assertIsInstance(form_field, CharField) 21 | widget = form_field.widget 22 | 23 | self.assertIsInstance(widget, TinyMCE) 24 | -------------------------------------------------------------------------------- /inventory_project/tests/test_playwright_admin.py: -------------------------------------------------------------------------------- 1 | from bx_django_utils.test_utils.playwright import PlaywrightTestCase 2 | from django.contrib.auth import authenticate 3 | from django.contrib.auth.models import User 4 | from django.http import HttpRequest 5 | from django.test import override_settings 6 | from override_storage import locmem_stats_override_storage 7 | from playwright.sync_api import BrowserContext, expect 8 | 9 | from inventory import __version__ 10 | from inventory.models import ItemImageModel, ItemLinkModel, ItemModel 11 | from inventory_project.tests.fixtures import TempImageFile, get_normal_user 12 | from inventory_project.tests.playwright_utils import login 13 | 14 | 15 | @override_settings(SECURE_SSL_REDIRECT=False) 16 | class PlaywrightInventoryTestCase(PlaywrightTestCase): 17 | def test_root_page(self): 18 | context: BrowserContext = self.browser.new_context( 19 | ignore_https_errors=True, 20 | locale='en_US', 21 | timezone_id='Europe/Berlin', 22 | ) 23 | with context.new_page() as page: 24 | page.goto(self.live_server_url) 25 | expect(page).to_have_url(f'{self.live_server_url}/admin/login/?next=/admin/') 26 | expect(page).to_have_title(f'Log in | PyInventory v{__version__}') 27 | 28 | def test_login(self): 29 | username = 'a-user' 30 | password = 'ThisIsNotAPassword!' 31 | superuser = User.objects.create_superuser(username=username, password=password) 32 | superuser.full_clean() 33 | 34 | user = authenticate(request=HttpRequest(), username=username, password=password) 35 | assert isinstance(user, User) 36 | 37 | context: BrowserContext = self.browser.new_context( 38 | ignore_https_errors=True, 39 | locale='en_US', 40 | timezone_id='Europe/Berlin', 41 | ) 42 | with context.new_page() as page: 43 | page.goto(self.live_server_url) 44 | expect(page).to_have_url(f'{self.live_server_url}/admin/login/?next=/admin/') 45 | expect(page).to_have_title(f'Log in | PyInventory v{__version__}') 46 | 47 | page.type('#id_username', username) 48 | page.type('#id_password', password) 49 | page.locator('text=Log in').click() 50 | 51 | expect(page).to_have_url(f'{self.live_server_url}/admin/') 52 | expect(page).to_have_title(f'Site administration | PyInventory v{__version__}') 53 | 54 | def test_admin(self): 55 | superuser = User.objects.create_superuser(username='foo', password='ThisIsNotAPassword!') 56 | superuser.full_clean() 57 | 58 | context: BrowserContext = self.browser.new_context( 59 | ignore_https_errors=True, 60 | locale='en_US', 61 | timezone_id='Europe/Berlin', 62 | ) 63 | with context.new_page() as page: 64 | login(page, self.client, url=self.live_server_url, user=superuser) 65 | 66 | page.goto(f'{self.live_server_url}/admin/') 67 | expect(page).to_have_url(f'{self.live_server_url}/admin/') 68 | expect(page).to_have_title(f'Site administration | PyInventory v{__version__}') 69 | 70 | def test_normal_user_create_item(self): 71 | normal_user = get_normal_user() 72 | 73 | context: BrowserContext = self.browser.new_context( 74 | ignore_https_errors=True, 75 | locale='en_US', 76 | timezone_id='Europe/Berlin', 77 | ) 78 | with context.new_page() as page, TempImageFile( 79 | format='png', size=(1, 1) 80 | ) as png_image, locmem_stats_override_storage() as storage_stats: 81 | login(page, self.client, url=self.live_server_url, user=normal_user) 82 | 83 | page.goto(f'{self.live_server_url}/admin/inventory/itemmodel/add/') 84 | expect(page).to_have_title(f'Add Item | PyInventory v{__version__}') 85 | 86 | page.locator('label:has-text("Kind:")') 87 | kind_field = page.locator('//input[@id="id_kind"]/..//input[@role="searchbox"]') 88 | kind_field.click() 89 | kind_field.fill('Mainboard') 90 | kind_field.press('Enter') 91 | 92 | page.locator('label:has-text("Producer:")') 93 | producer_field = page.locator('//input[@id="id_producer"]/..//input[@role="searchbox"]') 94 | producer_field.click() 95 | producer_field.fill('Triple D Int.Ltd.') 96 | producer_field.press('Enter') 97 | 98 | page.locator('label:has-text("Name:")') 99 | page.fill('//input[@id="id_name"]', 'TD-20 (8088)') 100 | 101 | # Add a Link: 102 | page.get_by_role('link', name='Add another Link').click() 103 | page.locator('#id_itemlinkmodel_set-0-url').click() 104 | page.locator('#id_itemlinkmodel_set-0-url').fill('http://test.tld/foo/bar') 105 | page.locator('#id_itemlinkmodel_set-0-name').click() 106 | page.locator('#id_itemlinkmodel_set-0-name').fill('The First Link') 107 | page.locator('#id_itemlinkmodel_set-0-tags').click() 108 | page.locator('#id_itemlinkmodel_set-0-tags').fill('a-link-tag') 109 | page.locator('#id_itemlinkmodel_set-0-tags').press('Tab') 110 | 111 | # Add Image 112 | page.get_by_role('link', name='Add another Image').click() 113 | page.locator('#id_itemimagemodel_set-0-image').click() 114 | page.locator('#id_itemimagemodel_set-0-image').set_input_files(png_image.name) 115 | page.locator('#id_itemimagemodel_set-0-name').click() 116 | page.locator('#id_itemimagemodel_set-0-name').fill('The Image Name') 117 | page.locator('#id_itemimagemodel_set-0-tags').click() 118 | page.locator('#id_itemimagemodel_set-0-tags').fill('a-image-tag') 119 | page.locator('#id_itemimagemodel_set-0-tags').press('Tab') 120 | 121 | assert ItemModel.objects.count() == 0 122 | 123 | # Save the item: 124 | page.locator('input:has-text("Save and continue editing")').click() 125 | page.locator('text=The Tunes Item “A Test Tunes Item” was added successfully. You may edit it again') 126 | page.locator('text="Triple D Int.Ltd." - TD-20 (8088)') 127 | 128 | assert ItemModel.objects.count() == 1 129 | item = ItemModel.objects.first() 130 | assert item.verbose_name() == 'Mainboard - "Triple D Int.Ltd." - TD-20 (8088)' 131 | 132 | # Save & continue? 133 | expect(page).to_have_url(f'{self.live_server_url}/admin/inventory/itemmodel/{item.id}/change/') 134 | 135 | # Check added image: 136 | page.locator('text=The Image Name') 137 | img = page.locator('//a[@class="image_file_input_preview"]/img') 138 | img.scroll_into_view_if_needed() 139 | img.is_visible() 140 | assert img.evaluate('image => image.complete') is True 141 | 142 | assert item.itemimagemodel_set.count() == 1 143 | image: ItemImageModel = item.itemimagemodel_set.first() 144 | assert str(image) == 'The Image Name' 145 | assert image.user == normal_user 146 | assert image.tags.get_tag_list() == ['a-image-tag'] 147 | 148 | # Check the added link: 149 | page.locator('text=The First Link') 150 | page.locator('text=Currently: http://test.tld/foo/bar') 151 | links = list(item.itemlinkmodel_set.values_list('name', 'url')) 152 | assert links == [('The First Link', 'http://test.tld/foo/bar')] 153 | link: ItemLinkModel = item.itemlinkmodel_set.first() 154 | assert link.tags.get_tag_list() == ['a-link-tag'] 155 | 156 | # The "png_image" file should be stored: 157 | self.assertEqual(storage_stats.save_cnt, 1) 158 | -------------------------------------------------------------------------------- /inventory_project/tests/test_project_setup.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | from unittest import TestCase 4 | 5 | from bx_py_utils.path import assert_is_dir, assert_is_file 6 | from django.conf import settings 7 | from django.core import checks 8 | from django.core.cache import cache 9 | from django.core.management import call_command 10 | from manage_django_project.management.commands import code_style 11 | from manageprojects.test_utils.project_setup import check_editor_config, get_py_max_line_length 12 | from packaging.version import Version 13 | 14 | from inventory import __version__ 15 | from manage import BASE_PATH 16 | 17 | 18 | class ProjectSetupTestCase(TestCase): 19 | def test_project_path(self): 20 | project_path = settings.BASE_PATH 21 | assert_is_dir(project_path) 22 | assert_is_dir(project_path / 'inventory') 23 | assert_is_dir(project_path / 'inventory_project') 24 | 25 | self.assertEqual(project_path, BASE_PATH) 26 | 27 | def test_template_dirs(self): 28 | assert len(settings.TEMPLATES) == 1 29 | dirs = settings.TEMPLATES[0].get('DIRS') 30 | assert len(dirs) == 1 31 | template_path = Path(dirs[0]).resolve() 32 | assert template_path.is_dir() 33 | 34 | def test_manage_check(self): 35 | all_issues = checks.run_checks( 36 | app_configs=None, 37 | tags=None, 38 | include_deployment_checks=True, 39 | databases=None, 40 | ) 41 | all_issue_ids = {issue.id for issue in all_issues} 42 | excpeted_issues = { 43 | 'async.E001', # DJANGO_ALLOW_ASYNC_UNSAFE set, because of playwright tests 44 | 'security.W009', # ignore fake SECRET_KEY in tests 45 | } 46 | if all_issue_ids != excpeted_issues: 47 | print('=' * 100) 48 | for issue in all_issues: 49 | print(issue) 50 | print('=' * 100) 51 | raise AssertionError(f'There are check issues (see blow): {all_issue_ids ^ excpeted_issues}') 52 | 53 | def test_cache(self): 54 | # django cache should work in tests, because some tests "depends" on it 55 | cache_key = 'a-cache-key' 56 | self.assertIs(cache.get(cache_key), None) 57 | cache.set(cache_key, 'the cache content', timeout=1) 58 | self.assertEqual(cache.get(cache_key), 'the cache content', f'Check: {settings.CACHES=}') 59 | cache.delete(cache_key) 60 | self.assertIs(cache.get(cache_key), None) 61 | 62 | def test_settings(self): 63 | self.assertEqual(settings.SETTINGS_MODULE, 'inventory_project.settings.tests') 64 | middlewares = [entry.rsplit('.', 1)[-1] for entry in settings.MIDDLEWARE] 65 | assert 'AlwaysLoggedInAsSuperUserMiddleware' not in middlewares 66 | assert 'DebugToolbarMiddleware' not in middlewares 67 | 68 | def test_version(self): 69 | self.assertIsNotNone(__version__) 70 | 71 | version = Version(__version__) # Will raise InvalidVersion() if wrong formatted 72 | self.assertEqual(str(version), __version__) 73 | 74 | manage_bin = BASE_PATH / 'manage.py' 75 | assert_is_file(manage_bin) 76 | 77 | output = subprocess.check_output([manage_bin, 'version'], text=True) 78 | self.assertIn(__version__, output) 79 | 80 | def test_manage(self): 81 | manage_bin = BASE_PATH / 'manage.py' 82 | assert_is_file(manage_bin) 83 | 84 | output = subprocess.check_output([manage_bin, 'project_info'], text=True) 85 | self.assertIn('inventory_project', output) 86 | self.assertIn('inventory_project.settings.local', output) 87 | self.assertIn('inventory_project.settings.tests', output) 88 | self.assertIn(__version__, output) 89 | 90 | output = subprocess.check_output([manage_bin, 'check'], text=True) 91 | self.assertIn('System check identified no issues (0 silenced).', output) 92 | 93 | output = subprocess.check_output([manage_bin, 'makemigrations'], text=True) 94 | self.assertIn("No changes detected", output) 95 | 96 | def test_code_style(self): 97 | call_command(code_style.Command()) 98 | 99 | def test_check_editor_config(self): 100 | check_editor_config(package_root=BASE_PATH) 101 | 102 | max_line_length = get_py_max_line_length(package_root=BASE_PATH) 103 | self.assertEqual(max_line_length, 119) 104 | -------------------------------------------------------------------------------- /inventory_project/tests/test_readme_history.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from bx_py_utils.auto_doc import assert_readme_block 4 | from cli_base.cli_tools.git_history import get_git_history 5 | 6 | import inventory 7 | from manage import BASE_PATH 8 | 9 | 10 | class ReadmeHistoryTestCase(TestCase): 11 | def test_readme_history(self): 12 | git_history = get_git_history( 13 | current_version=inventory.__version__, 14 | add_author=False, 15 | ) 16 | history = '\n'.join(git_history) 17 | assert_readme_block( 18 | readme_path=BASE_PATH / 'README.md', 19 | text_block=f'\n{history}\n', 20 | start_marker_line='[comment]: <> (✂✂✂ auto generated history start ✂✂✂)', 21 | end_marker_line='[comment]: <> (✂✂✂ auto generated history end ✂✂✂)', 22 | ) 23 | -------------------------------------------------------------------------------- /inventory_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import include, path 4 | from django.views.generic import RedirectView 5 | 6 | 7 | admin.autodiscover() 8 | 9 | urlpatterns = [ # Don't use i18n_patterns() here 10 | path('admin/', admin.site.urls), 11 | path('', RedirectView.as_view(pattern_name='admin:index')), 12 | path('tinymce/', include('tinymce.urls')), # TODO: check permissions? 13 | path(settings.MEDIA_URL.lstrip('/'), include('django_tools.serve_media_app.urls')), 14 | ] 15 | 16 | 17 | if settings.DEBUG: 18 | import debug_toolbar 19 | 20 | urlpatterns = [path('__debug__/', include(debug_toolbar.urls))] + urlpatterns 21 | -------------------------------------------------------------------------------- /inventory_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config 3 | """ 4 | 5 | 6 | from django.core.wsgi import get_wsgi_application 7 | 8 | 9 | application = get_wsgi_application() 10 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | bootstrap CLI 5 | ~~~~~~~~~~~~~ 6 | 7 | Just call this file, and the magic happens ;) 8 | """ 9 | 10 | import hashlib 11 | import os 12 | import shlex 13 | import signal 14 | import subprocess 15 | import sys 16 | import venv 17 | from pathlib import Path 18 | 19 | 20 | def print_no_pip_error(): 21 | print('Error: Pip not available!') 22 | print('Hint: "apt-get install python3-venv"\n') 23 | 24 | 25 | try: 26 | from ensurepip import version 27 | except ModuleNotFoundError as err: 28 | print(err) 29 | print('-' * 100) 30 | print_no_pip_error() 31 | raise 32 | else: 33 | if not version(): 34 | print_no_pip_error() 35 | sys.exit(-1) 36 | 37 | 38 | assert sys.version_info >= (3, 11), f'Python version {sys.version_info} is too old!' 39 | 40 | 41 | if sys.platform == 'win32': # wtf 42 | # Files under Windows, e.g.: .../.venv/Scripts/python.exe 43 | BIN_NAME = 'Scripts' 44 | FILE_EXT = '.exe' 45 | else: 46 | # Files under Linux/Mac and all other than Windows, e.g.: .../.venv/bin/python3 47 | BIN_NAME = 'bin' 48 | FILE_EXT = '' 49 | 50 | BASE_PATH = Path(__file__).parent 51 | VENV_PATH = BASE_PATH / '.venv' 52 | BIN_PATH = VENV_PATH / BIN_NAME 53 | PYTHON_PATH = BIN_PATH / f'python3{FILE_EXT}' 54 | PIP_PATH = BIN_PATH / f'pip{FILE_EXT}' 55 | UV_PATH = BIN_PATH / f'uv{FILE_EXT}' 56 | 57 | DEP_LOCK_PATH = BASE_PATH / 'uv.lock' 58 | DEP_HASH_PATH = VENV_PATH / '.dep_hash' 59 | 60 | # script file defined in pyproject.toml as [console_scripts] 61 | # (Under Windows: ".exe" not added!) 62 | PROJECT_SHELL_SCRIPT = BIN_PATH / 'inventory_project' 63 | 64 | 65 | def get_dep_hash(): 66 | """Get SHA512 hash from lock file content.""" 67 | return hashlib.sha512(DEP_LOCK_PATH.read_bytes()).hexdigest() 68 | 69 | 70 | def store_dep_hash(): 71 | """Generate .venv/.dep_hash""" 72 | DEP_HASH_PATH.write_text(get_dep_hash()) 73 | 74 | 75 | def venv_up2date(): 76 | """Is existing .venv is up-to-date?""" 77 | if DEP_HASH_PATH.is_file(): 78 | return DEP_HASH_PATH.read_text() == get_dep_hash() 79 | return False 80 | 81 | 82 | def verbose_check_call(*popen_args): 83 | print(f'\n+ {shlex.join(str(arg) for arg in popen_args)}\n') 84 | return subprocess.check_call(popen_args) 85 | 86 | 87 | def noop_sigint_handler(signal_num, frame): 88 | """ 89 | Don't exist cmd2 shell on "Interrupt from keyboard" 90 | e.g.: User stops the dev. server by CONTROL-C 91 | """ 92 | 93 | 94 | def main(argv): 95 | assert DEP_LOCK_PATH.is_file(), f'File not found: "{DEP_LOCK_PATH}" !' 96 | 97 | # Create virtual env in ".venv/": 98 | if not PYTHON_PATH.is_file(): 99 | print(f'Create virtual env here: {VENV_PATH.absolute()}') 100 | builder = venv.EnvBuilder(symlinks=True, upgrade=True, with_pip=True) 101 | builder.create(env_dir=VENV_PATH) 102 | 103 | # Set environment variable for uv to use '.venv-app' as project environment: 104 | os.environ['UV_PROJECT_ENVIRONMENT'] = str(VENV_PATH.absolute()) 105 | 106 | if not PROJECT_SHELL_SCRIPT.is_file() or not venv_up2date(): 107 | # Update pip 108 | verbose_check_call(PYTHON_PATH, '-m', 'pip', 'install', '-U', 'pip') 109 | 110 | # Install uv 111 | verbose_check_call(PYTHON_PATH, '-m', 'pip', 'install', '-U', 'uv') 112 | 113 | # install requirements 114 | verbose_check_call(UV_PATH, 'sync', '--frozen') 115 | 116 | # install project 117 | verbose_check_call(PIP_PATH, 'install', '--no-deps', '-e', '.') 118 | 119 | # Activate git pre-commit hooks: 120 | verbose_check_call(PYTHON_PATH, '-m', 'pre_commit', 'install') 121 | 122 | store_dep_hash() 123 | 124 | signal.signal(signal.SIGINT, noop_sigint_handler) # ignore "Interrupt from keyboard" signals 125 | 126 | # Call our entry point CLI: 127 | try: 128 | verbose_check_call(PROJECT_SHELL_SCRIPT, *argv[1:]) 129 | except subprocess.CalledProcessError as err: 130 | sys.exit(err.returncode) 131 | except KeyboardInterrupt: 132 | print('Bye!') 133 | sys.exit(130) 134 | 135 | 136 | if __name__ == '__main__': 137 | main(sys.argv) 138 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "PyInventory" 3 | dynamic = ["version"] 4 | description = "Web based management to catalog things including state and location etc. using Python/Django." 5 | license = {text = "GPL-3.0-or-later"} 6 | readme = "README.md" 7 | authors = [ 8 | {name = 'Jens Diemer', email = 'PyInventory@jensdiemer.de'} 9 | ] 10 | keywords=['inventory','django'] 11 | requires-python = ">=3.11" 12 | dependencies = [ 13 | # Stay with Django v5.1.x until https://github.com/radiac/django-tagulous/issues/187 is fixed 14 | "django!=5.2.0", # https://docs.djangoproject.com 15 | 16 | "colorlog", # https://github.com/borntyping/python-colorlog 17 | "gunicorn", # https://github.com/benoimyproject.wsgitc/gunicorn 18 | 19 | 20 | "django-import-export", # https://github.com/django-import-export/django-import-export 21 | "django-dbbackup", # https://github.com/django-dbbackup/django-dbbackup 22 | "django-tools", # https://github.com/jedie/django-tools/ 23 | "django-reversion-compare", # https://github.com/jedie/django-reversion-compare/ 24 | "django-tinymce", # https://github.com/jazzband/django-tinymce/ 25 | "django-tagulous", # https://github.com/radiac/django-tagulous 26 | "django-admin-sortable2", # https://github.com/jrief/django-admin-sortable2 27 | "pillow", # https://github.com/jrief/django-admin-sortable2 28 | 29 | "requests", # https://github.com/psf/requests 30 | 31 | "django-debug-toolbar", # http://django-debug-toolbar.readthedocs.io/en/stable/changes.html 32 | "bx_py_utils", # https://github.com/boxine/bx_py_utils 33 | "bx_django_utils", # https://github.com/boxine/bx_django_utils 34 | "django-axes", # https://github.com/jazzband/django-axes 35 | ] 36 | [dependency-groups] 37 | dev = [ 38 | "django-debug-toolbar", # http://django-debug-toolbar.readthedocs.io/en/stable/changes.html 39 | "manage_django_project", # https://github.com/jedie/manage_django_project 40 | # TODO: "autocron", # https://github.com/kbr/autocron 41 | 42 | "cmd2_ext_test", # https://github.com/python-cmd2/cmd2/tree/master/plugins/ext_test 43 | "beautifulsoup4", # https://www.crummy.com/software/BeautifulSoup/ 44 | 'lxml', # https://github.com/lxml/lxml 45 | 46 | "uv", # https://github.com/astral-sh/uv 47 | "hatchling", # https://github.com/pypa/hatch/tree/master/backend 48 | "playwright", # https://github.com/microsoft/playwright-python 49 | "tblib", # https://github.com/ionelmc/python-tblib 50 | "coverage", # https://github.com/nedbat/coveragepy 51 | "autopep8", # https://github.com/hhatto/autopep8 52 | "pyupgrade", # https://github.com/asottile/pyupgrade 53 | "flake8", # https://github.com/pycqa/flake8 54 | "flake8-bugbear", # https://github.com/PyCQA/flake8-bugbear 55 | "pyflakes", # https://github.com/PyCQA/pyflakes 56 | "codespell", # https://github.com/codespell-project/codespell 57 | "EditorConfig", # https://github.com/editorconfig/editorconfig-core-py 58 | "pip-audit", # https://github.com/pypa/pip-audit 59 | "mypy", # https://github.com/python/mypy 60 | "twine", # https://github.com/pypa/twine 61 | "pre-commit", # https://github.com/pre-commit/pre-commit 62 | "typeguard", # https://github.com/agronholm/typeguard/ 63 | 64 | # https://github.com/akaihola/darker 65 | # https://github.com/ikamensh/flynt 66 | # https://github.com/pycqa/isort 67 | # https://github.com/pygments/pygments 68 | "darker[flynt, isort, color]", 69 | 70 | "model_bakery", # https://github.com/model-bakers/model_bakery 71 | "requests-mock", # https://github.com/jamielennox/requests-mock 72 | "django-override-storage", # https://github.com/danifus/django-override-storage 73 | ] 74 | 75 | [project.urls] 76 | Documentation = "https://github.com/jedie/PyInventory" 77 | Source = "https://github.com/jedie/PyInventory" 78 | 79 | 80 | [project.scripts] 81 | # Must be set in ./manage.py and PROJECT_SHELL_SCRIPT: 82 | inventory_project = "inventory_project.__main__:main" 83 | 84 | [manage_django_project] 85 | module_name="inventory_project" 86 | 87 | # Django settings used for all commands except test/coverage/tox: 88 | local_settings='inventory_project.settings.local' 89 | 90 | # Django settings used for test/coverage/tox commands: 91 | test_settings='inventory_project.settings.tests' 92 | 93 | 94 | [build-system] 95 | requires = ["hatchling"] 96 | build-backend = "hatchling.build" 97 | 98 | [tool.hatch.build.targets.wheel] 99 | packages = ["inventory", "inventory_project"] 100 | 101 | [tool.hatch.version] 102 | path = "inventory/__init__.py" 103 | 104 | 105 | [tool.cli_base] 106 | version_module_name = "inventory" # Used by "update-readme-history" pre-commit hook 107 | 108 | 109 | [tool.cli_base.pip_audit] 110 | requirements=["requirements.dev.txt"] 111 | strict=true 112 | require_hashes=true 113 | ignore-vuln=[ 114 | # "CVE-2019-8341", # Jinja2: Side Template Injection (SSTI) 115 | ] 116 | 117 | 118 | 119 | 120 | 121 | [tool.darker] 122 | src = ['.'] 123 | revision = "origin/main..." 124 | line_length = 119 125 | color = true 126 | skip_string_normalization = true 127 | diff = false 128 | check = false 129 | stdout = false 130 | isort = true 131 | lint = [ 132 | "flake8", 133 | ] 134 | log_level = "INFO" 135 | 136 | 137 | [tool.isort] 138 | # https://pycqa.github.io/isort/docs/configuration/config_files/#pyprojecttoml-preferred-format 139 | atomic=true 140 | profile='black' 141 | skip_glob=['.*', '*/htmlcov/*','*/migrations/*'] 142 | known_first_party=['inventory'] 143 | line_length=119 144 | lines_after_imports=2 145 | 146 | 147 | [tool.coverage.run] # https://coverage.readthedocs.io/en/latest/config.html#run 148 | branch = true 149 | parallel = true 150 | concurrency = ["multiprocessing"] 151 | source = ['.'] 152 | command_line = '-m unittest --verbose --locals --buffer' 153 | 154 | [tool.coverage.report] 155 | omit = ['.*', '*/tests/*', '*/migrations/*'] 156 | skip_empty = true 157 | fail_under = 30 158 | show_missing = true 159 | exclude_lines = [ 160 | 'if self.debug:', 161 | 'pragma: no cover', 162 | 'raise NotImplementedError', 163 | 'if __name__ == .__main__.:', 164 | ] 165 | 166 | [tool.mypy] 167 | warn_unused_configs = true 168 | ignore_missing_imports = true 169 | allow_redefinition = true # https://github.com/python/mypy/issues/7165 170 | show_error_codes = true 171 | plugins = [] 172 | exclude = ['.venv', 'tests', 'migrations'] 173 | 174 | 175 | [manageprojects] # https://github.com/jedie/manageprojects 176 | initial_revision = "7cece02" 177 | initial_date = 2023-07-15T16:37:28+02:00 178 | cookiecutter_template = "https://github.com/jedie/cookiecutter_templates/" 179 | cookiecutter_directory = "managed-django-project" 180 | applied_migrations = [ 181 | "141b3e4", # 2024-09-05T17:53:31+02:00 182 | "a36dd75", # 2025-03-23T11:39:23+01:00 183 | "b3e0624", # 2025-05-01T00:07:45+02:00 184 | ] 185 | 186 | [manageprojects.cookiecutter_context.cookiecutter] 187 | full_name = "Jens Diemer" 188 | github_username = "jedie" 189 | author_email = "PyInventory@jensdiemer.de" 190 | package_name = "inventory" 191 | package_version = "0.21.0" 192 | package_description = "Web based management to catalog things including state and location etc. using Python/Django." 193 | package_url = "https://github.com/jedie/PyInventory" 194 | issues_url = "https://github.com/jedie/PyInventory/issues" 195 | license = "GPL-3.0-or-later" 196 | _template = "https://github.com/jedie/cookiecutter_templates/" 197 | applied_migrations = [ 198 | "8d0ebe1", # 2023-08-17T18:15:10+02:00 199 | ] 200 | --------------------------------------------------------------------------------