diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index f9e3285781..21b8a0ead1 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -138,6 +138,7 @@ INSTALLED_APPS = [ 'part.apps.PartConfig', 'report.apps.ReportConfig', 'stock.apps.StockConfig', + 'users.apps.UsersConfig', # Third part add-ons 'django_filters', # Extended filter functionality diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 0afb18f616..5f84ce507f 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -38,4 +38,5 @@ class CompanyConfig(AppConfig): company.image = None company.save() except (OperationalError, ProgrammingError): - print("Could not generate Company thumbnails") + # Getting here probably meant the database was in test mode + pass diff --git a/InvenTree/company/migrations/0019_auto_20200413_0642.py b/InvenTree/company/migrations/0019_auto_20200413_0642.py index c81dfd795a..c3c2f58ea0 100644 --- a/InvenTree/company/migrations/0019_auto_20200413_0642.py +++ b/InvenTree/company/migrations/0019_auto_20200413_0642.py @@ -24,7 +24,6 @@ def reverse_association(apps, schema_editor): # Exit if there are no SupplierPart objects # This crucial otherwise the unit test suite fails! if SupplierPart.objects.count() == 0: - print("No SupplierPart objects - skipping") return print("Reversing migration for manufacturer association") @@ -105,7 +104,6 @@ def associate_manufacturers(apps, schema_editor): # Exit if there are no SupplierPart objects # This crucial otherwise the unit test suite fails! if SupplierPart.objects.count() == 0: - print("No SupplierPart objects - skipping") return # Link a 'manufacturer_name' to a 'Company' diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index 2137ec5d89..198e58e337 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -37,4 +37,4 @@ class PartConfig(AppConfig): part.image = None part.save() except (OperationalError, ProgrammingError): - print("Could not generate Part thumbnails") + pass diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py index b12fa73f94..86d4bb1a86 100644 --- a/InvenTree/users/admin.py +++ b/InvenTree/users/admin.py @@ -1,3 +1,135 @@ # -*- coding: utf-8 -*- -# from __future__ import unicode_literals -# from django.contrib import admin +from __future__ import unicode_literals + +from django.utils.translation import ugettext_lazy as _ + +from django.contrib import admin +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.contrib.auth.models import Group +from django.contrib.auth.admin import UserAdmin + +from users.models import RuleSet + +User = get_user_model() + + +class RuleSetInline(admin.TabularInline): + """ + Class for displaying inline RuleSet data in the Group admin page. + """ + + model = RuleSet + can_delete = False + verbose_name = 'Ruleset' + verbose_plural_name = 'Rulesets' + fields = ['name'] + [option for option in RuleSet.RULE_OPTIONS] + readonly_fields = ['name'] + max_num = len(RuleSet.RULESET_CHOICES) + min_num = 1 + extra = 0 + + +class InvenTreeGroupAdminForm(forms.ModelForm): + """ + Custom admin form for the Group model. + + Adds the ability for editing user membership directly in the group admin page. + """ + + class Meta: + model = Group + exclude = [] + fields = [ + 'name', + 'users', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + # Populate the users field with the current Group users. + self.fields['users'].initial = self.instance.user_set.all() + + # Add the users field. + users = forms.ModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + widget=FilteredSelectMultiple('users', False), + label=_('Users'), + help_text=_('Select which users are assigned to this group') + ) + + def save_m2m(self): + # Add the users to the Group. + + self.instance.user_set.set(self.cleaned_data['users']) + + def save(self, *args, **kwargs): + # Default save + instance = super().save() + # Save many-to-many data + self.save_m2m() + return instance + + +class RoleGroupAdmin(admin.ModelAdmin): + """ + Custom admin interface for the Group model + """ + + form = InvenTreeGroupAdminForm + + inlines = [ + RuleSetInline, + ] + + def get_formsets_with_inlines(self, request, obj=None): + for inline in self.get_inline_instances(request, obj): + # Hide RuleSetInline in the 'Add role' view + if not isinstance(inline, RuleSetInline) or obj is not None: + yield inline.get_formset(request, obj), inline + + filter_horizontal = ['permissions'] + + # Save inlines before model + # https://stackoverflow.com/a/14860703/12794913 + def save_model(self, request, obj, form, change): + if obj is not None: + # Save model immediately only if in 'Add role' view + super().save_model(request, obj, form, change) + else: + pass # don't actually save the parent instance + + def save_formset(self, request, form, formset, change): + formset.save() # this will save the children + form.instance.save() # form.instance is the parent + + +class InvenTreeUserAdmin(UserAdmin): + """ + Custom admin page for the User model. + + Hides the "permissions" view as this is now handled + entirely by groups and RuleSets. + + (And it's confusing!) + """ + + fieldsets = ( + (None, {'fields': ('username', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + ) + + +admin.site.unregister(Group) +admin.site.register(Group, RoleGroupAdmin) + +admin.site.unregister(User) +admin.site.register(User, InvenTreeUserAdmin) diff --git a/InvenTree/users/apps.py b/InvenTree/users/apps.py index 251989770b..07e303c1be 100644 --- a/InvenTree/users/apps.py +++ b/InvenTree/users/apps.py @@ -1,8 +1,33 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.db.utils import OperationalError, ProgrammingError + from django.apps import AppConfig class UsersConfig(AppConfig): name = 'users' + + def ready(self): + + try: + self.assign_permissions() + except (OperationalError, ProgrammingError): + pass + + def assign_permissions(self): + + from django.contrib.auth.models import Group + from users.models import RuleSet, update_group_roles + + # First, delete any rule_set objects which have become outdated! + for rule in RuleSet.objects.all(): + if rule.name not in RuleSet.RULESET_NAMES: + print("need to delete:", rule.name) + rule.delete() + + # Update group permission assignments for all groups + for group in Group.objects.all(): + + update_group_roles(group) diff --git a/InvenTree/users/migrations/0001_initial.py b/InvenTree/users/migrations/0001_initial.py new file mode 100644 index 0000000000..04071c5a63 --- /dev/null +++ b/InvenTree/users/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.7 on 2020-10-03 13:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ] + + operations = [ + migrations.CreateModel( + name='RuleSet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(choices=[('general', 'General'), ('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('supplier', 'Suppliers'), ('purchase_order', 'Purchase Orders'), ('customer', 'Customers'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50)), + ('can_view', models.BooleanField(default=True, help_text='Permission to view items', verbose_name='View')), + ('can_add', models.BooleanField(default=False, help_text='Permission to add items', verbose_name='Create')), + ('can_change', models.BooleanField(default=False, help_text='Permissions to edit items', verbose_name='Update')), + ('can_delete', models.BooleanField(default=False, help_text='Permission to delete items', verbose_name='Delete')), + ('group', models.ForeignKey(help_text='Group', on_delete=django.db.models.deletion.CASCADE, related_name='rule_sets', to='auth.Group')), + ], + options={ + 'unique_together': {('name', 'group')}, + }, + ), + ] diff --git a/InvenTree/users/migrations/0002_auto_20201004_0158.py b/InvenTree/users/migrations/0002_auto_20201004_0158.py new file mode 100644 index 0000000000..a0573a89be --- /dev/null +++ b/InvenTree/users/migrations/0002_auto_20201004_0158.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2020-10-04 01:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='ruleset', + name='name', + field=models.CharField(choices=[('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50), + ), + ] diff --git a/InvenTree/users/migrations/__init__.py b/InvenTree/users/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 40a96afc6f..09f2a046d1 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -1 +1,317 @@ # -*- coding: utf-8 -*- + +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from django.dispatch import receiver +from django.db.models.signals import post_save + + +class RuleSet(models.Model): + """ + A RuleSet is somewhat like a superset of the django permission class, + in that in encapsulates a bunch of permissions. + + There are *many* apps models used within InvenTree, + so it makes sense to group them into "roles". + + These roles translate (roughly) to the menu options available. + + Each role controls permissions for a number of database tables, + which are then handled using the normal django permissions approach. + """ + + RULESET_CHOICES = [ + ('admin', _('Admin')), + ('part', _('Parts')), + ('stock', _('Stock')), + ('build', _('Build Orders')), + ('purchase_order', _('Purchase Orders')), + ('sales_order', _('Sales Orders')), + ] + + RULESET_NAMES = [ + choice[0] for choice in RULESET_CHOICES + ] + + RULESET_MODELS = { + 'admin': [ + 'auth_group', + 'auth_user', + 'auth_permission', + 'authtoken_token', + 'users_ruleset', + ], + 'part': [ + 'part_part', + 'part_bomitem', + 'part_partcategory', + 'part_partattachment', + 'part_partsellpricebreak', + 'part_parttesttemplate', + 'part_partparametertemplate', + 'part_partparameter', + ], + 'stock': [ + 'stock_stockitem', + 'stock_stocklocation', + 'stock_stockitemattachment', + 'stock_stockitemtracking', + 'stock_stockitemtestresult', + ], + 'build': [ + 'part_part', + 'part_partcategory', + 'part_bomitem', + 'build_build', + 'build_builditem', + 'stock_stockitem', + 'stock_stocklocation', + ], + 'purchase_order': [ + 'company_company', + 'company_supplierpart', + 'company_supplierpricebreak', + 'order_purchaseorder', + 'order_purchaseorderattachment', + 'order_purchaseorderlineitem', + ], + 'sales_order': [ + 'company_company', + 'order_salesorder', + 'order_salesorderattachment', + 'order_salesorderlineitem', + 'order_salesorderallocation', + ] + } + + # Database models we ignore permission sets for + RULESET_IGNORE = [ + # Core django models (not user configurable) + 'admin_logentry', + 'contenttypes_contenttype', + 'sessions_session', + + # Models which currently do not require permissions + 'common_colortheme', + 'common_currency', + 'common_inventreesetting', + 'company_contact', + 'label_stockitemlabel', + 'report_reportasset', + 'report_testreport', + 'part_partstar', + ] + + RULE_OPTIONS = [ + 'can_view', + 'can_add', + 'can_change', + 'can_delete', + ] + + class Meta: + unique_together = ( + ('name', 'group'), + ) + + name = models.CharField( + max_length=50, + choices=RULESET_CHOICES, + blank=False, + help_text=_('Permission set') + ) + + group = models.ForeignKey( + Group, + related_name='rule_sets', + blank=False, null=False, + on_delete=models.CASCADE, + help_text=_('Group'), + ) + + can_view = models.BooleanField(verbose_name=_('View'), default=True, help_text=_('Permission to view items')) + + can_add = models.BooleanField(verbose_name=_('Create'), default=False, help_text=_('Permission to add items')) + + can_change = models.BooleanField(verbose_name=_('Update'), default=False, help_text=_('Permissions to edit items')) + + can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items')) + + @staticmethod + def get_model_permission_string(model, permission): + """ + Construct the correctly formatted permission string, + given the app_model name, and the permission type. + """ + + app, model = model.split('_') + + return "{app}.{perm}_{model}".format( + app=app, + perm=permission, + model=model + ) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + + super().save(*args, **kwargs) + + def get_models(self): + """ + Return the database tables / models that this ruleset covers. + """ + + return self.RULESET_MODELS.get(self.name, []) + + +def update_group_roles(group, debug=False): + """ + + Iterates through all of the RuleSets associated with the group, + and ensures that the correct permissions are either applied or removed from the group. + + This function is called under the following conditions: + + a) Whenever the InvenTree database is launched + b) Whenver the group object is updated + + The RuleSet model has complete control over the permissions applied to any group. + + """ + + # List of permissions already associated with this group + group_permissions = set() + + # Iterate through each permission already assigned to this group, + # and create a simplified permission key string + for p in group.permissions.all(): + (permission, app, model) = p.natural_key() + + permission_string = '{app}.{perm}'.format( + app=app, + perm=permission + ) + + group_permissions.add(permission_string) + + # List of permissions which must be added to the group + permissions_to_add = set() + + # List of permissions which must be removed from the group + permissions_to_delete = set() + + def add_model(name, action, allowed): + """ + Add a new model to the pile: + + args: + name - The name of the model e.g. part_part + action - The permission action e.g. view + allowed - Whether or not the action is allowed + """ + + if action not in ['view', 'add', 'change', 'delete']: + raise ValueError("Action {a} is invalid".format(a=action)) + + permission_string = RuleSet.get_model_permission_string(model, action) + + if allowed: + + # An 'allowed' action is always preferenced over a 'forbidden' action + if permission_string in permissions_to_delete: + permissions_to_delete.remove(permission_string) + + if permission_string not in group_permissions: + permissions_to_add.add(permission_string) + + else: + + # A forbidden action will be ignored if we have already allowed it + if permission_string not in permissions_to_add: + + if permission_string in group_permissions: + permissions_to_delete.add(permission_string) + + # Get all the rulesets associated with this group + for r in RuleSet.RULESET_CHOICES: + + rulename = r[0] + + try: + ruleset = RuleSet.objects.get(group=group, name=rulename) + except RuleSet.DoesNotExist: + # Create the ruleset with default values (if it does not exist) + ruleset = RuleSet.objects.create(group=group, name=rulename) + + # Which database tables does this RuleSet touch? + models = ruleset.get_models() + + for model in models: + # Keep track of the available permissions for each model + + add_model(model, 'view', ruleset.can_view) + add_model(model, 'add', ruleset.can_add) + add_model(model, 'change', ruleset.can_change) + add_model(model, 'delete', ruleset.can_delete) + + def get_permission_object(permission_string): + """ + Find the permission object in the database, + from the simplified permission string + + Args: + permission_string - a simplified permission_string e.g. 'part.view_partcategory' + + Returns the permission object in the database associated with the permission string + """ + + (app, perm) = permission_string.split('.') + + (permission_name, model) = perm.split('_') + + try: + content_type = ContentType.objects.get(app_label=app, model=model) + permission = Permission.objects.get(content_type=content_type, codename=perm) + except ContentType.DoesNotExist: + print(f"Error: Could not find permission matching '{permission_string}'") + permission = None + + return permission + + # Add any required permissions to the group + for perm in permissions_to_add: + + permission = get_permission_object(perm) + + group.permissions.add(permission) + + if debug: + print(f"Adding permission {perm} to group {group.name}") + + # Remove any extra permissions from the group + for perm in permissions_to_delete: + + permission = get_permission_object(perm) + + group.permissions.remove(permission) + + if debug: + print(f"Removing permission {perm} from group {group.name}") + + +@receiver(post_save, sender=Group) +def create_missing_rule_sets(sender, instance, **kwargs): + """ + Called *after* a Group object is saved. + As the linked RuleSet instances are saved *before* the Group, + then we can now use these RuleSet values to update the + group permissions. + """ + + update_group_roles(instance) diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py index 57c7c1fe6b..d14ffc4950 100644 --- a/InvenTree/users/tests.py +++ b/InvenTree/users/tests.py @@ -1,4 +1,157 @@ # -*- coding: utf-8 -*- -# from __future__ import unicode_literals +from __future__ import unicode_literals -# from django.test import TestCase +from django.test import TestCase +from django.apps import apps +from django.contrib.auth.models import Group + +from users.models import RuleSet + + +class RuleSetModelTest(TestCase): + """ + Some simplistic tests to ensure the RuleSet model is setup correctly. + """ + + def test_ruleset_models(self): + + keys = RuleSet.RULESET_MODELS.keys() + + # Check if there are any rulesets which do not have models defined + + missing = [name for name in RuleSet.RULESET_NAMES if name not in keys] + + if len(missing) > 0: + print("The following rulesets do not have models assigned:") + for m in missing: + print("-", m) + + # Check if models have been defined for a ruleset which is incorrect + extra = [name for name in keys if name not in RuleSet.RULESET_NAMES] + + if len(extra) > 0: + print("The following rulesets have been improperly added to RULESET_MODELS:") + for e in extra: + print("-", e) + + # Check that each ruleset has models assigned + empty = [key for key in keys if len(RuleSet.RULESET_MODELS[key]) == 0] + + if len(empty) > 0: + print("The following rulesets have empty entries in RULESET_MODELS:") + for e in empty: + print("-", e) + + self.assertEqual(len(missing), 0) + self.assertEqual(len(extra), 0) + self.assertEqual(len(empty), 0) + + def test_model_names(self): + """ + Test that each model defined in the rulesets is valid, + based on the database schema! + """ + + available_models = apps.get_models() + + available_tables = set() + + # Extract each available database model and construct a formatted string + for model in available_models: + label = model.objects.model._meta.label + label = label.replace('.', '_').lower() + available_tables.add(label) + + assigned_models = set() + + # Now check that each defined model is a valid table name + for key in RuleSet.RULESET_MODELS.keys(): + + models = RuleSet.RULESET_MODELS[key] + + for m in models: + + assigned_models.add(m) + + missing_models = set() + + for model in available_tables: + if model not in assigned_models and model not in RuleSet.RULESET_IGNORE: + missing_models.add(model) + + if len(missing_models) > 0: + print("The following database models are not covered by the defined RuleSet permissions:") + for m in missing_models: + print("-", m) + + extra_models = set() + + defined_models = set() + + for model in assigned_models: + defined_models.add(model) + + for model in RuleSet.RULESET_IGNORE: + defined_models.add(model) + + for model in defined_models: + if model not in available_tables: + extra_models.add(model) + + if len(extra_models) > 0: + print("The following RuleSet permissions do not match a database model:") + for m in extra_models: + print("-", m) + + self.assertEqual(len(missing_models), 0) + self.assertEqual(len(extra_models), 0) + + def test_permission_assign(self): + """ + Test that the permission assigning works! + """ + + # Create a new group + group = Group.objects.create(name="Test group") + + rulesets = group.rule_sets.all() + + # Rulesets should have been created automatically for this group + self.assertEqual(rulesets.count(), len(RuleSet.RULESET_CHOICES)) + + # Check that all permissions have been assigned permissions? + permission_set = set() + + for models in RuleSet.RULESET_MODELS.values(): + + for model in models: + permission_set.add(model) + + # Every ruleset by default sets one permission, the "view" permission set + self.assertEqual(group.permissions.count(), len(permission_set)) + + # Add some more rules + for rule in rulesets: + rule.can_add = True + rule.can_change = True + + rule.save() + + group.save() + + # There should now be three permissions for each rule set + self.assertEqual(group.permissions.count(), 3 * len(permission_set)) + + # Now remove *all* permissions + for rule in rulesets: + rule.can_view = False + rule.can_add = False + rule.can_change = False + rule.can_delete = False + + rule.save() + + group.save() + + # There should now not be any permissions assigned to this group + self.assertEqual(group.permissions.count(), 0) diff --git a/tasks.py b/tasks.py index 9948b470d1..df386633e9 100644 --- a/tasks.py +++ b/tasks.py @@ -22,7 +22,8 @@ def apps(): 'part', 'report', 'stock', - 'InvenTree' + 'InvenTree', + 'users', ] def localDir():