From d34fdcb846777e627355f52be0d97bdd4cb40ef0 Mon Sep 17 00:00:00 2001 From: Horilla Date: Wed, 18 Sep 2024 15:51:44 +0530 Subject: [PATCH] [ADD] ACCESSIBILITY: Add employee accessibility app for handling employees permissions and accessibility --- accessibility/__init__.py | 0 accessibility/accessibility.py | 11 ++ accessibility/admin.py | 3 + accessibility/apps.py | 15 ++ accessibility/decorators.py | 51 ++++++ accessibility/filters.py | 102 ++++++++++++ accessibility/methods.py | 30 ++++ accessibility/middlewares.py | 49 ++++++ accessibility/migrations/__init__.py | 0 accessibility/models.py | 16 ++ accessibility/signals.py | 67 ++++++++ .../accessibility/accessibility.html | 146 ++++++++++++++++++ .../accessibility/filter_form_body.html | 22 +++ accessibility/tests.py | 3 + accessibility/urls.py | 20 +++ accessibility/views.py | 58 +++++++ base/methods.py | 7 +- employee/accessibility.py | 0 employee/filters.py | 5 +- employee/methods/methods.py | 1 - employee/sidebar.py | 15 ++ employee/views.py | 27 +++- horilla/filters.py | 5 + horilla/horilla_apps.py | 1 + horilla/horilla_middlewares.py | 1 + templates/settings.html | 20 +++ 26 files changed, 665 insertions(+), 10 deletions(-) create mode 100644 accessibility/__init__.py create mode 100644 accessibility/accessibility.py create mode 100644 accessibility/admin.py create mode 100644 accessibility/apps.py create mode 100644 accessibility/decorators.py create mode 100644 accessibility/filters.py create mode 100644 accessibility/methods.py create mode 100644 accessibility/middlewares.py create mode 100644 accessibility/migrations/__init__.py create mode 100644 accessibility/models.py create mode 100644 accessibility/signals.py create mode 100644 accessibility/templates/accessibility/accessibility.html create mode 100644 accessibility/templates/accessibility/filter_form_body.html create mode 100644 accessibility/tests.py create mode 100644 accessibility/urls.py create mode 100644 accessibility/views.py create mode 100644 employee/accessibility.py diff --git a/accessibility/__init__.py b/accessibility/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/accessibility/accessibility.py b/accessibility/accessibility.py new file mode 100644 index 000000000..870f65ffc --- /dev/null +++ b/accessibility/accessibility.py @@ -0,0 +1,11 @@ +""" +accessibility/accessibility.py +""" + +from django.utils.translation import gettext_lazy as _ + + +ACCESSBILITY_FEATURE = [ + ("employee_view", _("Default Employee View")), + ("employee_detailed_view", _("Default Employee Detailed View")), +] diff --git a/accessibility/admin.py b/accessibility/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/accessibility/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/accessibility/apps.py b/accessibility/apps.py new file mode 100644 index 000000000..dfdee6d37 --- /dev/null +++ b/accessibility/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig + + +class AccessibilityConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accessibility" + + def ready(self) -> None: + from horilla.urls import urlpatterns, include, path + from accessibility import signals + + urlpatterns.append( + path("", include("accessibility.urls")), + ) + return super().ready() diff --git a/accessibility/decorators.py b/accessibility/decorators.py new file mode 100644 index 000000000..b6e096bf1 --- /dev/null +++ b/accessibility/decorators.py @@ -0,0 +1,51 @@ +""" +employee/decorators.py +""" + +from django.http import HttpResponse +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ +from django.shortcuts import redirect +from accessibility.methods import check_is_accessibile +from base.decorators import decorator_with_arguments + + +@decorator_with_arguments +def enter_if_accessible(function, feature, perm=None, method=None): + """ + accessiblie check decorator + """ + + def check_accessible(request, *args, **kwargs): + """ + Check accessible + """ + path = "/" + referrer = request.META.get("HTTP_REFERER") + if referrer and request.path not in referrer: + path = request.META["HTTP_REFERER"] + accessible = False + cache_key = request.session.session_key + "accessibility_filter" + employee = getattr(request.user, "employee_get") + if employee: + accessible = check_is_accessibile(feature, cache_key, employee) + has_perm = True + if perm: + has_perm = request.user.has_perm(perm) + + if accessible or has_perm or method(request): + return function(request, *args, **kwargs) + key = "HTTP_HX_REQUEST" + keys = request.META.keys() + messages.info(request, _("You dont have access to the feature")) + if key in keys: + return HttpResponse( + f""" + + """ + ) + return redirect(path) + + return check_accessible diff --git a/accessibility/filters.py b/accessibility/filters.py new file mode 100644 index 000000000..57f001a43 --- /dev/null +++ b/accessibility/filters.py @@ -0,0 +1,102 @@ +""" +accessibility/filters.py +""" + +from functools import reduce +from django.utils.translation import gettext as _ +from django.db.models import Q +from django.template.loader import render_to_string +import django_filters +from horilla.filters import HorillaFilterSet +from horilla.horilla_middlewares import _thread_locals +from employee.models import Employee + + +def _filter_form_structured(self): + """ + Render the form fields as HTML table rows with Bootstrap styling. + """ + request = getattr(_thread_locals, "request", None) + context = { + "form": self, + "request": request, + } + table_html = render_to_string("accessibility/filter_form_body.html", context) + return table_html + + +class AccessibilityFilter(HorillaFilterSet): + """ + Accessibility Filter with dynamic OR logic between fields + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.structured = _filter_form_structured(self.form) + + pk = django_filters.ModelMultipleChoiceFilter( + queryset=Employee.objects.all(), + field_name="pk", + lookup_expr="in", + label=_("Employee"), + ) + + verbose_name = { + "employee_work_info__job_position_id": _("Job Position"), + "employee_work_info__department_id": _("Department"), + "employee_work_info__work_type_id": _("Work Type"), + "employee_work_info__employee_type_id": _("Employee Type"), + "employee_work_info__job_role_id": _("Job Role"), + "employee_work_info__company_id": _("Company"), + "employee_work_info__shift_id": _("Shift"), + "employee_work_info__tags": _("Tags"), + "employee_user_id__groups": _("Groups"), + "employee_user_id__user_permissions": _("Permissions"), + } + + class Meta: + """ + Meta class for additional options + """ + + model = Employee + fields = [ + "pk", + "employee_work_info__job_position_id", + "employee_work_info__department_id", + "employee_work_info__work_type_id", + "employee_work_info__employee_type_id", + "employee_work_info__job_role_id", + "employee_work_info__company_id", + "employee_work_info__shift_id", + "employee_work_info__tags", + "employee_user_id__groups", + "employee_user_id__user_permissions", + ] + + def filter_queryset(self, queryset): + """ + Dynamically apply OR condition between all specified fields + """ + or_conditions = [] + + # Get the filter fields from Meta class + for field in self.Meta.fields: + field_value = self.data.get(field) + if field_value: + if isinstance(field_value, list) and len(field_value) == 1: + field_value = field_value[0] + + if "__" in field: + or_conditions.append(Q(**{f"{field}__id__in": field_value})) + else: + if isinstance(field_value, list): + or_conditions.append(Q(**{f"{field}__in": field_value})) + else: + or_conditions.append(Q(**{f"{field}__in": [field_value]})) + + + if or_conditions: + queryset = queryset.filter(reduce(lambda x, y: x | y, or_conditions)) + + return queryset diff --git a/accessibility/methods.py b/accessibility/methods.py new file mode 100644 index 000000000..7ef23b607 --- /dev/null +++ b/accessibility/methods.py @@ -0,0 +1,30 @@ +""" +accessibility/methods.py +""" + +from django.core.cache import cache +from horilla.horilla_middlewares import _thread_locals +from accessibility.models import DefaultAccessibility +from accessibility.filters import AccessibilityFilter + + +def check_is_accessibile(feature, cache_key, employee): + """ + Method to check the employee is accessible for the feature or not + """ + if not employee: + return False + + accessibility = DefaultAccessibility.objects.filter(feature=feature).first() + + if not feature or not accessibility: + return True + + data: dict = cache.get(cache_key, default={}) + if data and data.get(feature) is not None: + return data.get(feature) + + filter = accessibility.filter + employees = AccessibilityFilter(data=filter).qs + accessibile = employee in employees + return accessibile diff --git a/accessibility/middlewares.py b/accessibility/middlewares.py new file mode 100644 index 000000000..5621cfe1c --- /dev/null +++ b/accessibility/middlewares.py @@ -0,0 +1,49 @@ +""" +accessibility/middlewares.py +""" + +from django.core.cache import cache +from accessibility.models import ACCESSBILITY_FEATURE +from accessibility.methods import check_is_accessibile + +ACCESSIBILITY_CACHE_USER_KEYS = {} + + +def update_accessibility_cache(cache_key, request): + """ + Cache for get all the queryset + """ + feature_accessible = {} + for accessibility, _display in ACCESSBILITY_FEATURE: + feature_accessible[accessibility] = check_is_accessibile( + accessibility, cache_key, getattr(request.user, "employee_get") + ) + cache.set(cache_key, feature_accessible) + + +class AccessibilityMiddleware: + """ + AccessibilityMiddleware + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + session_key = request.session.session_key + if session_key: + cache_key = session_key + "accessibility_filter" + exists_user_cache_key = ACCESSIBILITY_CACHE_USER_KEYS.get( + request.user.id, [] + ) + if not exists_user_cache_key: + ACCESSIBILITY_CACHE_USER_KEYS[request.user.id] = exists_user_cache_key + if ( + session_key + and getattr(request.user, "employee_get", None) + and not cache.get(cache_key) + ): + exists_user_cache_key.append(cache_key) + update_accessibility_cache(cache_key, request) + response = self.get_response(request) + return response diff --git a/accessibility/migrations/__init__.py b/accessibility/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/accessibility/models.py b/accessibility/models.py new file mode 100644 index 000000000..f9cc132fc --- /dev/null +++ b/accessibility/models.py @@ -0,0 +1,16 @@ +""" +accessibility/models.py +""" + +from django.db import models +from horilla.models import HorillaModel +from accessibility.accessibility import ACCESSBILITY_FEATURE + + +class DefaultAccessibility(HorillaModel): + """ + DefaultAccessibilityModel + """ + + feature = models.CharField(max_length=100, choices=ACCESSBILITY_FEATURE) + filter = models.JSONField() diff --git a/accessibility/signals.py b/accessibility/signals.py new file mode 100644 index 000000000..040b14481 --- /dev/null +++ b/accessibility/signals.py @@ -0,0 +1,67 @@ +""" +accessibility/signals.py +""" + +import threading +from django.db.models.signals import post_save +from django.core.cache import cache +from django.dispatch import receiver +from employee.models import EmployeeWorkInformation +from accessibility.models import DefaultAccessibility +from accessibility.middlewares import ( + ACCESSIBILITY_CACHE_USER_KEYS, +) +from horilla.signals import post_bulk_update + + +def _clear_accessibility_cache(): + for _user_id, cache_keys in ACCESSIBILITY_CACHE_USER_KEYS.copy().items(): + for key in cache_keys: + cache.delete(key) + + +def _clear_bulk_employees_cache(queryset): + for instance in queryset: + cache_key = ACCESSIBILITY_CACHE_USER_KEYS.get( + instance.employee_id.employee_user_id.id + ) + if cache_key: + cache.delete(cache_key) + + +@receiver(post_save, sender=EmployeeWorkInformation) +def monitor_employee_update(sender, instance, created, **kwargs): + """ + This method to track employee instance update + """ + + _sender = sender + _created = created + cache_keys = ACCESSIBILITY_CACHE_USER_KEYS.copy().get( + instance.employee_id.employee_user_id.id, [] + ) + for key in cache_keys: + cache.delete(key) + + +@receiver(post_save, sender=DefaultAccessibility) +def monitor_accessibility_update(sender, instance, created, **kwargs): + """ + This method is used to track accessibility updates + """ + _sender = sender + _created = created + _instance = instance + thread = threading.Thread(target=_clear_accessibility_cache) + thread.start() + + +@receiver(post_bulk_update, sender=EmployeeWorkInformation) +def monitor_employee_bulk_update(sender, queryset, *args, **kwargs): + """ + This method is used to track accessibility updates + """ + _sender = sender + _queryset = queryset + thread = threading.Thread(target=_clear_bulk_employees_cache(queryset)) + thread.start() diff --git a/accessibility/templates/accessibility/accessibility.html b/accessibility/templates/accessibility/accessibility.html new file mode 100644 index 000000000..4ddcbd909 --- /dev/null +++ b/accessibility/templates/accessibility/accessibility.html @@ -0,0 +1,146 @@ +{% extends "settings.html" %} +{% block settings %} +{% load i18n %} + +
+

+ {% trans "Default Accessibility" %} + + +

+ +
+
+
+ {% for accessibility, display in accessibility %} +
+ +
+
+ + {{accessibility_filter.form.structured}} + +
+ +
+
+ {% endfor %} +
+
+ + +{% endblock settings %} \ No newline at end of file diff --git a/accessibility/templates/accessibility/filter_form_body.html b/accessibility/templates/accessibility/filter_form_body.html new file mode 100644 index 000000000..416ea6cc9 --- /dev/null +++ b/accessibility/templates/accessibility/filter_form_body.html @@ -0,0 +1,22 @@ +{% load widget_tweaks %} {% load i18n %} +{% load generic_template_filters %} +
+
{{ form.non_field_errors }}
+ {% for field in form.visible_fields %} +
+
+ + {% if field.help_text != '' %} + + {% endif %} +
+ + {% if field.field.widget.input_type == 'checkbox' %} +
{{ field|add_class:'oh-switch__checkbox' }}
+ {% else %} +
{{ field|add_class:'form-control' }} + {{ field.errors }}
+ {% endif %} +
+ {% endfor %} +
diff --git a/accessibility/tests.py b/accessibility/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/accessibility/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accessibility/urls.py b/accessibility/urls.py new file mode 100644 index 000000000..c86ba9086 --- /dev/null +++ b/accessibility/urls.py @@ -0,0 +1,20 @@ +""" +accessibility/urls.py +""" + +from django.urls import path +from accessibility import views as accessibility + + +urlpatterns = [ + path( + "user-accessibility", + accessibility.user_accessibility, + name="user-accessibility", + ), + path( + "get-initial-accessibility-data", + accessibility.get_accessibility_data, + name="get-initial-accessibility-data", + ), +] diff --git a/accessibility/views.py b/accessibility/views.py new file mode 100644 index 000000000..6d3b82b52 --- /dev/null +++ b/accessibility/views.py @@ -0,0 +1,58 @@ +""" +employee/accessibility.py + +Employee accessibility related methods and functionalites +""" + +from django.shortcuts import render +from django.contrib import messages +from django.utils.translation import gettext_lazy as _ +from django.http import HttpResponse, JsonResponse +from horilla.decorators import login_required, permission_required +from accessibility.filters import AccessibilityFilter +from accessibility.models import DefaultAccessibility +from accessibility.accessibility import ACCESSBILITY_FEATURE + + +@login_required +@permission_required("auth.change_permission") +def user_accessibility(request): + """ + User accessibility method + """ + if request.POST: + feature = request.POST["feature"] + accessibility = DefaultAccessibility.objects.filter(feature=feature).first() + accessibility = accessibility if accessibility else DefaultAccessibility() + accessibility.feature = feature + accessibility.filter = dict(request.POST) + accessibility.save() + if len(request.POST.keys()) > 1: + messages.success(request, _("Accessibility filter saved")) + else: + messages.info(request, _("All filter cleared")) + + return HttpResponse("") + + accessibility_filter = AccessibilityFilter() + return render( + request, + "accessibility/accessibility.html", + { + "accessibility": ACCESSBILITY_FEATURE, + "accessibility_filter": accessibility_filter, + }, + ) + + +@login_required +@permission_required("auth.change_permission") +def get_accessibility_data(request): + """ + Save accessibility filter method + """ + feature = request.GET["feature"] + accessibility = DefaultAccessibility.objects.filter(feature=feature).first() + if not accessibility: + return JsonResponse("", safe=False) + return JsonResponse(accessibility.filter) diff --git a/base/methods.py b/base/methods.py index 3396c09df..66abae5c9 100644 --- a/base/methods.py +++ b/base/methods.py @@ -1,5 +1,4 @@ import calendar -import io import json import os import random @@ -11,17 +10,17 @@ from django.conf import settings from django.contrib.staticfiles import finders from django.core.exceptions import ObjectDoesNotExist from django.db import models -from django.db.models import F, ForeignKey, ManyToManyField, OneToOneField, Q +from django.db.models import ForeignKey, ManyToManyField, OneToOneField, Q from django.db.models.functions import Lower from django.forms.models import ModelChoiceField from django.http import HttpResponse -from django.template.loader import get_template, render_to_string +from django.template.loader import get_template from django.utils.translation import gettext as _ from xhtml2pdf import pisa from base.models import Company, CompanyLeaves, DynamicPagination, Holidays from employee.models import Employee, EmployeeWorkInformation -from horilla.decorators import login_required +from horilla.horilla_middlewares import _thread_locals from horilla.horilla_settings import HORILLA_DATE_FORMATS, HORILLA_TIME_FORMATS diff --git a/employee/accessibility.py b/employee/accessibility.py new file mode 100644 index 000000000..e69de29bb diff --git a/employee/filters.py b/employee/filters.py index ed51357b9..1a015ea00 100644 --- a/employee/filters.py +++ b/employee/filters.py @@ -11,13 +11,10 @@ import uuid import django import django_filters from django import forms -from django.contrib.auth.models import Group, Permission from django.utils.translation import gettext as _ -from django_filters import CharFilter, DateFilter +from django_filters import CharFilter # from attendance.models import Attendance -from base.methods import reload_queryset -from base.models import WorkType from employee.models import DisciplinaryAction, Employee, Policy from horilla.filters import FilterSet, filter_by_name from horilla_documents.models import Document diff --git a/employee/methods/methods.py b/employee/methods/methods.py index 1ffa2deba..bb8d1bae9 100644 --- a/employee/methods/methods.py +++ b/employee/methods/methods.py @@ -2,7 +2,6 @@ employee/methods.py """ -import re from itertools import groupby from django.db import models diff --git a/employee/sidebar.py b/employee/sidebar.py index 68029bcbd..7a9a5c76b 100644 --- a/employee/sidebar.py +++ b/employee/sidebar.py @@ -7,6 +7,7 @@ To set Horilla sidebar for employee from django.urls import reverse from django.utils.translation import gettext_lazy as trans +from accessibility.methods import check_is_accessibile from base.templatetags.basefilters import is_reportingmanager MENU = trans("Employee") @@ -21,6 +22,7 @@ SUBMENUS = [ { "menu": trans("Employees"), "redirect": reverse("employee-view"), + "accessibility": "employee.sidebar.employee_accessibility", }, { "menu": trans("Document Requests"), @@ -86,3 +88,16 @@ def rotating_work_type_accessibility(request, submenu, user_perms, *args, **kwar return request.user.has_perm( "base.view_rotatingworktypeassign" ) or is_reportingmanager(request.user) + + +def employee_accessibility(request, submenu, user_perms, *args, **kwargs): + """ + Employee accessibility method + """ + cache_key = request.session.session_key + "accessibility_filter" + employee = getattr(request.user, "employee_get") + return ( + is_reportingmanager(request.user) + or request.user.has_perm("employee.view_employee") + or check_is_accessibile("employee_view", cache_key, employee) + ) diff --git a/employee/views.py b/employee/views.py index e1bed8f88..a364983d2 100755 --- a/employee/views.py +++ b/employee/views.py @@ -37,6 +37,7 @@ from django.utils.translation import gettext as __ from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods +from accessibility.decorators import enter_if_accessible from base.forms import ModelForm from base.methods import ( choosesubordinates, @@ -155,6 +156,11 @@ filter_mapping = { } +def _check_reporting_manager(request): + employee = request.user.employee_get + return employee.reporting_manager.exists() + + # Create your views here. @login_required def get_language_code(request): @@ -233,6 +239,11 @@ def self_info_update(request): @login_required +@enter_if_accessible( + feature="employee_detailed_view", + perm="employee.view_employee", + method=_check_reporting_manager, +) def employee_view_individual(request, obj_id, **kwargs): """ This method is used to view profile of an employee. @@ -894,6 +905,11 @@ def paginator_qry(qryset, page_number): @login_required +@enter_if_accessible( + feature="employee_view", + perm="employee.view_employee", + method=_check_reporting_manager, +) def employee_view(request): """ This method is used to render template for view all employee @@ -1591,6 +1607,11 @@ def employee_update_bank_details(request, obj_id=None): @login_required @hx_request_required +@enter_if_accessible( + feature="employee_view", + perm="employee.view_employee", + method=_check_reporting_manager, +) def employee_filter_view(request): """ This method is used to filter employee. @@ -2046,7 +2067,11 @@ def get_manager_in(request): @login_required -@manager_can_enter("employee.view_employee") +@enter_if_accessible( + feature="employee_view", + perm="employee.view_employee", + method=_check_reporting_manager, +) def employee_search(request): """ This method is used to search employee diff --git a/horilla/filters.py b/horilla/filters.py index 97cd8e3c2..4ca3706c4 100755 --- a/horilla/filters.py +++ b/horilla/filters.py @@ -106,8 +106,13 @@ class HorillaFilterSet(FilterSet): HorillaFilterSet """ + verbose_name: dict = {} + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + for key, value in self.verbose_name.items(): + self.form.fields[key].label = value + request = getattr(_thread_locals, "request", None) if request: setattr(request, "is_filtering", True) diff --git a/horilla/horilla_apps.py b/horilla/horilla_apps.py index f45519da2..e42222a74 100644 --- a/horilla/horilla_apps.py +++ b/horilla/horilla_apps.py @@ -7,6 +7,7 @@ This module is used to register horilla addons from horilla import settings from horilla.settings import INSTALLED_APPS +INSTALLED_APPS.append("accessibility") INSTALLED_APPS.append("horilla_audit") INSTALLED_APPS.append("horilla_widgets") INSTALLED_APPS.append("horilla_crumbs") diff --git a/horilla/horilla_middlewares.py b/horilla/horilla_middlewares.py index e26779c1b..92808ca55 100644 --- a/horilla/horilla_middlewares.py +++ b/horilla/horilla_middlewares.py @@ -14,6 +14,7 @@ from horilla.settings import MIDDLEWARE MIDDLEWARE.append("base.middleware.CompanyMiddleware") MIDDLEWARE.append("horilla.horilla_middlewares.MethodNotAllowedMiddleware") MIDDLEWARE.append("horilla.horilla_middlewares.ThreadLocalMiddleware") +MIDDLEWARE.append("accessibility.middlewares.AccessibilityMiddleware") _thread_locals = threading.local() diff --git a/templates/settings.html b/templates/settings.html index 0d76e1f0c..8f8ec03c1 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -70,6 +70,16 @@ > {% endif %} +
+ {% if perms.auth.view_permission %} + {% trans "Accessibility Restriction" %} + {% endif %} +
{% if perms.auth.view_group %} {% endif %}
+
+ {% if perms.base.view_dynamicemailconfiguration %} + {% trans "Gdrive Backup" %} + {% endif %} +