[ADD] ACCESSIBILITY: Add employee accessibility app for handling employees permissions and accessibility

This commit is contained in:
Horilla
2024-09-18 15:51:44 +05:30
parent 332e970870
commit d34fdcb846
26 changed files with 665 additions and 10 deletions

View File

View File

@@ -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")),
]

3
accessibility/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

15
accessibility/apps.py Normal file
View File

@@ -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()

View File

@@ -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"""
<script>
window.location.href="{referrer}"
</script>
"""
)
return redirect(path)
return check_accessible

102
accessibility/filters.py Normal file
View File

@@ -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

30
accessibility/methods.py Normal file
View File

@@ -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

View File

@@ -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

View File

16
accessibility/models.py Normal file
View File

@@ -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()

67
accessibility/signals.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,146 @@
{% extends "settings.html" %}
{% block settings %}
{% load i18n %}
<div id="response" hidden>
</div>
<div
class="oh-inner-sidebar-content__header d-flex justify-content-between align-items-center"
>
<h2 class="oh-inner-sidebar-content__title oh-label__info">
{% trans "Default Accessibility" %}
<span class="oh-info mr-2 mb-2" title="{% trans "Limit default view access to horilla feature" %}">
</span>
</h2>
</div>
<div class="oh-card" id="accessibilityContainer">
<div class="oh-accordion-meta">
{% for accessibility, display in accessibility %}
<div class="oh-accordion-meta__item">
<div class="oh-accordion-meta__header">
<span class="oh-accordion-meta__title"
>{{display}}</span
>
<div class="oh-accordion-meta__actions" onclick="event.stopPropagation()">
<div class="oh-dropdown" x-data="{open: false}">
<button
class="oh-btn oh-stop-prop oh-accordion-meta__btn"
@click="open = !open"
@click.outside="open = false"
>
{% trans "Actions" %}
<ion-icon
class="ms-2 oh-accordion-meta__btn-icon"
name="caret-down-outline"
></ion-icon>
</button>
<div
class="oh-dropdown__menu oh-dropdown__menu--right"
x-show="open"
>
<ul class="oh-dropdown__items">
{% comment %} <li class="oh-dropdown__item">
<a href="#" class="oh-dropdown__link">Archive</a>
</li> {% endcomment %}
<li class="oh-dropdown__item">
<a
href="#"
class="oh-dropdown__link oh-dropdown__link--danger"
onclick="
Swal.fire({
title: 'Are you sure?',
text: `{% trans "You won't be able to revert this!" %}`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: '{% trans 'Yes' %}, {% trans 'Clear it' %}!'
}).then((result) => {
if (result.isConfirmed) {
$('#{{accessibility}}_body select').val('')
$('#{{accessibility}}_body').find('input[type=submit]').click()
$('#{{accessibility}}_body select').parent().find('span').remove()
$('#{{accessibility}}_body select').select2()
}
});
"
>{% trans "Clear Filter" %}</a
>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="oh-accordion-meta__body d-none" id="{{accessibility}}_body">
<form hx-post="" hx-target="#response" hx-swap="afterend">
<input hidden type="text" name="feature" value="{{accessibility}}">
{{accessibility_filter.form.structured}}
<input hidden type="submit" value="submit">
</form>
<script>
$(document).ready(function () {
setTimeout(() => {
$.ajax({
url: "{% url 'get-initial-accessibility-data' %}?feature={{accessibility}}",
success: function (response) {
for (let key in response) {
if (response.hasOwnProperty(key)) {
let values = response[key];
let field = document.querySelector(`#{{accessibility}}_body [name="${key}"]`);
if (field) {
// Handle select fields
if (field.tagName === 'SELECT') {
// Check if it's a multiple select
if (field.multiple) {
// Loop through the options and set selected if the value matches
for (let option of field.options) {
option.selected = values.includes(option.value);
}
} else {
// For single select, set the value
field.value = values[0];
}
}
}
}
}
select = $("#accessibilityContainer #{{accessibility}}_body").find("select")
select.parent().find('span').remove()
select.select2()
}
});
}, 100);
});
</script>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
// Save the filter form while change the filter form
$("#accessibilityContainer select").change(function (e) {
$(this).parent().closest("form").find("input[type=submit]").click();
});
$(document).mouseup(function(e) {
var container = $('.select2.select2-container');
if (!container.is(e.target) && container.has(e.target).length === 0) {
setTimeout(() => {
$(".select2-container.select2-container--default.select2-container--open").removeClass("select2-container--open")
}, 10);
}
});
</script>
<script>
const items = document.querySelectorAll("#accessibilityContainer select");
items.forEach((item, index) => {
item.id = `select-${index + 1}`;
});
</script>
{% endblock settings %}

View File

@@ -0,0 +1,22 @@
{% load widget_tweaks %} {% load i18n %}
{% load generic_template_filters %}
<div class="row" style="padding-right: 20px; padding-right: 10px;">
<div class="col-12" style="padding-right: 20px !important;">{{ form.non_field_errors }}</div>
{% for field in form.visible_fields %}
<div class="col-12 col-md-{{ field|col }}" id="id_{{ field.name }}_parent_div" style="padding-right: 0;">
<div class="oh-label__info" for="id_{{ field.name }}">
<label class="oh-label {% if field.field.required %} required-star{% endif %}" for="id_{{ field.name }}"><b>{% trans field.label %}</b></label>
{% if field.help_text != '' %}
<span class="oh-info mr-2" title="{{ field.help_text|safe }}"></span>
{% endif %}
</div>
{% if field.field.widget.input_type == 'checkbox' %}
<div class="oh-switch" style="width: 30px">{{ field|add_class:'oh-switch__checkbox' }}</div>
{% else %}
<div id="dynamic_field_{{ field.name }}">{{ field|add_class:'form-control' }}
{{ field.errors }}</div>
{% endif %}
</div>
{% endfor %}
</div>

3
accessibility/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

20
accessibility/urls.py Normal file
View File

@@ -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",
),
]

58
accessibility/views.py Normal file
View File

@@ -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("<script>$('#reloadMessagesButton').click()</script>")
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)

View File

@@ -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

View File

View File

@@ -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

View File

@@ -2,7 +2,6 @@
employee/methods.py
"""
import re
from itertools import groupby
from django.db import models

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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()

View File

@@ -70,6 +70,16 @@
>
{% endif %}
</div>
<div class="oh-input-group">
{% if perms.auth.view_permission %}
<a
id="defaultAccessibility"
href="{% url 'user-accessibility' %}"
class="oh-inner-sidebar__link oh-dropdown__link"
>{% trans "Accessibility Restriction" %}</a
>
{% endif %}
</div>
<div class="oh-input-group">
{% if perms.auth.view_group %}
<a
@@ -110,6 +120,16 @@
>
{% endif %}
</div>
<div class="oh-input-group">
{% if perms.base.view_dynamicemailconfiguration %}
<a
id="date"
{% comment %} href="{% url 'gdrive' %}" {% endcomment %}
class="oh-inner-sidebar__link oh-dropdown__link"
>{% trans "Gdrive Backup" %}</a
>
{% endif %}
</div>
</div>
</div>
</div>