diff --git a/aleksis/core/admin.py b/aleksis/core/admin.py index 29a48d1517db6661fafd7ddd81bc0f975faa2d5a..24c7905093be9974a267b4ac2dfdbf97a18c1508 100644 --- a/aleksis/core/admin.py +++ b/aleksis/core/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin +from .mixins import BaseModelAdmin from .models import ( Group, Person, @@ -12,7 +13,12 @@ from .models import ( CustomMenuItem, ) -admin.site.register(Person) + +class PersonAdmin(BaseModelAdmin): + pass + + +admin.site.register(Person, PersonAdmin) admin.site.register(Group) admin.site.register(School) admin.site.register(SchoolTerm) @@ -25,7 +31,7 @@ class AnnouncementRecipientInline(admin.StackedInline): model = AnnouncementRecipient -class AnnouncementAdmin(admin.ModelAdmin): +class AnnouncementAdmin(BaseModelAdmin): inlines = [ AnnouncementRecipientInline, ] diff --git a/aleksis/core/decorators.py b/aleksis/core/decorators.py deleted file mode 100644 index 1c884a123c8a4b2a60e4616e4c928064e4a7123e..0000000000000000000000000000000000000000 --- a/aleksis/core/decorators.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Callable - -from django.contrib.auth.decorators import login_required, user_passes_test - -from .util.core_helpers import has_person - - -def admin_required(function: Callable = None) -> Callable: - actual_decorator = user_passes_test(lambda u: u.is_active and u.is_superuser) - return actual_decorator(function) - - -def person_required(function: Callable = None) -> Callable: - """ Requires a logged-in user which is linked to a person. """ - - actual_decorator = user_passes_test(has_person) - return actual_decorator(login_required(function)) diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index f04baef7eb3b0f4b560aeaa919f29d8222751564..33cb12c3bd734f6ffd21c72ac29dfc9feb1a7254 100644 --- a/aleksis/core/menus.py +++ b/aleksis/core/menus.py @@ -62,8 +62,7 @@ MENUS = { "url": "#", "icon": "security", "validators": [ - "menu_generator.validators.is_authenticated", - "menu_generator.validators.is_superuser", + ("aleksis.core.util.predicates.permission_validator", "core.view_admin_menu"), ], "submenu": [ { @@ -71,8 +70,7 @@ MENUS = { "url": "announcements", "icon": "announcement", "validators": [ - "menu_generator.validators.is_authenticated", - "menu_generator.validators.is_superuser", + ("aleksis.core.util.predicates.permission_validator", "core.view_announcements"), ], }, { @@ -80,8 +78,7 @@ MENUS = { "url": "data_management", "icon": "view_list", "validators": [ - "menu_generator.validators.is_authenticated", - "menu_generator.validators.is_superuser", + ("aleksis.core.util.predicates.permission_validator", "core.manage_data"), ], }, { @@ -89,8 +86,7 @@ MENUS = { "url": "system_status", "icon": "power_settings_new", "validators": [ - "menu_generator.validators.is_authenticated", - "menu_generator.validators.is_superuser", + ("aleksis.core.util.predicates.permission_validator", "core.view_system_status"), ], }, { @@ -98,8 +94,7 @@ MENUS = { "url": "impersonate-list", "icon": "people", "validators": [ - "menu_generator.validators.is_authenticated", - "menu_generator.validators.is_superuser", + ("aleksis.core.util.predicates.permission_validator", "core.impersonate"), ], }, { @@ -107,8 +102,7 @@ MENUS = { "url": "school_management", "icon": "school", "validators": [ - "menu_generator.validators.is_authenticated", - "menu_generator.validators.is_superuser", + ("aleksis.core.util.predicates.permission_validator", "core.manage_school"), ], }, { @@ -116,7 +110,6 @@ MENUS = { "url": "admin:index", "icon": "settings", "validators": [ - "menu_generator.validators.is_authenticated", "menu_generator.validators.is_superuser", ], }, @@ -127,22 +120,23 @@ MENUS = { "url": "#", "icon": "people", "root": True, - "validators": [ - "menu_generator.validators.is_authenticated", - "aleksis.core.util.core_helpers.has_person", - ], + "validators": [("aleksis.core.util.predicates.permission_validator", "core.view_people_menu")], "submenu": [ { "name": _("Persons"), "url": "persons", "icon": "person", - "validators": ["menu_generator.validators.is_authenticated"], + "validators": [ + ("aleksis.core.util.predicates.permission_validator", "core.view_persons") + ], }, { "name": _("Groups"), "url": "groups", "icon": "group", - "validators": ["menu_generator.validators.is_authenticated"], + "validators": [ + ("aleksis.core.util.predicates.permission_validator", "core.view_groups") + ], }, { "name": _("Persons and accounts"), diff --git a/aleksis/core/migrations/0023_add_permissions_person.py b/aleksis/core/migrations/0023_add_permissions_person.py new file mode 100644 index 0000000000000000000000000000000000000000..2f73d8d5e7d450ea0afca7ba836a3ee08088a0bf --- /dev/null +++ b/aleksis/core/migrations/0023_add_permissions_person.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.5 on 2020-04-18 12:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_group_types'), + ] + + operations = [ + migrations.AlterModelOptions( + name='person', + options={'ordering': ['last_name', 'first_name'], 'permissions': (('view_address', 'Can view address'), ('view_contact_details', 'Can view contact details'), ('view_photo', 'Can view photo'), ('view_personal_details', 'Can view personal details'), ('view_person_groups', 'Can view persons groups')), 'verbose_name': 'Person', 'verbose_name_plural': 'Persons'}, + ), + ] diff --git a/aleksis/core/migrations/0024_globalpermissions.py b/aleksis/core/migrations/0024_globalpermissions.py new file mode 100644 index 0000000000000000000000000000000000000000..b127523a981c0e847340c10c5aca6210d0c94dc1 --- /dev/null +++ b/aleksis/core/migrations/0024_globalpermissions.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.5 on 2020-04-19 10:30 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0023_add_permissions_person'), + ] + + operations = [ + migrations.CreateModel( + name='GlobalPermissions', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict, editable=False)), + ], + options={ + 'permissions': (('view_system_status', 'Can view system status'), ('link_persons_accounts', 'Can link persons to accounts'), ('manage_data', 'Can manage data'), ('impersonate', 'Can impersonate')), + 'managed': False, + }, + ), + ] diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index 574cf185cc2350e3e7b45f7723c8e9313a631790..15b5c0c67c21a3cf473eda2b1c8637db849125b2 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -7,8 +7,10 @@ from django.db.models import QuerySet from django.forms.models import ModelFormMetaclass, ModelForm from easyaudit.models import CRUDEvent +from guardian.admin import GuardedModelAdmin from jsonstore.fields import JSONField, JSONFieldMixin from material.base import LayoutNode, Layout +from rules.contrib.admin import ObjectPermissionsModelAdmin class CRUDMixin(models.Model): @@ -233,3 +235,7 @@ class ExtensibleForm(ModelForm, metaclass=_ExtensibleFormMetaclass): cls.base_layout.append(node) cls.layout = Layout(*cls.base_layout) + + +class BaseModelAdmin(GuardedModelAdmin, ObjectPermissionsModelAdmin): + pass diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 8d9991f03e26a70c965e597acf05a1d189f65767..0ea49774c79d08bcd15d8635fec71b3831e99cdd 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -95,6 +95,13 @@ class Person(ExtensibleModel): ordering = ["last_name", "first_name"] verbose_name = _("Person") verbose_name_plural = _("Persons") + permissions = ( + ("view_address", _("Can view address")), + ("view_contact_details", _("Can view contact details")), + ("view_photo", _("Can view photo")), + ("view_person_groups", _("Can view persons groups")), + ("view_personal_details", _("Can view personal details")), + ) icon_ = "person" @@ -566,3 +573,15 @@ class GroupType(ExtensibleModel): class Meta: verbose_name = _("Group type") verbose_name_plural = _("Group types") + + +class GlobalPermissions(ExtensibleModel): + class Meta: + managed = False + permissions = ( + ("view_system_status", _("Can view system status")), + ("link_persons_accounts", _("Can link persons to accounts")), + ("manage_data", _("Can manage data")), + ("impersonate", _("Can impersonate")), + ("search", _("Can use search")), + ) diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..234d6f80159442633f3e45fbc753fe7ad39de6c4 --- /dev/null +++ b/aleksis/core/rules.py @@ -0,0 +1,139 @@ +from rules import add_perm, always_allow + +from .models import Person, Group, Announcement +from .util.predicates import ( + has_person, + has_global_perm, + has_any_object, + is_current_person, + has_object_perm, +) + + +add_perm("core", always_allow) + +# View dashboard +add_perm("core.view_dashboard", has_person) + +# Use search +search_predicate = has_person & has_global_perm("core.search") +add_perm("core.search", search_predicate) + +# View persons +view_persons_predicate = has_person & ( + has_global_perm("core.view_person") | has_any_object("core.view_person", Person) +) +add_perm("core.view_persons", view_persons_predicate) + +# View person +view_person_predicate = has_person & ( + has_global_perm("core.view_person") | has_object_perm("core.view_person") | is_current_person +) +add_perm("core.view_person", view_person_predicate) + +# View person address +view_address_predicate = has_person & ( + has_global_perm("core.view_address") | has_object_perm("core.view_address") | is_current_person +) +add_perm("core.view_address", view_address_predicate) + +# View person contact details +view_contact_details_predicate = has_person & ( + has_global_perm("core.view_contact_details") | has_object_perm("core.view_contact_details") | is_current_person +) +add_perm("core.view_contact_details", view_contact_details_predicate) + +# View person photo +view_photo_predicate = has_person & ( + has_global_perm("core.view_photo") | has_object_perm("core.view_photo") | is_current_person +) +add_perm("core.view_photo", view_photo_predicate) + +# View persons groups +view_groups_predicate = has_person & ( + has_global_perm("core.view_person_groups") | has_object_perm("core.view_person_groups") | is_current_person +) +add_perm("core.view_person_groups", view_groups_predicate) + +# Edit person +edit_person_predicate = has_person & ( + has_global_perm("core.change_person") | has_object_perm("core.change_person") +) +add_perm("core.edit_person", edit_person_predicate) + +# Link persons with accounts +link_persons_accounts_predicate = has_person & has_global_perm("core.link_persons_accounts") +add_perm("core.link_persons_accounts", link_persons_accounts_predicate) + +# View groups +view_groups_predicate = has_person & ( + has_global_perm("core.view_group") | has_any_object("core.view_group", Group) +) +add_perm("core.view_groups", view_groups_predicate) + +# View group +view_group_predicate = has_person & ( + has_global_perm("core.view_group") | has_object_perm("core.view_group") +) +add_perm("core.view_group", view_group_predicate) + +# Edit group +edit_group_predicate = has_person & ( + has_global_perm("core.change_group") | has_object_perm("core.change_group") +) +add_perm("core.edit_group", edit_group_predicate) + +# Edit school information +edit_school_information_predicate = has_person & has_global_perm("core.change_school") +add_perm("core.edit_school_information", edit_school_information_predicate) + +# Edit school term +edit_schoolterm_predicate = has_person & has_global_perm("core.change_schoolterm") +add_perm("core.edit_schoolterm", edit_schoolterm_predicate) + +# Manage school +manage_school_predicate = edit_school_information_predicate | edit_schoolterm_predicate +add_perm("core.manage_school", manage_school_predicate) + +# Manage data +manage_data_predicate = has_person & has_global_perm("core.manage_data") +add_perm("core.manage_data", manage_data_predicate) + +# View announcements +view_announcements_predicate = has_person & ( + has_global_perm("core.view_announcement") | has_any_object("core.view_announcement", Announcement) +) +add_perm("core.view_announcements", view_announcements_predicate) + +# Create or edit announcement +create_or_edit_announcement_predicate = has_person & ( + has_global_perm("core.add_announcement") & (has_global_perm("core.change_announcement") | has_object_perm("core.change_announcement")) +) +add_perm("core.create_or_edit_announcement", create_or_edit_announcement_predicate) + +# Delete announcement +delete_announcement_predicate = has_person & ( + has_global_perm("core.delete_announcement") | has_object_perm("core.delete_announcement") +) +add_perm("core.delete_announcement", delete_announcement_predicate) + +# Use impersonate +impersonate_predicate = has_person & has_global_perm("core.impersonate") +add_perm("core.impersonate", impersonate_predicate) + +# View system status +view_system_status_predicate = has_person & has_global_perm("core.view_system_status") +add_perm("core.view_system_status", view_system_status_predicate) + +# View people menu (persons + objects) +add_perm("core.view_people_menu", has_person & (view_persons_predicate | view_groups_predicate)) + +# View admin menu +view_admin_menu_predicate = has_person & (manage_data_predicate | manage_school_predicate | impersonate_predicate | view_system_status_predicate | view_announcements_predicate) +add_perm("core.view_admin_menu", view_admin_menu_predicate) + +# View person personal details +view_personal_details_predicate = has_person & ( + has_global_perm("core.view_personal_details") | has_object_perm("core.view_personal_details") | is_current_person +) +add_perm("core.view_personal_details", view_personal_details_predicate) diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 81c00f57599429b58494b7e38fb5a6f5651fdbb1..eb24f076df6a414052e2e5a29d9dfbf8af5c7083 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -30,6 +30,8 @@ _settings = LazySettings( # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SILENCED_SYSTEM_CHECKS = [] + # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = _settings.get("secret_key", "DoNotUseInProduction") @@ -55,6 +57,8 @@ INSTALLED_APPS = [ "django.contrib.sites", "django.contrib.staticfiles", "django.contrib.humanize", + "guardian", + "rules.apps.AutodiscoverRulesConfig", "haystack", "polymorphic", "django_global_request", @@ -575,6 +579,16 @@ LOGGING = { }, } +# Rules and permissions + +GUARDIAN_RAISE_403 = True +ANONYMOUS_USER_NAME = None + +SILENCED_SYSTEM_CHECKS.append("guardian.W001") + +# Append authentication backends +AUTHENTICATION_BACKENDS.append("rules.permissions.ObjectPermissionBackend") + HAYSTACK_BACKEND_SHORT = _settings.get("search.backend", "simple") if HAYSTACK_BACKEND_SHORT == "simple": diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html index fe536557737d05a04c93d9bfbedfcc63f1bbe60b..1aaaf85ca133c0657e8589597bda13f0cdb38de2 100644 --- a/aleksis/core/templates/core/base.html +++ b/aleksis/core/templates/core/base.html @@ -1,7 +1,7 @@ {# -*- engine:django -*- #} -{% load i18n menu_generator static sass_tags any_js pwa %} +{% load i18n menu_generator static sass_tags any_js pwa rules %} <!DOCTYPE html> @@ -71,7 +71,8 @@ </object> </a> </li> - {% if user.is_authenticated %} + {% has_perm 'core.search' user as search %} + {% if search %} <li class="search"> <form method="get" action="{% url "haystack_search" %}" id="search-form" class="autocomplete"> <div class="search-wrapper"> diff --git a/aleksis/core/templates/core/person_full.html b/aleksis/core/templates/core/person_full.html index 8f9ceed60aa7ca1a99bde9e3682f5988d2f29f75..cf584d8ae87e203936c1c077a6a4345c06661cb9 100644 --- a/aleksis/core/templates/core/person_full.html +++ b/aleksis/core/templates/core/person_full.html @@ -2,24 +2,29 @@ {% extends "core/base.html" %} -{% load i18n static cropping %} +{% load i18n static cropping rules %} {% load render_table from django_tables2 %} {% block browser_title %}{{ person.first_name }} {{ person.last_name }}{% endblock %} {% block content %} <h4>{{ person.first_name }} {{ person.last_name }}</h4> - <p> - <a href="{% url 'edit_person_by_id' person.id %}" class="btn waves-effect waves-light"> - <i class="material-icons left">edit</i> - {% trans "Edit" %} - </a> - </p> + + {% has_perm 'core.edit_person' user person as can_change_person %} + {% if can_change_person %} + <p> + <a href="{% url 'edit_person_by_id' person.id %}" class="btn waves-effect waves-light"> + <i class="material-icons left">edit</i> + {% trans "Edit" %} + </a> + </p> + {% endif %} <h5>{% blocktrans %}Contact details{% endblocktrans %}</h5> <div class="row"> <div class="col s12 m4"> - {% if person.photo %} + {% has_perm 'core.view_photo' user person as can_view_photo %} + {% if person.photo and can_view_photo %} <img class="person-img" src="{% cropped_thumbnail person 'photo_cropping' max_size='300x400' %}" alt="{{ person.first_name }} {{ person.last_name }}"/> {% else %} @@ -46,36 +51,48 @@ </td> <td colspan="3">{{ person.get_sex_display }}</td> </tr> - <tr> - <td> - <i class="material-icons small">home</i> - </td> - <td colspan="2">{{ person.street }} {{ person.housenumber }}</td> - <td colspan="2">{{ person.postal_code }} {{ person.place }}</td> - </tr> - <tr> - <td> - <i class="material-icons small">phone</i> - </td> - <td>{{ person.phone_number }}</td> - <td>{{ person.mobile_number }}</td> - </tr> - <tr> - <td> - <i class="material-icons small">email</i> - </td> - <td colspan="3">{{ person.email }}</td> - </tr> - <tr> - <td> - <i class="material-icons small">cake</i> - </td> - <td colspan="3">{{ person.date_of_birth|date }}</td> - </tr> + {% has_perm 'core.view_address' user person as can_view_address %} + {% if can_view_address %} + <tr> + <td> + <i class="material-icons small">home</i> + </td> + <td colspan="2">{{ person.street }} {{ person.housenumber }}</td> + <td colspan="2">{{ person.postal_code }} {{ person.place }}</td> + </tr> + {% endif %} + {% has_perm 'core.view_contact_details' user person as can_view_contact_details %} + {% if can_view_contact_details %} + <tr> + <td> + <i class="material-icons small">phone</i> + </td> + <td>{{ person.phone_number }}</td> + <td>{{ person.mobile_number }}</td> + </tr> + <tr> + <td> + <i class="material-icons small">email</i> + </td> + <td colspan="3">{{ person.email }}</td> + </tr> + {% endif %} + {% has_perm 'core.view_personal_details' user person as can_view_personal_details %} + {% if can_view_personal_details %} + <tr> + <td> + <i class="material-icons small">cake</i> + </td> + <td colspan="3">{{ person.date_of_birth|date }}</td> + </tr> + {% endif %} </table> </div> </div> - <h5>{% blocktrans %}Groups{% endblocktrans %}</h5> - {% render_table groups_table %} + {% has_perm 'core.view_person_groups' user person as can_view_groups %} + {% if can_view_groups %} + <h5>{% blocktrans %}Groups{% endblocktrans %}</h5> + {% render_table groups_table %} + {% endif %} {% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 5de76472c91da9243edfa13c57c5ce29a7782e08..6c1648ee402020e33e5f37230b0730174a55e39f 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -9,6 +9,7 @@ from django.views.i18n import JavaScriptCatalog import calendarweek.django import debug_toolbar from django_js_reverse.views import urls_js +from rules.contrib.views import permission_required from two_factor.urls import urlpatterns as tf_urls from . import views @@ -41,7 +42,7 @@ urlpatterns = [ path("announcement/edit/<int:pk>/", views.announcement_form, name="edit_announcement"), path("announcement/delete/<int:pk>/", views.delete_announcement, name="delete_announcement"), path("search/searchbar/", views.searchbar_snippets, name="searchbar_snippets"), - path("search/", include("haystack.urls")), + path("search/", views.PermissionSearchView(), name="haystack_search"), path("maintenance-mode/", include("maintenance_mode.urls")), path("impersonate/", include("impersonate.urls")), path("__i18n__/", include("django.conf.urls.i18n")), diff --git a/aleksis/core/util/predicates.py b/aleksis/core/util/predicates.py new file mode 100644 index 0000000000000000000000000000000000000000..3fb1b64e98ee26f29b2b72014ccf66bc2c39e53d --- /dev/null +++ b/aleksis/core/util/predicates.py @@ -0,0 +1,86 @@ +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import User +from django.db.models import Model +from django.http import HttpRequest +from guardian.backends import ObjectPermissionBackend +from guardian.shortcuts import get_objects_for_user +from rules import predicate + +from .core_helpers import has_person as has_person_helper + +# 1. Global permissions (view all, add, change all, delete all) +# 2. Object permissions (view, change, delete) +# 3. Rules + + +def permission_validator(request: HttpRequest, perm: str) -> bool: + """ Checks whether the request user has a permission """ + + if request.user: + return request.user.has_perm(perm) + return False + + +def check_global_permission(user: User, perm: str) -> bool: + """ Checks whether a user has a global permission """ + + return ModelBackend().has_perm(user, perm) + + +def check_object_permission(user: User, perm: str, obj: Model) -> bool: + """ Checks whether a user has a permission on a object """ + + return ObjectPermissionBackend().has_perm(user, perm, obj) + + +def has_global_perm(perm: str): + """ Builds predicate which checks whether a user has a global permission """ + + name = "has_global_perm:{}".format(perm) + + @predicate(name) + def fn(user: User) -> bool: + return check_global_permission(user, perm) + + return fn + + +def has_object_perm(perm: str): + """ Builds predicate which checks whether a user has a permission on a object """ + + name = "has_global_perm:{}".format(perm) + + @predicate(name) + def fn(user: User, obj: Model) -> bool: + if not obj: + return False + return check_object_permission(user, perm, obj) + + return fn + + +def has_any_object(perm: str, klass): + """ Build predicate which checks whether a user has access to objects with the provided permission """ + + name = "has_any_object:{}".format(perm) + + @predicate(name) + def fn(user: User) -> bool: + objs = get_objects_for_user(user, perm, klass) + return len(objs) > 0 + + return fn + + +@predicate +def has_person(user: User) -> bool: + """ Predicate which checks whether a user has a linked person """ + + return has_person_helper(user) + + +@predicate +def is_current_person(user: User, obj: Model) -> bool: + """ Predicate which checks if the provided object is the person linked to the user object """ + + return user.person == obj diff --git a/aleksis/core/views.py b/aleksis/core/views.py index f3eae71612f3f0c488437b3a7ccb33fb4e214f0d..6c4fad570d22c05d3b057fa6f8b24e2c8e1dee8a 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -2,17 +2,19 @@ from importlib import import_module from typing import Optional from django.apps import apps -from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import PermissionDenied from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext_lazy as _ from django_tables2 import RequestConfig +from guardian.shortcuts import get_objects_for_user from haystack.inputs import AutoQuery from haystack.query import SearchQuerySet +from haystack.views import SearchView +from rules.contrib.views import permission_required -from .decorators import admin_required, person_required from .forms import ( EditGroupForm, EditPersonForm, @@ -27,7 +29,7 @@ from .util import messages from .util.apps import AppConfig -@person_required +@permission_required("core.view_dashboard") def index(request: HttpRequest) -> HttpResponse: context = {} @@ -63,12 +65,14 @@ def about(request): return render(request, "core/about.html", context) -@login_required +@permission_required("core.view_persons") def persons(request: HttpRequest) -> HttpResponse: context = {} # Get all persons - persons = Person.objects.filter(is_active=True) + persons = get_objects_for_user( + request.user, "core.view_person", Person.objects.filter(is_active=True) + ) # Build table persons_table = PersonsTable(persons) @@ -78,19 +82,19 @@ def persons(request: HttpRequest) -> HttpResponse: return render(request, "core/persons.html", context) -@person_required +def get_person_by_pk(request, id_: Optional[int] = None): + if id_: + return get_object_or_404(Person, pk=id_) + else: + return request.user.person + + +@permission_required("core.view_person", fn=get_person_by_pk) def person(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse: context = {} # Get person and check access - try: - if id_ is None: - person = request.user.person - else: - person = Person.objects.get(pk=id_) - except Person.DoesNotExist as e: - # Turn not-found object into a 404 error - raise Http404 from e + person = get_person_by_pk(request, id_) context["person"] = person @@ -105,16 +109,15 @@ def person(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse: return render(request, "core/person_full.html", context) -@login_required +def get_group_by_pk(request: HttpRequest, id_: int) -> Group: + return get_object_or_404(Group, pk=id_) + + +@permission_required("core.view_group", fn=get_group_by_pk) def group(request: HttpRequest, id_: int) -> HttpResponse: context = {} - # Get group and check if it exist - try: - group = Group.objects.get(pk=id_) - except Group.DoesNotExist as e: - # Turn not-found object into a 404 error - raise Http404 from e + group = get_group_by_pk(request, id_) context["group"] = group @@ -140,12 +143,12 @@ def group(request: HttpRequest, id_: int) -> HttpResponse: return render(request, "core/group_full.html", context) -@login_required +@permission_required("core.view_groups") def groups(request: HttpRequest) -> HttpResponse: context = {} # Get all groups - groups = Group.objects.all() + groups = get_objects_for_user(request.user, "core.view_group", Group) # Build table groups_table = GroupsTable(groups) @@ -155,7 +158,7 @@ def groups(request: HttpRequest) -> HttpResponse: return render(request, "core/groups.html", context) -@admin_required +@permission_required("core.link_persons_accounts") def persons_accounts(request: HttpRequest) -> HttpResponse: context = {} @@ -171,11 +174,15 @@ def persons_accounts(request: HttpRequest) -> HttpResponse: return render(request, "core/persons_accounts.html", context) -@admin_required +def get_person_by_id(request: HttpRequest, id_:int): + return get_object_or_404(Person, id=id_) + + +@permission_required("core.edit_person", fn=get_person_by_id) def edit_person(request: HttpRequest, id_: int) -> HttpResponse: context = {} - person = get_object_or_404(Person, id=id_) + person = get_person_by_id(request, id_) edit_person_form = EditPersonForm(request.POST or None, request.FILES or None, instance=person) @@ -193,15 +200,22 @@ def edit_person(request: HttpRequest, id_: int) -> HttpResponse: return render(request, "core/edit_person.html", context) -@admin_required +def get_group_by_id(request: HttpRequest, id_: Optional[int] = None): + if id_: + return get_object_or_404(Group, id=id_) + else: + return None + + +@permission_required("core.edit_group", fn=get_group_by_id) def edit_group(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse: context = {} + group = get_group_by_id(request, id_) + if id_: - group = get_object_or_404(Group, id=id_) edit_group_form = EditGroupForm(request.POST or None, instance=group) else: - group = None edit_group_form = EditGroupForm(request.POST or None) if request.method == "POST": @@ -217,26 +231,26 @@ def edit_group(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse: return render(request, "core/edit_group.html", context) -@admin_required +@permission_required("core.manage_data") def data_management(request: HttpRequest) -> HttpResponse: context = {} return render(request, "core/data_management.html", context) -@admin_required +@permission_required("core.view_system_status") def system_status(request: HttpRequest) -> HttpResponse: context = {} return render(request, "core/system_status.html", context) -@admin_required +@permission_required("core.manage_school") def school_management(request: HttpRequest) -> HttpResponse: context = {} return render(request, "core/school_management.html", context) -@admin_required +@permission_required("core.edit_school_information") def edit_school(request: HttpRequest) -> HttpResponse: context = {} @@ -257,7 +271,7 @@ def edit_school(request: HttpRequest) -> HttpResponse: return render(request, "core/edit_school.html", context) -@admin_required +@permission_required("core.edit_schoolterm") def edit_schoolterm(request: HttpRequest) -> HttpResponse: context = {} @@ -290,7 +304,7 @@ def notification_mark_read(request: HttpRequest, id_: int) -> HttpResponse: return redirect("index") -@admin_required +@permission_required("core.view_announcements") def announcements(request: HttpRequest) -> HttpResponse: context = {} @@ -301,12 +315,18 @@ def announcements(request: HttpRequest) -> HttpResponse: return render(request, "core/announcement/list.html", context) -@admin_required +def get_announcement_by_pk(request: HttpRequest, pk: Optional[int] = None): + if pk: + return get_object_or_404(Announcement, pk=pk) + return None + + +@permission_required("core.create_or_edit_announcement", fn=get_announcement_by_pk) def announcement_form(request: HttpRequest, pk: Optional[int] = None) -> HttpResponse: context = {} if pk: - announcement = get_object_or_404(Announcement, pk=pk) + announcement = get_announcement_by_pk(request, pk) form = AnnouncementForm( request.POST or None, instance=announcement @@ -328,17 +348,17 @@ def announcement_form(request: HttpRequest, pk: Optional[int] = None) -> HttpRes return render(request, "core/announcement/form.html", context) -@admin_required +@permission_required("core.delete_announcement", fn=get_announcement_by_pk) def delete_announcement(request: HttpRequest, pk: int) -> HttpResponse: if request.method == "POST": - announcement = get_object_or_404(Announcement, pk=pk) + announcement = get_announcement_by_pk(request, pk) announcement.delete() messages.success(request, _("The announcement has been deleted.")) return redirect("announcements") -@login_required +@permission_required("core.search") def searchbar_snippets(request: HttpRequest) -> HttpResponse: query = request.GET.get('q', '') limit = int(request.GET.get('limit', '5')) @@ -347,3 +367,13 @@ def searchbar_snippets(request: HttpRequest) -> HttpResponse: context = {"results": results} return render(request, "search/searchbar_snippets.html", context) + + +class PermissionSearchView(PermissionRequiredMixin, SearchView): + permission_required = "core.search" + + def create_response(self): + context = self.get_context() + if not self.has_permission(): + return self.handle_no_permission() + return render(self.request, self.template, context) diff --git a/poetry.lock b/poetry.lock index 6893b47254efa4705507f421317319d5a99d8e70..4fe739a06e68f6620a5c073574d11a128a9599e0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -382,6 +382,17 @@ version = "2.2.0" [package.dependencies] Django = ">=1.8" +[[package]] +category = "main" +description = "Django utility for a memoization decorator that uses the Django cache framework." +name = "django-cache-memoize" +optional = false +python-versions = "*" +version = "0.1.6" + +[package.extras] +dev = ["flake8", "tox", "twine", "therapist", "black"] + [[package]] category = "main" description = "Database-backed Periodic Tasks." @@ -617,17 +628,6 @@ version = "1.6.3" [package.dependencies] six = "*" -[[package]] -category = "main" -description = "An implementation of memoization technique for Django." -name = "django-memoize" -optional = false -python-versions = "*" -version = "2.3.0" - -[package.dependencies] -django = "*" - [[package]] category = "main" description = "A straightforward menu generator for Django" @@ -1912,7 +1912,7 @@ description = "A collection of helpers and mock objects for unit tests and doc t name = "testfixtures" optional = false python-versions = "*" -version = "6.14.0" +version = "6.14.1" [package.extras] build = ["setuptools-git", "wheel", "twine"] @@ -2073,7 +2073,7 @@ celery = ["Celery", "django-celery-results", "django-celery-beat", "django-celer ldap = ["django-auth-ldap"] [metadata] -content-hash = "afd2f4b69870b913a740e8247dbe3618d40e0b51294203c5e883b9f72427bd71" +content-hash = "0f4a5dd1371431d369bd5d1ef0dd49286afb38daae4a1019ff085b097a38a560" python-versions = "^3.7" [metadata.files] @@ -2227,6 +2227,10 @@ django-bulk-update = [ {file = "django-bulk-update-2.2.0.tar.gz", hash = "sha256:5ab7ce8a65eac26d19143cc189c0f041d5c03b9d1b290ca240dc4f3d6aaeb337"}, {file = "django_bulk_update-2.2.0-py2.py3-none-any.whl", hash = "sha256:49a403392ae05ea872494d74fb3dfa3515f8df5c07cc277c3dc94724c0ee6985"}, ] +django-cache-memoize = [ + {file = "django-cache-memoize-0.1.6.tar.gz", hash = "sha256:7f271be70b11155929ee8a4a2b5f53c9fb46b9befa1b546caffa3298e6ac8f7d"}, + {file = "django_cache_memoize-0.1.6-py2.py3-none-any.whl", hash = "sha256:d239e8c37734b0a70b74f94fa33b180b3b0c82c3784beb21209bb4ab64a3e6fb"}, +] django-celery-beat = [ {file = "django-celery-beat-2.0.0.tar.gz", hash = "sha256:fdf1255eecfbeb770c6521fe3e69989dfc6373cd5a7f0fe62038d37f80f47e48"}, {file = "django_celery_beat-2.0.0-py2.py3-none-any.whl", hash = "sha256:fe0b2a1b31d4a6234fea4b31986ddfd4644a48fab216ce1843f3ed0ddd2e9097"}, @@ -2306,9 +2310,6 @@ django-material = [ {file = "django-material-1.6.3.tar.gz", hash = "sha256:f8758afe1beabc16a3c54f5437c7fea15946b7d068eedd89c97d57a363793950"}, {file = "django_material-1.6.3-py2.py3-none-any.whl", hash = "sha256:502dc88c2f61f190fdc401666e83b47da00cbda98477af6ed8b7d43944ce6407"}, ] -django-memoize = [ - {file = "django-memoize-2.3.0.tar.gz", hash = "sha256:85decffbef7d38ffc569dc96527f598e6677bbc01ce29adf722b051da7efd4be"}, -] django-menu-generator = [ {file = "django-menu-generator-1.0.4.tar.gz", hash = "sha256:ce71a5055c16933c8aff64fb36c21e5cf8b6d505733aceed1252f8b99369a378"}, ] @@ -2417,6 +2418,8 @@ flake8-builtins = [ ] flake8-django = [ {file = "flake8-django-0.0.4.tar.gz", hash = "sha256:7329ec2e2b8b194e8109639c534359014c79df4d50b14f4b85b8395edc5d6760"}, + {file = "flake8_django-0.0.4-py3.5.egg", hash = "sha256:ca66462724acbcf241d29edec201dac40c05cc27ae118b5abb8d74066681402f"}, + {file = "flake8_django-0.0.4-py3.7.egg", hash = "sha256:29721a4976f784921b140752234447af1192c4e4f989d0db4e2d9f7f7915fa86"}, ] flake8-docstrings = [ {file = "flake8-docstrings-1.5.0.tar.gz", hash = "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717"}, @@ -2485,6 +2488,7 @@ libsass = [ {file = "libsass-0.19.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:81a013a4c2a614927fd1ef7a386eddabbba695cbb02defe8f31cf495106e974c"}, {file = "libsass-0.19.4-cp35-cp35m-win32.whl", hash = "sha256:fcb7ab4dc81889e5fc99cafbc2017bc76996f9992fc6b175f7a80edac61d71df"}, {file = "libsass-0.19.4-cp35-cp35m-win_amd64.whl", hash = "sha256:fc5f8336750f76f1bfae82f7e9e89ae71438d26fc4597e3ab4c05ca8fcd41d8a"}, + {file = "libsass-0.19.4-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:53f87116e7441827878bd79bbad8debac23e1930423f61ab8d837ec4a4c36e0c"}, {file = "libsass-0.19.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:9b59afa0d755089c4165516400a39a289b796b5612eeef5736ab7a1ebf96a67c"}, {file = "libsass-0.19.4-cp36-cp36m-win32.whl", hash = "sha256:c93df526eeef90b1ea4799c1d33b6cd5aea3e9f4633738fb95c1287c13e6b404"}, {file = "libsass-0.19.4-cp36-cp36m-win_amd64.whl", hash = "sha256:0fd8b4337b3b101c6e6afda9112cc0dc4bacb9133b59d75d65968c7317aa3272"}, @@ -2492,6 +2496,7 @@ libsass = [ {file = "libsass-0.19.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:338e9ae066bf1fde874e335324d5355c52d2081d978b4f74fc59536564b35b08"}, {file = "libsass-0.19.4-cp37-cp37m-win32.whl", hash = "sha256:e318f06f06847ff49b1f8d086ac9ebce1e63404f7ea329adab92f4f16ba0e00e"}, {file = "libsass-0.19.4-cp37-cp37m-win_amd64.whl", hash = "sha256:a7e685466448c9b1bf98243339793978f654a1151eb5c975f09b83c7a226f4c1"}, + {file = "libsass-0.19.4-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:0fb4399f7bbecab7b181f2c2d82c3a0ba2916bf9169714b96e425355a5b23b9f"}, {file = "libsass-0.19.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6a51393d75f6e3c812785b0fa0b7d67c54258c28011921f204643b55f7355ec0"}, {file = "libsass-0.19.4.tar.gz", hash = "sha256:8b5b6d1a7c4ea1d954e0982b04474cc076286493f6af2d0a13c2e950fbe0be95"}, ] @@ -2862,8 +2867,8 @@ termcolor = [ {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, ] testfixtures = [ - {file = "testfixtures-6.14.0-py2.py3-none-any.whl", hash = "sha256:799144b3cbef7b072452d9c36cbd024fef415ab42924b96aad49dfd9c763de66"}, - {file = "testfixtures-6.14.0.tar.gz", hash = "sha256:cdfc3d73cb6d3d4dc3c67af84d912e86bf117d30ae25f02fe823382ef99383d2"}, + {file = "testfixtures-6.14.1-py2.py3-none-any.whl", hash = "sha256:30566e24a1b34e4d3f8c13abf62557d01eeb4480bcb8f1745467bfb0d415a7d9"}, + {file = "testfixtures-6.14.1.tar.gz", hash = "sha256:58d2b3146d93bc5ddb0cd24e0ccacb13e29bdb61e5c81235c58f7b8ee4470366"}, ] "testing.common.database" = [ {file = "testing.common.database-2.0.3-py2.py3-none-any.whl", hash = "sha256:e3ed492bf480a87f271f74c53b262caf5d85c8bc09989a8f534fa2283ec52492"}, diff --git a/pyproject.toml b/pyproject.toml index d07c890ad188c3a08f15095e07c9378125ece20e..b0e9c84ee4820832417255a8f290c762fe270206 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,8 @@ django-polymorphic = "^2.1.2" django-otp = "0.7.5" django-colorfield = "^0.2.1" django-bleach = "^0.6.1" +django-guardian = "^2.2.0" +rules = "^2.2" django-cache-memoize = "^0.1.6" django-haystack = {version="3.0b1", allows-prereleases = true} celery-haystack = {version="^0.3.1", optional=true}