From 58effa8f2ef65902d694dcf536d669e55e09cc6a Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Sat, 24 Oct 2020 17:15:05 +0200 Subject: [PATCH 1/5] implement multi user usage --- README.creole | 24 +++++++++++++++++++++++ README.rst | 32 ++++++++++++++++++++++++++++++- inventory/admin/base.py | 4 ++++ inventory/admin/item.py | 36 +++++++++++++++++++++++++++++++++++ inventory/admin/location.py | 12 +++++++++++- inventory/checks.py | 26 ++++++++++++++++++++++++- inventory/forms.py | 35 ++++++++++++++++++++++++++++++++++ inventory/middlewares.py | 20 +++++++++++++++++++ inventory/permissions.py | 34 +++++++++++++++++++++++++++++++++ inventory/request_dict.py | 16 ++++++++++++++++ inventory_project/settings.py | 4 +++- 11 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 inventory/forms.py create mode 100644 inventory/middlewares.py create mode 100644 inventory/permissions.py create mode 100644 inventory/request_dict.py diff --git a/README.creole b/README.creole index 5e76486..dbba62a 100644 --- a/README.creole +++ b/README.creole @@ -44,6 +44,8 @@ There exists two kind of installation/usage: * local virtualenv (without docker) * docker-compose +see below + === prepare {{{ @@ -147,6 +149,28 @@ Notes: ---- +== Multi user usage + +PyInventory supports multiple users. The idea: + +* Every normal user sees only his own created database entries +* All users used the Django admin + +Note: All created Tags are shared for all existing users! + + +So setup a normal user: + +* Set "Staff status" +* Unset "Superuser status" +* Add user to "normal_user" group +* Don't add any additional permissions + +e.g.: + +{{https://raw.githubusercontent.com/jedie/jedie.github.io/master/screenshots/PyInventory/PyInventory normal user example.png|normal user example}} + + == Backwards-incompatible changes Nothing, yet ;) diff --git a/README.rst b/README.rst index 15099e2..07dd187 100644 --- a/README.rst +++ b/README.rst @@ -79,6 +79,8 @@ There exists two kind of installation/usage: * docker-compose +see below + prepare ======= @@ -191,6 +193,34 @@ Screenshots ---- +---------------- +Multi user usage +---------------- + +PyInventory supports multiple users. The idea: + +* Every normal user sees only his own created database entries + +* All users used the Django admin + +Note: All created Tags are shared for all existing users! + +So setup a normal user: + +* Set "Staff status" + +* Unset "Superuser status" + +* Add user to "normal_user" group + +* Don't add any additional permissions + +e.g.: + +|normal user example| + +.. |normal user example| image:: https://raw.githubusercontent.com/jedie/jedie.github.io/master/screenshots/PyInventory/PyInventory normal user example.png + ------------------------------ Backwards-incompatible changes ------------------------------ @@ -246,4 +276,4 @@ donation ------------ -``Note: this file is generated from README.creole 2020-10-24 16:39:24 with "python-creole"`` \ No newline at end of file +``Note: this file is generated from README.creole 2020-10-24 17:15:43 with "python-creole"`` \ No newline at end of file diff --git a/inventory/admin/base.py b/inventory/admin/base.py index 8f86bef..f1660a3 100644 --- a/inventory/admin/base.py +++ b/inventory/admin/base.py @@ -2,6 +2,10 @@ from reversion_compare.admin import CompareVersionAdmin class BaseUserAdmin(CompareVersionAdmin): + def get_changelist(self, request, **kwargs): + self.user = request.user + return super().get_changelist(request, **kwargs) + def save_model(self, request, obj, form, change): if obj.user_id is None: obj.user = request.user diff --git a/inventory/admin/item.py b/inventory/admin/item.py index 195ce2a..9162eb5 100644 --- a/inventory/admin/item.py +++ b/inventory/admin/item.py @@ -1,11 +1,14 @@ import tagulous from adminsortable2.admin import SortableInlineAdminMixin from django.contrib import admin +from django.contrib.admin.views.main import ChangeList +from django.template.loader import render_to_string from django.utils.translation import ugettext_lazy as _ from import_export.admin import ImportExportMixin from import_export.resources import ModelResource from inventory.admin.base import BaseUserAdmin +from inventory.forms import ItemModelModelForm from inventory.models import ItemLinkModel, ItemModel @@ -13,6 +16,15 @@ class ItemLinkModelInline(SortableInlineAdminMixin, admin.TabularInline): model = ItemLinkModel extra = 1 + def get_queryset(self, request): + qs = super().get_queryset(request) + + if not request.user.is_superuser: + # Display only own created entries + qs = qs.filter(user=request.user) + + return qs + class ItemModelResource(ModelResource): @@ -20,8 +32,19 @@ class ItemModelResource(ModelResource): model = ItemModel +class ItemModelChangeList(ChangeList): + def get_queryset(self, request): + """ + List always the base instances + """ + qs = super().get_queryset(request) + qs = qs.filter(parent__isnull=True) + return qs + + @admin.register(ItemModel) class ItemModelAdmin(ImportExportMixin, BaseUserAdmin): + form = ItemModelModelForm date_hierarchy = 'create_dt' list_display = ( 'kind', 'producer', @@ -76,5 +99,18 @@ class ItemModelAdmin(ImportExportMixin, BaseUserAdmin): readonly_fields = ('id', 'create_dt', 'update_dt', 'user') inlines = (ItemLinkModelInline,) + def get_changelist(self, request, **kwargs): + self.user = request.user + return ItemModelChangeList + + def get_queryset(self, request): + qs = super().get_queryset(request) + + if not request.user.is_superuser: + # Display only own created entries + qs = qs.filter(user=request.user) + + return qs + tagulous.admin.enhance(ItemModel, ItemModelAdmin) diff --git a/inventory/admin/location.py b/inventory/admin/location.py index 1c6fc68..2834f49 100644 --- a/inventory/admin/location.py +++ b/inventory/admin/location.py @@ -3,6 +3,7 @@ from import_export.admin import ImportExportMixin from import_export.resources import ModelResource from inventory.admin.base import BaseUserAdmin +from inventory.forms import LocationModelModelForm from inventory.models import LocationModel @@ -14,4 +15,13 @@ class LocationModelResource(ModelResource): @admin.register(LocationModel) class LocationModelAdmin(ImportExportMixin, BaseUserAdmin): - pass + form = LocationModelModelForm + + def get_queryset(self, request): + qs = super().get_queryset(request) + + if not request.user.is_superuser: + # Display only own created entries + qs = qs.filter(user=request.user) + + return qs diff --git a/inventory/checks.py b/inventory/checks.py index 5d83730..cd0ffc0 100644 --- a/inventory/checks.py +++ b/inventory/checks.py @@ -1,6 +1,8 @@ from pathlib import Path -from django.core.checks import Error, register +from django.core.checks import Error, Warning, register + +from inventory.permissions import get_or_create_normal_user_group, setup_normal_user_permissions @register() @@ -15,3 +17,25 @@ def inventory_checks(app_configs, **kwargs): ) ) return errors + + +@register() +def inventory_user_groups(app_configs, **kwargs): + """ + Setup PyInventory user groups + """ + warnings = [] + + normal_user_group, created = get_or_create_normal_user_group() + if created: + warnings.append( + Warning(f'User group {normal_user_group} created') + ) + + updated = setup_normal_user_permissions(normal_user_group) + if updated: + warnings.append( + Warning(f'Update permissions for {normal_user_group}') + ) + + return warnings diff --git a/inventory/forms.py b/inventory/forms.py new file mode 100644 index 0000000..f329dd3 --- /dev/null +++ b/inventory/forms.py @@ -0,0 +1,35 @@ +from django import forms +from django.core.exceptions import FieldDoesNotExist + +from inventory.request_dict import get_request_dict + + +class BaseUserOnlyModelForm(forms.ModelForm): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Filter all related fields that has a "user" attribute for the current user + # e.g.: + # The user should only select his own "location" and "items" + + user = get_request_dict()['user'] # get current user via threading.local() + for formfield in self.fields.values(): + if not hasattr(formfield, 'queryset'): + continue + + queryset = formfield.queryset + opts = queryset.model._meta + try: + opts.get_field('user') + except FieldDoesNotExist: + continue + + formfield.queryset = queryset.filter(user=user) + + +class ItemModelModelForm(BaseUserOnlyModelForm): + pass + + +class LocationModelModelForm(BaseUserOnlyModelForm): + pass diff --git a/inventory/middlewares.py b/inventory/middlewares.py new file mode 100644 index 0000000..98066c3 --- /dev/null +++ b/inventory/middlewares.py @@ -0,0 +1,20 @@ +from inventory.request_dict import clear_request_dict, get_request_dict + + +class RequestDictMiddleware: + """ + Make the "current user" information avaiable everywhere via threading.local() + Access e.g.: + user = get_request_dict()['user'] + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + get_request_dict().update(user=request.user) + + response = self.get_response(request) + + clear_request_dict() + + return response diff --git a/inventory/permissions.py b/inventory/permissions.py new file mode 100644 index 0000000..95f3abe --- /dev/null +++ b/inventory/permissions.py @@ -0,0 +1,34 @@ +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType + +from inventory.models import ItemLinkModel, ItemModel, LocationModel + + +NORMAL_USER_GROUP_NAME = 'normal user' + + +def get_permissions(*models): + content_types = [] + for model in models: + content_types.append(ContentType.objects.get_for_model(model)) + + return Permission.objects.filter(content_type__in=content_types) + + +def get_or_create_normal_user_group(): + return Group.objects.get_or_create(name=NORMAL_USER_GROUP_NAME) + + +def setup_normal_user_permissions(normal_user_group): + """ + Setup PyInventory "normal user" permissions + """ + assert normal_user_group.name == NORMAL_USER_GROUP_NAME + permissions = get_permissions(ItemModel, ItemLinkModel, LocationModel) + existing_permissions = normal_user_group.permissions.all() + + if set(permissions) != set(existing_permissions): + normal_user_group.permissions.set(permissions) + return True + + return False diff --git a/inventory/request_dict.py b/inventory/request_dict.py new file mode 100644 index 0000000..37479ca --- /dev/null +++ b/inventory/request_dict.py @@ -0,0 +1,16 @@ +import threading + + +__request_dict = threading.local() + + +def get_request_dict(): + try: + return __request_dict.context + except AttributeError: + __request_dict.context = {} + return __request_dict.context + + +def clear_request_dict(): + __request_dict.context = {} diff --git a/inventory_project/settings.py b/inventory_project/settings.py index edc0f3c..d44fb6c 100644 --- a/inventory_project/settings.py +++ b/inventory_project/settings.py @@ -58,10 +58,12 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + + 'inventory.middlewares.RequestDictMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', - 'django_tools.middlewares.ThreadLocal.ThreadLocalMiddleware', ] TEMPLATES = [ From 0e28d53743b90b1b82ce60d9e16aafba729f9ec1 Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Sat, 24 Oct 2020 19:11:31 +0200 Subject: [PATCH 2/5] Make Location.description optional --- .../migrations/0003_auto_20201024_1830.py | 19 +++++++++++++++++++ inventory/models/location.py | 1 + 2 files changed, 20 insertions(+) create mode 100644 inventory/migrations/0003_auto_20201024_1830.py diff --git a/inventory/migrations/0003_auto_20201024_1830.py b/inventory/migrations/0003_auto_20201024_1830.py new file mode 100644 index 0000000..ad2b3b5 --- /dev/null +++ b/inventory/migrations/0003_auto_20201024_1830.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.16 on 2020-10-24 16:30 + +import ckeditor_uploader.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0002_auto_20201017_2211'), + ] + + operations = [ + migrations.AlterField( + model_name='locationmodel', + name='description', + field=ckeditor_uploader.fields.RichTextUploadingField(blank=True, help_text='LocationModel.description.help_text', null=True, verbose_name='LocationModel.description.verbose_name'), + ), + ] diff --git a/inventory/models/location.py b/inventory/models/location.py index f2be037..057ca58 100644 --- a/inventory/models/location.py +++ b/inventory/models/location.py @@ -10,6 +10,7 @@ class LocationModel(BaseModel): A Storage for items. """ description = RichTextUploadingField( + blank=True, null=True, config_name='LocationModel.description', verbose_name=_('LocationModel.description.verbose_name'), help_text=_('LocationModel.description.help_text') From 2a4d6a198894f29396d70ec01874d8e834d8ec67 Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Sat, 24 Oct 2020 19:11:59 +0200 Subject: [PATCH 3/5] "merge" nested items --- inventory/admin/item.py | 21 ++++++++++++++++--- inventory/models/item.py | 9 ++++++++ .../admin/inventory/item/column_item.html | 8 +++++++ 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 inventory/templates/admin/inventory/item/column_item.html diff --git a/inventory/admin/item.py b/inventory/admin/item.py index 9162eb5..cbb7a2b 100644 --- a/inventory/admin/item.py +++ b/inventory/admin/item.py @@ -45,15 +45,30 @@ class ItemModelChangeList(ChangeList): @admin.register(ItemModel) class ItemModelAdmin(ImportExportMixin, BaseUserAdmin): form = ItemModelModelForm + + def column_item(self, obj): + qs = ItemModel.objects.filter(user=self.user) + qs = qs.filter(parent=obj) + context = { + 'base_item': obj, + 'sub_items': qs + } + return render_to_string( + template_name='admin/inventory/item/column_item.html', + context=context, + ) + + column_item.short_description = _('ItemModel.verbose_name_plural') + date_hierarchy = 'create_dt' list_display = ( 'kind', 'producer', - 'name', - 'parent', 'location', + 'column_item', + 'location', 'received_date', 'update_dt' ) ordering = ('kind', 'producer', 'name') - list_display_links = ('name',) + list_display_links = None list_filter = ('kind', 'location', 'producer', 'tags') search_fields = ('name', 'description') fieldsets = ( diff --git a/inventory/models/item.py b/inventory/models/item.py index 7d3efad..3590a04 100644 --- a/inventory/models/item.py +++ b/inventory/models/item.py @@ -1,6 +1,7 @@ import tagulous.models from ckeditor_uploader.fields import RichTextUploadingField from django.db import models +from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from inventory.models.base import BaseModel @@ -117,6 +118,14 @@ class ItemModel(BaseModel): help_text=_('ItemModel.handed_over_price.help_text') ) + def local_admin_link(self): + url = reverse('admin:inventory_itemmodel_change', args=[self.id]) + return url + + def verbose_name(self): + parts = [str(part) for part in (self.kind, self.producer, self.name)] + return ' - '.join(part for part in parts if part) + def __str__(self): if self.parent_id is None: title = self.name diff --git a/inventory/templates/admin/inventory/item/column_item.html b/inventory/templates/admin/inventory/item/column_item.html new file mode 100644 index 0000000..fce3772 --- /dev/null +++ b/inventory/templates/admin/inventory/item/column_item.html @@ -0,0 +1,8 @@ +{{ base_item.name }} +{% if sub_items %} + +{% endif %} \ No newline at end of file From 55760dc9735757580faffdda7bd165965ad19a7c Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Sat, 24 Oct 2020 19:24:01 +0200 Subject: [PATCH 4/5] bugfix BaseUserOnlyModelForm --- inventory/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inventory/forms.py b/inventory/forms.py index f329dd3..bc22859 100644 --- a/inventory/forms.py +++ b/inventory/forms.py @@ -5,8 +5,8 @@ from inventory.request_dict import get_request_dict class BaseUserOnlyModelForm(forms.ModelForm): - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # Filter all related fields that has a "user" attribute for the current user # e.g.: From b3220522f177c68377f0df5fd0cc416658e7d66c Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Sat, 24 Oct 2020 19:24:13 +0200 Subject: [PATCH 5/5] sort nestet items --- inventory/admin/item.py | 2 +- inventory/models/item.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/inventory/admin/item.py b/inventory/admin/item.py index cbb7a2b..e8b469c 100644 --- a/inventory/admin/item.py +++ b/inventory/admin/item.py @@ -48,7 +48,7 @@ class ItemModelAdmin(ImportExportMixin, BaseUserAdmin): def column_item(self, obj): qs = ItemModel.objects.filter(user=self.user) - qs = qs.filter(parent=obj) + qs = qs.filter(parent=obj).sort() context = { 'base_item': obj, 'sub_items': qs diff --git a/inventory/models/item.py b/inventory/models/item.py index 3590a04..ea30908 100644 --- a/inventory/models/item.py +++ b/inventory/models/item.py @@ -8,10 +8,17 @@ from inventory.models.base import BaseModel from inventory.models.links import BaseLink +class ItemQuerySet(models.QuerySet): + def sort(self): + return self.order_by('kind', 'producer', 'name') + + class ItemModel(BaseModel): """ A Item that can be described and store somewhere ;) """ + objects = ItemQuerySet.as_manager() + kind = tagulous.models.TagField( case_sensitive=False, force_lowercase=False,