From 3eca5bb04ca6f559824818265c02ddbed3e1cfb2 Mon Sep 17 00:00:00 2001 From: Horilla Date: Wed, 28 Aug 2024 12:01:40 +0530 Subject: [PATCH] [ADD] BASE: Horilla generic bulk update method --- horilla_views/cbv_methods.py | 163 ++++++++++++-- horilla_views/forms.py | 146 +++++++++++++ horilla_views/generic/cbv/views.py | 117 ++++++++++- .../templates/generic/bulk_form.html | 4 + horilla_views/templates/generic/form.html | 10 +- .../templates/generic/horilla_card.html | 10 + .../generic/horilla_detailed_view.html | 1 + .../templates/generic/horilla_list.html | 53 ++++- .../generic/horilla_profile_view.html | 4 +- .../templates/generic/horilla_tabs.html | 198 ++++++++++-------- 10 files changed, 591 insertions(+), 115 deletions(-) create mode 100644 horilla_views/templates/generic/bulk_form.html diff --git a/horilla_views/cbv_methods.py b/horilla_views/cbv_methods.py index c881b929b..a1a9017e4 100644 --- a/horilla_views/cbv_methods.py +++ b/horilla_views/cbv_methods.py @@ -4,13 +4,15 @@ horilla/cbv_methods.py import types import uuid +from typing import Any from urllib.parse import urlencode from venv import logger -from django import template +from django import forms, template from django.contrib import messages from django.core.cache import cache as CACHE from django.core.paginator import Paginator +from django.db import models from django.db.models.fields.related import ForeignKey from django.db.models.fields.related_descriptors import ( ForwardManyToOneDescriptor, @@ -31,6 +33,67 @@ from horilla import settings from horilla.horilla_middlewares import _thread_locals from horilla_views.templatetags.generic_template_filters import getattribute +FIELD_WIDGET_MAP = { + models.CharField: forms.TextInput(attrs={"class": "oh-input w-100"}), + models.ImageField: forms.FileInput( + attrs={"type": "file", "class": "oh-input w-100"} + ), + models.FileField: forms.FileInput( + attrs={"type": "file", "class": "oh-input w-100"} + ), + models.TextField: forms.Textarea( + { + "class": "oh-input w-100", + "rows": 2, + "cols": 40, + } + ), + models.IntegerField: forms.NumberInput(attrs={"class": "oh-input w-100"}), + models.FloatField: forms.NumberInput(attrs={"class": "oh-input w-100"}), + models.DecimalField: forms.NumberInput(attrs={"class": "oh-input w-100"}), + models.EmailField: forms.EmailInput(attrs={"class": "oh-input w-100"}), + models.DateField: forms.DateInput( + attrs={"type": "date", "class": "oh-input w-100"} + ), + models.DateTimeField: forms.DateTimeInput( + attrs={"type": "date", "class": "oh-input w-100"} + ), + models.TimeField: forms.TimeInput( + attrs={"type": "time", "class": "oh-input w-100"} + ), + models.BooleanField: forms.Select({"class": "oh-select oh-select-2 w-100"}), + models.ForeignKey: forms.Select({"class": "oh-select oh-select-2 w-100"}), + models.ManyToManyField: forms.SelectMultiple( + attrs={"class": "oh-select oh-select-2 select2-hidden-accessible"} + ), + models.OneToOneField: forms.Select({"class": "oh-select oh-select-2 w-100"}), +} + +MODEL_FORM_FIELD_MAP = { + models.CharField: forms.CharField, + models.TextField: forms.CharField, # Textarea can be specified as a widget + models.IntegerField: forms.IntegerField, + models.FloatField: forms.FloatField, + models.DecimalField: forms.DecimalField, + models.ImageField: forms.FileField, + models.FileField: forms.FileField, + models.EmailField: forms.EmailField, + models.DateField: forms.DateField, + models.DateTimeField: forms.DateTimeField, + models.TimeField: forms.TimeField, + models.BooleanField: forms.BooleanField, + models.ForeignKey: forms.ModelChoiceField, + models.ManyToManyField: forms.ModelMultipleChoiceField, + models.OneToOneField: forms.ModelChoiceField, +} + + +BOOLEAN_CHOICES = ( + ("", "----------"), + (True, "Yes"), + (False, "No"), +) + def decorator_with_arguments(decorator): """ @@ -76,6 +139,10 @@ def decorator_with_arguments(decorator): def login_required(view_func): + """ + Decorator to check authenticity of users + """ + def wrapped_view(self, *args, **kwargs): request = getattr(_thread_locals, "request") if not getattr(self, "request", None): @@ -108,6 +175,10 @@ def login_required(view_func): @decorator_with_arguments def permission_required(function, perm): + """ + Decorator to validate user permissions + """ + def _function(self, *args, **kwargs): request = getattr(_thread_locals, "request") if not getattr(self, "request", None): @@ -127,6 +198,20 @@ def permission_required(function, perm): return _function +def hx_request_required(function): + """ + Decorator method that only allow HTMX metod to enter + """ + + def _function(request, *args, **kwargs): + key = "HTTP_HX_REQUEST" + if key not in request.META.keys(): + return render(request, "405.html") + return function(request, *args, **kwargs) + + return _function + + def csrf_input(request): return format_html( '', @@ -211,19 +296,6 @@ def update_initial_cache(request: object, cache: dict, view: object): return -def 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("generic/form.html", context) - return table_html - - class Reverse: reverse: bool = True page: str = "" @@ -331,3 +403,66 @@ def update_saved_filter_cache(request, cache): }, ) return cache + + +def get_nested_field(model_class: models.Model, field_name: str) -> object: + """ + Recursion function to execute nested field logic + """ + if "__" in field_name: + splits = field_name.split("__", 1) + related_model_class = getmodelattribute( + model_class, + splits[0], + ).related.related_model + return get_nested_field(related_model_class, splits[1]) + field = getattribute(model_class, field_name) + return field + + +def get_field_class_map(model_class: models.Model, bulk_update_fields: list) -> dict: + """ + Returns a dictionary mapping field names to their corresponding field classes + for a given model class, including related fields(one-to-one). + """ + field_class_map = {} + for field_name in bulk_update_fields: + field = get_nested_field(model_class, field_name) + field_class_map[field_name] = field.field + return field_class_map + + +def 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("generic/form.html", context) + return table_html + + +def value_to_field(field: object, value: list) -> Any: + """ + return value according to the format of the field + """ + if isinstance(field, models.ManyToManyField): + return [int(val) for val in value] + elif isinstance( + field, + ( + models.DateField, + models.DateTimeField, + models.CharField, + models.EmailField, + models.TextField, + models.TimeField, + ), + ): + value = value[0] + return value + value = eval(str(value[0])) + return value diff --git a/horilla_views/forms.py b/horilla_views/forms.py index 291f30235..054c97e6c 100644 --- a/horilla_views/forms.py +++ b/horilla_views/forms.py @@ -2,12 +2,27 @@ horilla_views/forms.py """ +import os + from django import forms +from django.contrib import messages +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django.template.loader import render_to_string from django.utils.safestring import SafeText +from django.utils.translation import gettext_lazy as _trans from horilla.horilla_middlewares import _thread_locals from horilla_views import models +from horilla_views.cbv_methods import ( + BOOLEAN_CHOICES, + FIELD_WIDGET_MAP, + MODEL_FORM_FIELD_MAP, + get_field_class_map, + structured, + value_to_field, +) +from horilla_views.templatetags.generic_template_filters import getattribute class ToggleColumnForm(forms.Form): @@ -75,3 +90,134 @@ class SavedFilterForm(forms.ModelForm): attrs["placeholder"] = "Saved filter title" if self.instance.pk: self.verbose_name = self.instance.title + + +class DynamicBulkUpdateForm(forms.Form): + """ + DynamicBulkUpdateForm + """ + + verbose_name = _trans("Bulk Update") + + def __init__( + self, + *args, + root_model: models.models.Model = None, + bulk_update_fields: list = [], + ids: list = [], + **kwargs + ): + self.ids = ids + self.root_model = root_model + self.bulk_update_fields = sorted( + bulk_update_fields, key=lambda x: x.count("__") + ) + self.structured = structured + mappings = get_field_class_map(root_model, bulk_update_fields) + self.request = getattribute(_thread_locals, "request") + + super().__init__(*args, **kwargs) + for key, val in mappings.items(): + widget = FIELD_WIDGET_MAP.get(type(val)) + field = MODEL_FORM_FIELD_MAP.get(type(val)) + if widget and field: + if isinstance(val, models.models.BooleanField): + self.fields[key] = forms.ChoiceField( + choices=BOOLEAN_CHOICES, + widget=widget, + label=val.verbose_name.capitalize(), + required=False, + ) + continue + elif not getattribute(val, "related_model"): + self.fields[key] = field( + widget=widget, + label=val.verbose_name.capitalize(), + required=False, + ) + continue + queryset = val.related_model.objects.all() + self.fields[key] = field( + widget=widget, + queryset=queryset, + label=val.verbose_name, + required=False, + ) + + def save(self, *args, **kwargs): + """ + Bulk save method + """ + mappings = get_field_class_map(self.root_model, self.bulk_update_fields) + data_mapp = {} + data_m2m_mapp = {} + relation_mapp = {} + map_queryset = {} + fiels_mapping = {} + parent_model = self.root_model + for key, val in mappings.items(): + field = MODEL_FORM_FIELD_MAP.get(type(val)) + if field: + if not fiels_mapping.get(val.model): + fiels_mapping[val.model] = {} + if not data_m2m_mapp.get(val.model): + data_m2m_mapp[val.model] = {} + if not data_mapp.get(val.model): + data_mapp[val.model] = {} + if not relation_mapp.get(val.model): + if val.model == self.root_model: + relation_mapp[val.model] = "id__in" + else: + related_key = key.split("__")[-2] + field = getattribute(parent_model, related_key) + relation_mapp[val.model] = ( + field.related.field.name + + "__" + + relation_mapp[parent_model] + ) + parent_model = val.model + files = self.files.getlist(key) + value = self.data.getlist(key) + if (not value or not value[0]) and not files: + continue + key = key.split("__")[-1] + model_field = getattribute(val.model, key).field + if isinstance(model_field, models.models.ManyToManyField): + data_m2m_mapp[val.model][key] = value + continue + if files and isinstance( + model_field, + ( + models.models.FileField, + models.models.ImageField, + ), + ): + file_path = os.path.join(model_field.upload_to, files[0].name) + + data_mapp[val.model][key] = file_path + fiels_mapping[val.model][model_field] = files[0] + continue + data_mapp[val.model][key] = value[0] + + for model, data in data_mapp.items(): + queryset = model.objects.filter(**{relation_mapp[model]: self.ids}) + # here fields, files, and related fields- + # get updated but need to save the files manually + queryset.update(**data) + map_queryset[model] = queryset + m2m_data = data_m2m_mapp[model] + # saving m2m + if m2m_data: + for field, ids in m2m_data.items(): + related_objects = getattr( + model, field + ).field.related_model.objects.filter(id__in=ids) + for instance in queryset: + getattr(instance, field).set(related_objects) + for model, files in fiels_mapping.items(): + if files: + for field, file in files.items(): + file_path = os.path.join(field.upload_to, file.name) + default_storage.save(file_path, ContentFile(file.read())) + + messages.success(self.request, _trans("Selected Records updated")) diff --git a/horilla_views/generic/cbv/views.py b/horilla_views/generic/cbv/views.py index 0c262e960..502819033 100644 --- a/horilla_views/generic/cbv/views.py +++ b/horilla_views/generic/cbv/views.py @@ -8,10 +8,14 @@ from urllib.parse import parse_qs from bs4 import BeautifulSoup from django import forms +from django.contrib import messages from django.core.cache import cache as CACHE from django.core.paginator import Page from django.http import HttpRequest, HttpResponse, QueryDict +from django.shortcuts import render from django.urls import resolve, reverse +from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _trans from django.views.generic import DetailView, FormView, ListView, TemplateView from base.methods import closest_numbers, get_key_instances @@ -21,16 +25,18 @@ from horilla.horilla_middlewares import _thread_locals from horilla_views import models from horilla_views.cbv_methods import ( get_short_uuid, + hx_request_required, paginator_qry, sortby, structured, update_initial_cache, update_saved_filter_cache, ) -from horilla_views.forms import ToggleColumnForm +from horilla_views.forms import DynamicBulkUpdateForm, ToggleColumnForm from horilla_views.templatetags.generic_template_filters import getattribute +@method_decorator(hx_request_required, name="dispatch") class HorillaListView(ListView): """ HorillaListView @@ -87,6 +93,9 @@ class HorillaListView(ListView): export_fields: list = [] verbose_name: str = "" + bulk_update_fields: list = [] + bulk_template: str = "generic/bulk_form.html" + def __init__(self, **kwargs: Any) -> None: if not self.view_id: self.view_id = get_short_uuid(4) @@ -112,6 +121,78 @@ class HorillaListView(ListView): if column[1] in hidden_fields: self.visible_column.remove(column) + def bulk_update_accessibility(self) -> bool: + """ + Accessibility method for bulk update + """ + return self.request.user.has_perm( + f"{self.model._meta.app_label}.change_{self.model.__name__.lower()}" + ) + + def serve_bulk_form(self, request: HttpRequest) -> HttpResponse: + """ + Bulk form serve method + """ + + if not self.bulk_update_accessibility(): + return HttpResponse("You dont have permission") + form = self.get_bulk_form() + form.verbose_name = ( + form.verbose_name + + f" ({len((eval(request.GET.get('instance_ids','[]'))))} {_trans('Records')})" + ) + return render( + request, + self.bulk_template, + {"form": form, "post_bulk_path": self.post_bulk_path}, + ) + + def handle_bulk_submission(self, request: HttpRequest) -> HttpRequest: + """ + This method to handle bulk update form submission + """ + if not self.bulk_update_accessibility(): + return HttpResponse("You dont have permission") + + instance_ids = request.GET.get("instance_ids", "[]") + instance_ids = eval(instance_ids) + form = DynamicBulkUpdateForm( + request.POST, + request.FILES, + root_model=self.model, + bulk_update_fields=self.bulk_update_fields, + ids=instance_ids, + ) + if instance_ids and form.is_valid(): + form.save() + + script_id = get_short_uuid(length=3, prefix="bulk") + return HttpResponse( + f""" + + """ + ) + if not instance_ids: + messages.info(request, _trans("No records selected")) + return render( + request, + self.bulk_template, + {"form": form, "post_bulk_path": self.post_bulk_path}, + ) + + def get_bulk_form(self): + """ + Bulk from generating method + """ + # Bulk update feature + return DynamicBulkUpdateForm( + root_model=self.model, bulk_update_fields=self.bulk_update_fields + ) + def get_queryset(self): if not self.queryset: self.queryset = super().get_queryset() @@ -270,6 +351,29 @@ class HorillaListView(ListView): urlpatterns.append(path(self.export_path, self.export_data)) context["export_path"] = self.export_path + if self.bulk_update_fields and self.bulk_update_accessibility(): + get_bulk_path = ( + f"get-bulk-update-{self.view_id}-{self.request.session.session_key}/" + ) + post_bulk_path = ( + f"post-bulk-update-{self.view_id}-{self.request.session.session_key}/" + ) + self.post_bulk_path = post_bulk_path + urlpatterns.append( + path( + get_bulk_path, + self.serve_bulk_form, + ) + ) + urlpatterns.append( + path( + post_bulk_path, + self.handle_bulk_submission, + ) + ) + context["bulk_update_fields"] = self.bulk_update_fields + context["bulk_path"] = get_bulk_path + return context def select_all(self, *args, **kwargs): @@ -380,6 +484,7 @@ class HorillaSectionView(TemplateView): return context +@method_decorator(hx_request_required, name="dispatch") class HorillaDetailedView(DetailView): """ HorillDetailedView @@ -437,6 +542,7 @@ class HorillaDetailedView(DetailView): return context +@method_decorator(hx_request_required, name="dispatch") class HorillaTabView(TemplateView): """ HorillaTabView @@ -469,6 +575,7 @@ class HorillaTabView(TemplateView): return context +@method_decorator(hx_request_required, name="dispatch") class HorillaCardView(ListView): """ HorillaCardView @@ -609,7 +716,12 @@ class HorillaCardView(ListView): return context +@method_decorator(hx_request_required, name="dispatch") class ReloadMessages(TemplateView): + """ + Reload messages + """ + template_name = "generic/messages.html" def get_context_data(self, **kwargs): @@ -639,6 +751,7 @@ def save(self: forms.ModelForm, commit=True, *args, **kwargs): return response +@method_decorator(hx_request_required, name="dispatch") class HorillaFormView(FormView): """ HorillaFormView @@ -811,6 +924,7 @@ class HorillaFormView(FormView): return form +@method_decorator(hx_request_required, name="dispatch") class HorillaNavView(TemplateView): """ HorillaNavView @@ -862,6 +976,7 @@ class HorillaNavView(TemplateView): return context +@method_decorator(hx_request_required, name="dispatch") class HorillaProfileView(DetailView): """ GenericHorillaProfileView diff --git a/horilla_views/templates/generic/bulk_form.html b/horilla_views/templates/generic/bulk_form.html new file mode 100644 index 000000000..981b3d482 --- /dev/null +++ b/horilla_views/templates/generic/bulk_form.html @@ -0,0 +1,4 @@ +
+ {% csrf_token %} + {% include "generic/form.html" %} +
\ No newline at end of file diff --git a/horilla_views/templates/generic/form.html b/horilla_views/templates/generic/form.html index b92fbb279..b6efea7d2 100644 --- a/horilla_views/templates/generic/form.html +++ b/horilla_views/templates/generic/form.html @@ -37,7 +37,7 @@ {% endif %} -
+
{{ form.non_field_errors }}
@@ -83,3 +83,11 @@
+ diff --git a/horilla_views/templates/generic/horilla_card.html b/horilla_views/templates/generic/horilla_card.html index 9c9b614a7..d2c45eb2c 100644 --- a/horilla_views/templates/generic/horilla_card.html +++ b/horilla_views/templates/generic/horilla_card.html @@ -171,6 +171,16 @@ {% endif %} {% else %} + {% if card_status_indications %} +
+ {% for indication in card_status_indications %} + + + {{indication.1}} + + {% endfor %} +
+ {% endif %}
Page not found. 404. diff --git a/horilla_views/templates/generic/horilla_detailed_view.html b/horilla_views/templates/generic/horilla_detailed_view.html index 4b1ebbc6d..e43cefbc6 100644 --- a/horilla_views/templates/generic/horilla_detailed_view.html +++ b/horilla_views/templates/generic/horilla_detailed_view.html @@ -81,6 +81,7 @@ {% endfor %}
+
diff --git a/horilla_views/templates/generic/horilla_list.html b/horilla_views/templates/generic/horilla_list.html index ba6b9ba34..491eb053c 100644 --- a/horilla_views/templates/generic/horilla_list.html +++ b/horilla_views/templates/generic/horilla_list.html @@ -1,5 +1,15 @@ {% load static i18n generic_template_filters %}
+ + {% include "generic/export_fields_modal.html" %} {% else %} + {% if row_status_indications %} +
+ {% for indication in row_status_indications %} + + + {{indication.1}} + + {% endfor %} +
+ {% endif %}
Page not found. 404. diff --git a/horilla_views/templates/generic/horilla_profile_view.html b/horilla_views/templates/generic/horilla_profile_view.html index 85cfbc1fd..039df4c55 100644 --- a/horilla_views/templates/generic/horilla_profile_view.html +++ b/horilla_views/templates/generic/horilla_profile_view.html @@ -131,10 +131,11 @@ >
    {% for action in actions %} + {% if action.accessibility|accessibility:instance %}
  • @@ -145,6 +146,7 @@
  • + {% endif %} {% endfor %}
diff --git a/horilla_views/templates/generic/horilla_tabs.html b/horilla_views/templates/generic/horilla_tabs.html index 250c55798..c741ff6e9 100644 --- a/horilla_views/templates/generic/horilla_tabs.html +++ b/horilla_views/templates/generic/horilla_tabs.html @@ -3,102 +3,114 @@ {% comment %} {% include "generic/components.html" %} {% endcomment %} {% comment %} {% include "attendance/attendance/attendance_nav.html" %} {% endcomment %} {% load i18n generic_template_filters %} - -
- -
-
    - {% for tab in tabs %} -
  • - {{tab.title}} -
    -
    - - 0 - -
    - {% if tab.actions %} -
    - -
  • - {% endfor %} -
-
- {% for tab in tabs %} -
-
+ + {% endfor %} + +
+ {% for tab in tabs %} +
+
+
+ {% endfor %} +
- {% endfor %} -
-
- + {% if active_target %} + $("div.oh-tabs").find(`{{active_target|safe}}`).click(); + if (!$(".oh-tabs__tab--active").length) { + $("#{{view_id}}").find(".oh-tabs__tab").first().click() + } + {% endif %} + +