diff --git a/base/horilla_company_manager.py b/base/horilla_company_manager.py index fac16c369..7319029c4 100644 --- a/base/horilla_company_manager.py +++ b/base/horilla_company_manager.py @@ -3,10 +3,30 @@ horilla_company_manager.py """ import threading +from typing import Coroutine, Sequence from django.db import models +from django.db.models.query import QuerySet from base.thread_local_middleware import _thread_locals +from horilla.signals import post_bulk_update, pre_bulk_update + +django_filter_update = QuerySet.update + + +def update(self, *args, **kwargs): + # pre_update signal + request = getattr(_thread_locals, "request", None) + self.request = request + pre_bulk_update.send(sender=self.model, queryset=self, args=args, kwargs=kwargs) + result = django_filter_update(self, *args, **kwargs) + # post_update signal + post_bulk_update.send(sender=self.model, queryset=self, args=args, kwargs=kwargs) + + return result + + +setattr(QuerySet, "update", update) class HorillaCompanyManager(models.Manager): @@ -76,3 +96,8 @@ class HorillaCompanyManager(models.Manager): except: pass return queryset + + def filter(self, *args, **kwargs): + queryset = super().filter(*args, **kwargs) + setattr(_thread_locals, "queryset_filter", queryset) + return queryset diff --git a/base/signals.py b/base/signals.py index 8a01091b4..e69de29bb 100644 --- a/base/signals.py +++ b/base/signals.py @@ -1,8 +0,0 @@ -# from django.db.models.signals import post_save -# from notifications.signals import notify -# from base.models import * - -# def my_handler(sender, instance, created, **kwargs): -# notify.send(instance, verb='was saved') - -# post_save.connect(my_handler, sender=ShiftRequest) diff --git a/employee/models.py b/employee/models.py index ad8f4c287..381bbbe38 100644 --- a/employee/models.py +++ b/employee/models.py @@ -156,6 +156,9 @@ class Employee(models.Model): """ return getattr(getattr(self, "employee_work_info", None), "email", self.email) + def get_email(self): + return self.get_mail() + def get_work_type(self): """ This method is used to return the work type of the employee diff --git a/horilla/filters.py b/horilla/filters.py index d96f577fa..99ea8db1d 100755 --- a/horilla/filters.py +++ b/horilla/filters.py @@ -11,6 +11,7 @@ from django.db import models from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS from base.methods import reload_queryset +from horilla_views.templatetags.generic_template_filters import getattribute FILTER_FOR_DBFIELD_DEFAULTS[models.ForeignKey][ "filter_class" @@ -97,3 +98,25 @@ class HorillaPaginator(Paginator): else self.per_page ) return self.page + + +class HorillaFilterSet(FilterSet): + """ + HorillaFilterSet + """ + + def search_in(self, queryset, name, value): + """ + Search in generic method for filter field + """ + search = value.lower() + search_field = self.data.get("search_field") + if not search_field: + search_field = self.filters[name].field_name + + def _icontains(instance): + result = str(getattribute(instance, search_field)).lower() + return instance.pk if search in result else None + + ids = list(filter(None, map(_icontains, queryset))) + return queryset.filter(id__in=ids) diff --git a/horilla/horilla_apps.py b/horilla/horilla_apps.py index 21c432e3b..87b621253 100644 --- a/horilla/horilla_apps.py +++ b/horilla/horilla_apps.py @@ -15,6 +15,8 @@ INSTALLED_APPS.append("horilla_documents") INSTALLED_APPS.append("haystack") INSTALLED_APPS.append("helpdesk") INSTALLED_APPS.append("offboarding") +INSTALLED_APPS.append("horilla_views") +INSTALLED_APPS.append("horilla_automations") INSTALLED_APPS.append("auditlog") diff --git a/horilla/signals.py b/horilla/signals.py new file mode 100644 index 000000000..b0acc5554 --- /dev/null +++ b/horilla/signals.py @@ -0,0 +1,9 @@ +""" +horilla/signals.py +""" + +from django.dispatch import Signal, receiver + + +pre_bulk_update = Signal() +post_bulk_update = Signal() diff --git a/horilla/urls.py b/horilla/urls.py index a31a14d30..2cad41f5c 100755 --- a/horilla/urls.py +++ b/horilla/urls.py @@ -28,6 +28,8 @@ urlpatterns = [ path("accounts/", include("django.contrib.auth.urls")), path("accounts/", include("django.contrib.auth.urls")), path("", include("base.urls")), + path("", include("horilla_automations.urls")), + path("", include("horilla_views.urls")), path("recruitment/", include("recruitment.urls")), path("employee/", include("employee.urls")), path("leave/", include("leave.urls")), diff --git a/horilla_automations/__init__.py b/horilla_automations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_automations/admin.py b/horilla_automations/admin.py new file mode 100644 index 000000000..6d166a899 --- /dev/null +++ b/horilla_automations/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from horilla_automations.models import MailAutomation + +# Register your models here. + + +admin.site.register( + [ + MailAutomation, + ] +) diff --git a/horilla_automations/apps.py b/horilla_automations/apps.py new file mode 100644 index 000000000..6bedeb5ac --- /dev/null +++ b/horilla_automations/apps.py @@ -0,0 +1,36 @@ +from django.apps import AppConfig +from horilla_automations.signals import start_automation + + +class HorillaAutomationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "horilla_automations" + + def ready(self) -> None: + ready = super().ready() + try: + from recruitment.models import Candidate + from horilla_automations.models import MODEL_CHOICES + from employee.models import Employee + from horilla_automations.methods.methods import get_related_models + + main_models = [Candidate, Employee] + for main_model in main_models: + related_models = get_related_models(main_model) + + for model in related_models: + path = f"{model.__module__}.{model.__name__}" + MODEL_CHOICES.append((path, model.__name__)) + MODEL_CHOICES.append(("employee.models.Employee", "Employee")) + MODEL_CHOICES = list(set(MODEL_CHOICES)) + try: + start_automation() + except: + """ + Migrations are not affected yet + """ + except: + """ + Models not ready yet + """ + return ready diff --git a/horilla_automations/filters.py b/horilla_automations/filters.py new file mode 100644 index 000000000..1d7094932 --- /dev/null +++ b/horilla_automations/filters.py @@ -0,0 +1,18 @@ +""" +horilla_automations/filters.py +""" + +from horilla.filters import HorillaFilterSet, django_filters +from horilla_automations.models import MailAutomation + + +class AutomationFilter(HorillaFilterSet): + """ + AutomationFilter + """ + + search = django_filters.CharFilter(field_name="title", lookup_expr="icontains") + + class Meta: + model = MailAutomation + fields = "__all__" diff --git a/horilla_automations/forms.py b/horilla_automations/forms.py new file mode 100644 index 000000000..81dc12a68 --- /dev/null +++ b/horilla_automations/forms.py @@ -0,0 +1,66 @@ +""" +horilla_automations/forms.py +""" + +from typing import Any +from django import forms +from django.template.loader import render_to_string +from horilla_automations.methods.methods import generate_choices +from horilla_automations.models import MODEL_CHOICES, MailAutomation +from base.forms import ModelForm + + +class AutomationForm(ModelForm): + """ + AutomationForm + """ + + condition_html = forms.CharField(widget=forms.HiddenInput()) + condition_querystring = forms.CharField(widget=forms.HiddenInput()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.data: + mail_to = [] + + initial = [] + mail_details_choice = [] + if self.instance.pk: + mail_to = generate_choices(self.instance.model)[0] + mail_details_choice = generate_choices(self.instance.model)[1] + self.fields["mail_to"] = forms.MultipleChoiceField(choices=mail_to) + self.fields["mail_details"] = forms.ChoiceField( + choices=mail_details_choice, + help_text="Fill mail template details(reciever/instance, `self` will be the person who trigger the automation)", + ) + self.fields["mail_to"].initial = initial + attrs = self.fields["mail_to"].widget.attrs + attrs["class"] = "oh-select oh-select-2 w-100" + attrs = self.fields["model"].widget.attrs + self.fields["model"].choices = [("", "Select model")] + list(set(MODEL_CHOICES)) + attrs["onchange"] = "getToMail($(this))" + self.fields["mail_template"].empty_label = None + attrs = attrs.copy() + del attrs["onchange"] + self.fields["mail_details"].widget.attrs = attrs + if self.instance.pk: + self.fields["condition"].initial = self.instance.condition_html + self.fields["condition_html"].initial = self.instance.condition_html + self.fields["condition_querystring"].initial = ( + self.instance.condition_querystring + ) + + class Meta: + model = MailAutomation + fields = "__all__" + + def save(self, commit: bool = ...) -> Any: + self.instance: MailAutomation = self.instance + condition_querystring = self.cleaned_data["condition_querystring"] + condition_html = self.cleaned_data["condition_html"] + mail_to = self.data.getlist("mail_to") + self.instance.mail_to = str(mail_to) + self.instance.mail_details = self.data["mail_details"] + self.instance.condition_querystring = condition_querystring + self.instance.condition_html = condition_html + return super().save(commit) diff --git a/horilla_automations/methods/__init__.py b/horilla_automations/methods/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_automations/methods/methods.py b/horilla_automations/methods/methods.py new file mode 100644 index 000000000..61c445db8 --- /dev/null +++ b/horilla_automations/methods/methods.py @@ -0,0 +1,136 @@ +""" +horilla_automations/methods/methods.py + +""" + +import operator +from django.http import QueryDict +from django.core.exceptions import FieldDoesNotExist +from employee.models import Employee +from horilla.models import HorillaModel +from recruitment.models import Candidate + + +def get_related_models(model: HorillaModel) -> list: + related_models = [] + for field in model._meta.get_fields(): + if field.one_to_many or field.one_to_one or field.many_to_many: + related_model = field.related_model + related_models.append(related_model) + return related_models + + +def generate_choices(model_path): + module_name, class_name = model_path.rsplit(".", 1) + + module = __import__(module_name, fromlist=[class_name]) + model_class: Employee = getattr(module, class_name) + + to_fields = [] + mail_details_choice = [] + + for field in list(model_class._meta.fields) + list(model_class._meta.many_to_many): + related_model = field.related_model + if related_model in [Candidate, Employee]: + email_field = ( + f"{field.name}__get_email", + f"{field.verbose_name.capitalize().replace(' id','')} mail field ", + ) + mail_detail = ( + f"{field.name}__pk", + field.verbose_name.capitalize().replace(" id", ""), + ) + if field.related_model == Employee: + to_fields.append( + ( + f"{field.name}__employee_work_info__reporting_manager_id__get_email", + f"{field.verbose_name.capitalize().replace(' id','')}'s reporting manager", + ) + ) + mail_details_choice.append( + ( + f"{field.name}__employee_work_info__reporting_manager_id__pk", + f"{field.verbose_name.capitalize().replace(' id','')}'s reporting manager", + ) + ) + + to_fields.append(email_field) + mail_details_choice.append(mail_detail) + if model_class in [Candidate, Employee]: + to_fields.append( + ( + "get_email", + f"{model_class.__name__}'s mail", + ) + ) + mail_details_choice.append(("pk", model_class.__name__)) + + to_fields = list(set(to_fields)) + return to_fields, mail_details_choice, model_class + + +def get_model_class(model_path): + """ + method to return the model class from string 'app.models.Model' + """ + module_name, class_name = model_path.rsplit(".", 1) + + module = __import__(module_name, fromlist=[class_name]) + model_class: Employee = getattr(module, class_name) + return model_class + + +operator_map = { + "==": operator.eq, + "!=": operator.ne, + "and": lambda x, y: x and y, + "or": lambda x, y: x or y, +} + + +def querydict(query_string): + query_dict = QueryDict(query_string) + return query_dict + + +def split_query_string(query_string): + """ + Split the query string based on the "&logic=" substring + """ + query_parts = query_string.split("&logic=") + result = [] + + for i, part in enumerate(query_parts): + if i != 0: + result.append(querydict("&logic=" + part)) + else: + result.append(querydict(part)) + return result + + +def evaluate_condition(value1, operator_str, value2): + op_func = operator_map.get(operator_str) + if op_func is None: + raise ValueError(f"Invalid operator: {operator_str}") + return op_func(value1, value2) + + +def get_related_field_model(model: Employee, field_path): + parts = field_path.split("__") + for part in parts: + # Handle the special case for 'pk' + if part == "pk": + field = model._meta.pk + else: + try: + field = model._meta.get_field(part) + except FieldDoesNotExist: + # Handle the case where the field does not exist + raise + + if field.is_relation: + model = field.related_model + else: + # If the part is a non-relation field, break the loop + break + return model diff --git a/horilla_automations/methods/serialize.py b/horilla_automations/methods/serialize.py new file mode 100644 index 000000000..fa58d18dc --- /dev/null +++ b/horilla_automations/methods/serialize.py @@ -0,0 +1,83 @@ +""" +horilla_automation/methods/serialize.py +""" + +from django import forms +from django.db import models + + +def get_related_model_fields(model): + fields = [] + MODEL = model + + class _InstantModelForm(forms.ModelForm): + class Meta: + model = MODEL + fields = "__all__" + + instant_form = _InstantModelForm() + for field_name, field in instant_form.fields.items(): + field_info = { + "name": field_name, + "type": field.widget.__class__.__name__, + "label": field.label, + "required": field.required, + } + fields.append(field_info) + if hasattr(field, "queryset"): + field_info["options"] = [ + {"value": choice.pk, "label": str(choice)} for choice in field.queryset + ] + elif hasattr(field, "choices") and field.choices: + field_info["options"] = [ + {"value": choice[0], "label": choice[1]} for choice in field.choices + ] + + return fields + + +def serialize_form(form, prefix=""): + """ + serialize_form + """ + form_fields = form.fields + form_structure = [] + + for field_name, field in form_fields.items(): + field_structure = { + "name": prefix + field_name, + "type": field.widget.__class__.__name__, + "label": field.label, + "required": field.required, + } + + # If the field is a CharField, include the max_length property + if isinstance(field, forms.CharField): + field_structure["max_length"] = field.max_length + + # If the field is a Select field, include the options + if isinstance(field.widget, forms.Select): + field_structure["options"] = [ + {"value": str(key), "label": str(value)} for key, value in field.choices + ] + form_structure.append(field_structure) + + if isinstance(field, forms.ModelChoiceField): + related_model = field.queryset.model + related_fields = get_related_model_fields(related_model) + related_field_structures = [] + for related_field in related_fields: + related_field_structure = { + "name": prefix + field_name + "__" + related_field["name"], + "type": related_field["type"], + "label": related_field["label"].capitalize() + + " | " + + field.label, + "required": related_field["required"], + } + if related_field.get("options"): + related_field_structure["options"] = related_field["options"] + form_structure.append(related_field_structure) + field_structure["related_fields"] = related_field_structures + + return form_structure diff --git a/horilla_automations/migrations/__init__.py b/horilla_automations/migrations/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/horilla_automations/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/horilla_automations/models.py b/horilla_automations/models.py new file mode 100644 index 000000000..2e62b45e3 --- /dev/null +++ b/horilla_automations/models.py @@ -0,0 +1,113 @@ +from typing import Any +from django.conf import settings +from django.db import models +from django.urls import reverse +from horilla.models import HorillaModel +from django.utils.translation import gettext_lazy as _trans +from horilla_automations.methods.methods import get_related_models +from employee.models import Employee +from horilla_views.cbv_methods import render_template +from recruitment.models import RecruitmentMailTemplate + +MODEL_CHOICES = [] + +CONDITIONS = [ + ("equal", _trans("Equal (==)")), + ("notequal", _trans("Not Equal (!=)")), + ("lt", _trans("Less Than (<)")), + ("gt", _trans("Greater Than (>)")), + ("le", _trans("Less Than or Equal To (<=)")), + ("ge", _trans("Greater Than or Equal To (>=)")), + ("icontains", _trans("Contains")), +] + + +class MailAutomation(HorillaModel): + """ + MailAutoMation + """ + + choices = [ + ("on_create", "On Create"), + ("on_update", "On Update"), + ("on_delete", "On Delete"), + ] + title = models.CharField(max_length=50, unique=True) + method_title = models.CharField(max_length=50, editable=False) + model = models.CharField(max_length=100, choices=MODEL_CHOICES, null=False) + mail_to = models.TextField(verbose_name="Mail to") + mail_details = models.CharField( + max_length=250, + help_text="Fill mail template details(reciever/instance, `self` will be the person who trigger the automation)", + ) + mail_detail_choice = models.TextField(default="", editable=False) + trigger = models.CharField(max_length=10, choices=choices) + # udpate the on_update logic to if and only if when + # changes in the previous and current value + mail_template = models.ForeignKey(RecruitmentMailTemplate, on_delete=models.CASCADE) + template_attachments = models.ManyToManyField( + RecruitmentMailTemplate, + related_name="template_attachment", + blank=True, + ) + condition_html = models.TextField(null=True, editable=False) + condition_querystring = models.TextField(null=True, editable=False) + + condition = models.TextField() + + def save(self, *args, **kwargs): + if not self.pk: + self.method_title = self.title.replace(" ", "_").lower() + return super().save(*args, **kwargs) + + def __str__(self) -> str: + return self.title + + def get_avatar(self): + """ + Method will retun the api to the avatar or path to the profile image + """ + url = f"https://ui-avatars.com/api/?name={self.title}&background=random" + + return url + + def get_mail_to_display(self): + """ + method that returns the display value for `mail_to` + field + """ + mail_to = eval(self.mail_to) + mappings = [] + for mapping in mail_to: + mapping = mapping.split("__") + display = "" + for split in mapping: + split = split.replace("_id", "").replace("_", " ") + split = split.capitalize() + display = display + f"{split} >" + display = display[:-1] + mappings.append(display) + return render_template( + "horilla_automations/mail_to.html", {"instance": self, "mappings": mappings} + ) + + def detailed_url(self): + return reverse("automation-detailed-view", kwargs={"pk": self.pk}) + + def conditions(self): + return render_template( + "horilla_automations/conditions.html", {"instance": self} + ) + + def delete_url(self): + return reverse("delete-automation", kwargs={"pk": self.pk}) + + def edit_url(self): + """ + Edit url + """ + return reverse("update-automation", kwargs={"pk": self.pk}) + + def trigger_display(self): + """""" + return self.get_trigger_display() diff --git a/horilla_automations/signals.py b/horilla_automations/signals.py new file mode 100644 index 000000000..ff5321caf --- /dev/null +++ b/horilla_automations/signals.py @@ -0,0 +1,386 @@ +""" +horilla_automation/signals.py + +""" + +import copy +import threading +import types +from django import template +from django.db.models.signals import post_save, pre_save, post_delete +from django.dispatch import receiver +from django.db.models.query import QuerySet +from django.db import models +from django.core.mail import EmailMessage +from base.thread_local_middleware import _thread_locals +from horilla.signals import pre_bulk_update, post_bulk_update + + +@classmethod +def from_list(cls, object_list): + # Create a queryset-like object from the list + queryset_like_object = cls(model=object_list[0].__class__) + queryset_like_object._result_cache = list(object_list) + queryset_like_object._prefetch_related_lookups = () + return queryset_like_object + + +setattr(QuerySet, "from_list", from_list) + +SIGNAL_HANDLERS = [] +INSTANCE_HANDLERS = [] + + +def start_automation(): + """ + Automation signals + """ + from horilla_automations.models import MailAutomation + from horilla_automations.methods.methods import ( + split_query_string, + get_model_class, + ) + + @receiver(post_delete, sender=MailAutomation) + @receiver(post_save, sender=MailAutomation) + def automation_pre_create(sender, instance, **kwargs): + """ + signal method to handle automation post save + """ + start_connection() + track_previous_instance() + + def clear_connection(): + """ + Method to clear signals handlers + """ + for handler in SIGNAL_HANDLERS: + post_save.disconnect(handler, sender=handler.model_class) + post_bulk_update.disconnect(handler, sender=handler.model_class) + SIGNAL_HANDLERS.clear() + + def create_post_bulk_update_handler(automation, model_class, query_strings): + def post_bulk_update_handler(sender, queryset, *args, **kwargs): + def _bulk_update_thread_handler( + queryset, previous_queryset_copy, automation + ): + request = getattr(queryset, "request", None) + + if request: + for index, instance in enumerate(queryset): + previous_instance = previous_queryset_copy[index] + send_automated_mail( + request, + False, + automation, + query_strings, + instance, + previous_instance, + ) + + previous_bulk_record = getattr(_thread_locals, "previous_bulk_record", None) + previous_queryset = None + if previous_bulk_record: + previous_queryset = previous_bulk_record["queryset"] + previous_queryset_copy = previous_bulk_record["queryset_copy"] + + bulk_thread = threading.Thread( + target=_bulk_update_thread_handler, + args=(queryset, previous_queryset_copy, automation), + ) + bulk_thread.start() + + func_name = f"{automation.method_title}_post_bulk_signal_handler" + + # Dynamically create a function with a unique name + handler = types.FunctionType( + post_bulk_update_handler.__code__, + globals(), + name=func_name, + argdefs=post_bulk_update_handler.__defaults__, + closure=post_bulk_update_handler.__closure__, + ) + + # Set additional attributes on the function + handler.model_class = model_class + handler.automation = automation + + return handler + + def start_connection(): + """ + Method to start signal connection accordingly to the automation + """ + clear_connection() + automations = MailAutomation.objects.filter(is_active=True) + for automation in automations: + + condition_querystring = automation.condition_querystring.replace( + "automation_multiple_", "" + ) + + query_strings = split_query_string(condition_querystring) + + model_path = automation.model + model_class = get_model_class(model_path) + + handler = create_post_bulk_update_handler( + automation, model_class, query_strings + ) + SIGNAL_HANDLERS.append(handler) + + post_bulk_update.connect(handler, sender=model_class) + + def create_signal_handler(name, automation, query_strings): + def signal_handler(sender, instance, created, **kwargs): + """ + Signal handler for post-save events of the model instances. + """ + request = getattr(_thread_locals, "request", None) + previous_record = getattr(_thread_locals, "previous_record", None) + previous_instance = None + if previous_record: + previous_instance = previous_record["instance"] + + args = ( + request, + created, + automation, + query_strings, + instance, + previous_instance, + ) + thread = threading.Thread( + target=lambda: send_automated_mail(*args), + ) + thread.start() + + signal_handler.__name__ = name + signal_handler.model_class = model_class + signal_handler.automation = automation + return signal_handler + + # Create and connect the signal handler + handler_name = f"{automation.method_title}_signal_handler" + dynamic_signal_handler = create_signal_handler( + handler_name, automation, query_strings + ) + SIGNAL_HANDLERS.append(dynamic_signal_handler) + post_save.connect( + dynamic_signal_handler, sender=dynamic_signal_handler.model_class + ) + + def create_pre_bulk_update_handler(automation, model_class): + def pre_bulk_update_handler(sender, queryset, *args, **kwargs): + request = getattr(_thread_locals, "request", None) + if request: + _thread_locals.previous_bulk_record = { + "automation": automation, + "queryset": queryset, + "queryset_copy": QuerySet.from_list(copy.deepcopy(list(queryset))), + } + + func_name = f"{automation.method_title}_pre_bulk_signal_handler" + + # Dynamically create a function with a unique name + handler = types.FunctionType( + pre_bulk_update_handler.__code__, + globals(), + name=func_name, + argdefs=pre_bulk_update_handler.__defaults__, + closure=pre_bulk_update_handler.__closure__, + ) + + # Set additional attributes on the function + handler.model_class = model_class + handler.automation = automation + + return handler + + def track_previous_instance(): + """ + method to add signal to track the automations model previous instances + """ + + def clear_instance_signal_connection(): + """ + Method to clear instance handler signals + """ + for handler in INSTANCE_HANDLERS: + pre_save.disconnect(handler, sender=handler.model_class) + pre_bulk_update.disconnect(handler, sender=handler.model_class) + INSTANCE_HANDLERS.clear() + + clear_instance_signal_connection() + automations = MailAutomation.objects.filter(is_active=True) + for automation in automations: + model_class = get_model_class(automation.model) + + handler = create_pre_bulk_update_handler(automation, model_class) + INSTANCE_HANDLERS.append(handler) + pre_bulk_update.connect(handler, sender=model_class) + + @receiver(pre_save, sender=model_class) + def instance_handler(sender, instance, **kwargs): + """ + Signal handler for pres-save events of the model instances. + """ + # prevented storing the scheduled activities + request = getattr(_thread_locals, "request", None) + if instance.pk: + # to get the previous instance + instance = model_class.objects.filter(id=instance.pk).first() + if request: + _thread_locals.previous_record = { + "automation": automation, + "instance": instance, + } + instance_handler.__name__ = ( + f"{automation.method_title}_instance_handler" + ) + return instance_handler + + instance_handler.model_class = model_class + instance_handler.automation = automation + + INSTANCE_HANDLERS.append(instance_handler) + + track_previous_instance() + start_connection() + + +def send_automated_mail( + request, + created, + automation, + query_strings, + instance, + previous_instance, +): + from horilla_automations.methods.methods import evaluate_condition, operator_map + from horilla_views.templatetags.generic_template_filters import getattribute + + applicable = False + and_exists = False + false_exists = False + instance_values = [] + previous_instance_values = [] + for condition in query_strings: + if condition.getlist("condition"): + attr = condition.getlist("condition")[0] + operator = condition.getlist("condition")[1] + value = condition.getlist("condition")[2] + + if value == "on": + value = True + elif value == "off": + value = False + instance_value = getattribute(instance, attr) + previous_instance_value = getattribute(previous_instance, attr) + # The send mail method only trigger when actually any changes + # b/w the previous, current instance's `attr` field's values and + # if applicable for the automation + if getattr(instance_value, "pk", None) and isinstance( + instance_value, models.Model + ): + instance_value = str(getattr(instance_value, "pk", None)) + previous_instance_value = str( + getattr(previous_instance_value, "pk", None) + ) + elif isinstance(instance_value, QuerySet): + instance_value = list(instance_value.values_list("pk", flat=True)) + previous_instance_value = list( + previous_instance_value.values_list("pk", flat=True) + ) + + instance_values.append(instance_value) + + previous_instance_values.append(previous_instance_value) + + if not condition.get("logic"): + + applicable = evaluate_condition(instance_value, operator, value) + logic = condition.get("logic") + if logic: + applicable = operator_map[logic]( + applicable, + evaluate_condition(instance_value, operator, value), + ) + if not applicable: + false_exists = True + if logic == "and": + and_exists = True + if false_exists and and_exists: + applicable = False + break + if applicable: + if created and automation.trigger == "on_create": + send_mail(request, automation, instance) + elif (automation.trigger == "on_update") and ( + set(previous_instance_values) != set(instance_values) + ): + + send_mail(request, automation, instance) + + +def send_mail(request, automation, instance): + """ + mail sending method + """ + from horilla_views.templatetags.generic_template_filters import getattribute + from horilla_automations.methods.methods import ( + get_model_class, + get_related_field_model, + ) + from base.methods import generate_pdf + from base.backends import ConfiguredEmailBackend + from horilla.decorators import logger + + mail_template = automation.mail_template + pk = getattribute(instance, automation.mail_details) + model_class = get_model_class(automation.model) + model_class = get_related_field_model(model_class, automation.mail_details) + mail_to_instance = model_class.objects.filter(pk=pk).first() + tos = [] + for mapping in eval(automation.mail_to): + tos.append(getattribute(mail_to_instance, mapping)) + to = tos[:1] + cc = tos[1:] + email_backend = ConfiguredEmailBackend() + host = email_backend.dynamic_username + if mail_to_instance and request: + attachments = [] + try: + sender = request.user.employee_get + except: + sender = None + for template_attachment in automation.template_attachments.all(): + template_bdy = template.Template(template_attachment.body) + context = template.Context({"instance": mail_to_instance, "self": sender}) + render_bdy = template_bdy.render(context) + attachments.append( + ( + "Document", + generate_pdf(render_bdy, {}, path=False, title="Document").content, + "application/pdf", + ) + ) + + template_bdy = template.Template(mail_template.body) + context = template.Context({"instance": mail_to_instance, "self": sender}) + render_bdy = template_bdy.render(context) + email = EmailMessage(automation.title, render_bdy, host, to=to, cc=cc) + email.content_subtype = "html" + + email.attachments = attachments + + def _send_mail(email): + try: + email.send() + except Exception as e: + logger.error(e) + + thread = threading.Thread( + target=lambda: _send_mail(email), + ) + thread.start() diff --git a/horilla_automations/static/automation/automation.js b/horilla_automations/static/automation/automation.js new file mode 100644 index 000000000..d3f11b9b4 --- /dev/null +++ b/horilla_automations/static/automation/automation.js @@ -0,0 +1,312 @@ +function getToMail(element) { + model = element.val(); + $.ajax({ + type: "get", + url: "/get-to-mail-field", + data: { + model: model, + }, + success: function (response) { + $(".dynamic-condition-row").remove(); + select = $("#id_mail_to"); + select.html(""); + detailSelect = $("#id_mail_details"); + detailSelect.html(""); + mailTo = response.choices; + mailDetail = response.mail_details_choice; + + for (let option = 0; option < mailTo.length; option++) { + const element = mailTo[option]; + + var selected = option === 0; // Set the first option as selected + var newOption = new Option(element[1], element[0], selected, selected); + select.append(newOption); + } + for (let option = 0; option < mailDetail.length; option++) { + const element = mailDetail[option]; + + var selected = option === 0; // Set the first option as selected + var newOption = new Option(element[1], element[0], selected, selected); + detailSelect.append(newOption); + } + select.trigger("change"); + detailSelect.trigger("change"); + + table = $("#multipleConditionTable"); + $("#multipleConditionTable select").select2("destroy"); + + totalRows = "C" + (table.find(".dynamic-condition-row").length + 1); + + fieldsChoices = []; + $.each(response.serialized_form, function (indexInArray, valueOfElement) { + fieldsChoices.push([valueOfElement["name"], valueOfElement["label"]]); + }); + selectField = populateSelect(fieldsChoices, response); + tr = ` + + ${totalRows} + + + + + + + + + +
+ + +
+ + + `; + table.find("tr:last").after(tr); + $("#conditionalField").append(selectField); + $("#multipleConditionTable select").select2(); + selectField.trigger("change"); + selectField.attr("name", "automation_multiple_condition"); + }, + }); +} + +function getHtml() { + var htmlCode = ` +
+ + + + + + + + + +
CodeFieldConditionValueLogic + Action + + + +
+
+ + `; + return $(htmlCode); +} + +function populateSelect(data, response) { + const selectElement = $( + `` + ); + + data.forEach((item) => { + const $option = $(""); + $option.val(item[0]); + $option.text(item[1]); + selectElement.append($option); + }); + return selectElement; +} + +function updateValue(element) { + field = element.val(); + attr = element.attr("data-response"); + attr = attr + .replace(/[\u0000-\u001F\u007F-\u009F]/g, "") + .replace(/\\n/g, "\\\\n") + .replace(/\\t/g, "\\\\t"); + + response = JSON.parse(attr); + + valueElement = createElement(field, response); + element.closest("tr").find(".condition-value-th").html(""); + element.closest("tr").find(".condition-value-th").html(valueElement); + if (valueElement.is("select")) { + valueElement.select2(); + } +} + +function createElement(field, serialized_form) { + let element; + fieldItem = {}; + + $.each(serialized_form, function (indexInArray, valueOfElement) { + if (valueOfElement.name == field) { + fieldItem = valueOfElement; + } + }); + + switch (fieldItem.type) { + case "CheckboxInput": + element = document.createElement("input"); + element.type = "checkbox"; + element.checked = true; + element.onchange = function () { + if (this.checked) { + $(this).attr("checked", true); + $(this).val("on"); + } else { + $(this).attr("checked", false); + $(this).val("off"); + } + }; + element.name = "automation_multiple_condition"; + element.className = "oh-switch__checkbox oh-switch__checkbox"; + // Create the wrapping div + const wrapperDiv = document.createElement("div"); + wrapperDiv.className = "oh-switch"; + wrapperDiv.style.width = "30px"; + // Append the checkbox input to the div + wrapperDiv.appendChild(element); + $(element).change(); + element = wrapperDiv; + break; + + case "Select": + case "SelectMultiple": + element = document.createElement("select"); + if (fieldItem.type === "SelectMultiple") { + element.multiple = true; + } + element.onchange = function (event) { + addSelectedAttr(event); + }; + fieldItem.options.forEach((optionValue) => { + if (optionValue.value) { + const option = document.createElement("option"); + option.value = optionValue.value; + option.textContent = optionValue.label; + element.appendChild(option); + } + }); + break; + + case "Textarea": + element = document.createElement("textarea"); + element.style = ` + height: 29px !important; + margin-top: 5px; + `; + element.className = "oh-input w-100"; + if (fieldItem.max_length) { + element.maxLength = fieldItem.max_length; + } + element.onchange = function (event) { + $(this).html($(this).val()); + }; + break; + case "TextInput": + element = document.createElement("input"); + element.type = "text"; + element.style = ` + height: 30px !important; + `; + element.className = "oh-input w-100"; + if (fieldItem.max_length) { + element.maxLength = fieldItem.max_length; + } + element.onchange = function (event) { + $(this).attr("value", $(this).val()); + }; + break; + case "EmailInput": + element = document.createElement("input"); + element.type = "email"; + element.style = ` + height: 30px !important; + `; + element.className = "oh-input w-100"; + if (fieldItem.max_length) { + element.maxLength = fieldItem.max_length; + } + element.onchange = function (event) { + $(this).attr("value", $(this).val()); + }; + break; + case "NumberInput": + element = document.createElement("input"); + element.type = "number"; + element.style = ` + height: 30px !important; + `; + element.className = "oh-input w-100"; + if (fieldItem.max_length) { + element.maxLength = fieldItem.max_length; + } + element.onchange = function (event) { + $(this).attr("value", $(this).val()); + }; + break; + default: + element = document.createElement("input"); + element.type = "text"; + element.style = ` + height: 30px !important; + `; + element.className = "oh-input w-100"; + if (fieldItem.max_length) { + element.maxLength = fieldItem.max_length; + } + element.onchange = function (event) { + $(this).attr("value", $(this).val()); + }; + break; + } + if (element) { + element.name = "automation_multiple_condition"; + if (fieldItem.required) { + element.required = true; + } + + // Create label + const label = document.createElement("label"); + label.textContent = fieldItem.label; + label.htmlFor = "automation_multiple_condition"; + + return $(element); + } +} + +function addSelectedAttr(event) { + const options = Array.from(event.target.options); + options.forEach((option) => { + if (option.selected) { + option.setAttribute("selected", "selected"); + } else { + option.removeAttribute("selected"); + } + }); +} diff --git a/horilla_automations/templates/horilla_automations/automation_form.html b/horilla_automations/templates/horilla_automations/automation_form.html new file mode 100644 index 000000000..f28710f26 --- /dev/null +++ b/horilla_automations/templates/horilla_automations/automation_form.html @@ -0,0 +1,37 @@ +
+{% include "generic/horilla_form.html" %} +
+ \ No newline at end of file diff --git a/horilla_automations/templates/horilla_automations/automations.html b/horilla_automations/templates/horilla_automations/automations.html new file mode 100644 index 000000000..819adb719 --- /dev/null +++ b/horilla_automations/templates/horilla_automations/automations.html @@ -0,0 +1,3 @@ +{% extends "index.html" %} +{% block content %} +{% endblock content %} \ No newline at end of file diff --git a/horilla_automations/templates/horilla_automations/conditions.html b/horilla_automations/templates/horilla_automations/conditions.html new file mode 100644 index 000000000..32feb9e7f --- /dev/null +++ b/horilla_automations/templates/horilla_automations/conditions.html @@ -0,0 +1 @@ +{{instance.condition_html|safe|escape }} diff --git a/horilla_automations/templates/horilla_automations/detailed_action.html b/horilla_automations/templates/horilla_automations/detailed_action.html new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_automations/templates/horilla_automations/mail_to.html b/horilla_automations/templates/horilla_automations/mail_to.html new file mode 100644 index 000000000..2ce7078bf --- /dev/null +++ b/horilla_automations/templates/horilla_automations/mail_to.html @@ -0,0 +1,5 @@ +
    + {% for to in mappings %} +
  1. {{to}}
  2. + {% endfor %} +
diff --git a/horilla_automations/templates/horilla_automations/section_view.html b/horilla_automations/templates/horilla_automations/section_view.html new file mode 100644 index 000000000..937cc70c4 --- /dev/null +++ b/horilla_automations/templates/horilla_automations/section_view.html @@ -0,0 +1,17 @@ +{% include "generic/horilla_section.html" %} + diff --git a/horilla_automations/tests.py b/horilla_automations/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/horilla_automations/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/horilla_automations/urls.py b/horilla_automations/urls.py new file mode 100644 index 000000000..490b19c69 --- /dev/null +++ b/horilla_automations/urls.py @@ -0,0 +1,51 @@ +""" +horilla_automations/urls.py +""" + +from django.urls import path +from horilla_automations.views import views +from horilla_automations.views import cbvs + + +urlpatterns = [ + path( + "mail-automations", + cbvs.AutomationSectionView.as_view(), + name="mail-automations", + ), + path( + "mail-automations-nav", + cbvs.AutomationNavView.as_view(), + name="mail-automations-nav", + ), + path( + "create-automation", + cbvs.AutomationFormView.as_view(), + name="create-automation", + ), + path( + "update-automation//", + cbvs.AutomationFormView.as_view(), + name="update-automation", + ), + path( + "mail-automations-list-view", + cbvs.AutomationListView.as_view(), + name="mail-automations-list-view", + ), + path( + "get-to-mail-field", + views.get_to_field, + name="get-to-mail-field", + ), + path( + "automation-detailed-view//", + cbvs.AutomationDetailedView.as_view(), + name="automation-detailed-view", + ), + path( + "delete-automation//", + views.delete_automation, + name="delete-automation", + ), +] diff --git a/horilla_automations/views/__init__.py b/horilla_automations/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_automations/views/cbvs.py b/horilla_automations/views/cbvs.py new file mode 100644 index 000000000..a4e44cfab --- /dev/null +++ b/horilla_automations/views/cbvs.py @@ -0,0 +1,167 @@ +""" +horilla_automations/views/cbvs.py +""" + +from typing import Any +from horilla_automations.filters import AutomationFilter +from horilla_automations.forms import AutomationForm +from horilla_views.generic.cbv import views +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _trans +from django.contrib import messages +from horilla_automations import models + + +class AutomationSectionView(views.HorillaSectionView): + """ + AutomationSectionView + """ + + nav_url = reverse_lazy("mail-automations-nav") + view_url = reverse_lazy("mail-automations-list-view") + view_container_id = "listContainer" + + script_static_paths = [ + "static/automation/automation.js", + ] + + template_name = "horilla_automations/section_view.html" + + +class AutomationNavView(views.HorillaNavView): + """ + AutomationNavView + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.create_attrs = f""" + hx-get="{reverse_lazy("create-automation")}" + hx-target="#genericModalBody" + data-target="#genericModal" + data-toggle="oh-modal-toggle" + """ + + nav_title = _trans("Automations") + search_url = reverse_lazy("mail-automations-list-view") + search_swap_target = "#listContainer" + + +class AutomationFormView(views.HorillaFormView): + """ + AutomationFormView + """ + + form_class = AutomationForm + model = models.MailAutomation + new_display_title = _trans("New Automation") + template_name = "horilla_automations/automation_form.html" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + instance = models.MailAutomation.objects.filter(pk=self.kwargs["pk"]).first() + kwargs["instance"] = instance + + return kwargs + + def form_valid(self, form: AutomationForm) -> views.HttpResponse: + if form.is_valid(): + message = "New automation added" + if form.instance.pk: + message = "Automation updated" + form.save() + + messages.success(self.request, _trans(message)) + return self.HttpResponse() + return super().form_valid(form) + + +class AutomationListView(views.HorillaListView): + """ + AutomationListView + """ + + row_attrs = """ + hx-get="{detailed_url}?instance_ids={ordered_ids}" + hx-target="#genericModalBody" + data-toggle="oh-modal-toggle" + data-target="#genericModal" + """ + + model = models.MailAutomation + search_url = reverse_lazy("mail-automations-list-view") + filter_class = AutomationFilter + + actions = [ + { + "action": "Edit", + "icon": "create-outline", + "attrs": """ + class="oh-btn oh-btn--light-bkg w-100" + hx-get="{edit_url}" + hx-target="#genericModalBody" + data-target="#genericModal" + data-toggle="oh-modal-toggle" + """, + }, + { + "action": "Delete", + "icon": "trash-outline", + "attrs": """ + class="oh-btn oh-btn--light-bkg w-100 tex-danger" + onclick=" + event.stopPropagation(); + confirm('Do you want to delete the automation?','{delete_url}') + " + """, + }, + ] + + columns = [ + ("Title", "title"), + ("Model", "model"), + ("Email Mapping", "get_mail_to_display"), + ] + + +class AutomationDetailedView(views.HorillaDetailedView): + """ + AutomationDetailedView + """ + + model = models.MailAutomation + title = "Detailed View" + header = { + "title": "title", + "subtitle": "title", + "avatar": "get_avatar", + } + body = [ + ("Model", "model"), + ("Mail Templates", "mail_template"), + ("Mail To", "get_mail_to_display"), + ("Trigger", "trigger_display"), + ] + actions = [ + { + "action": "Edit", + "icon": "create-outline", + "attrs": """ + hx-get="{edit_url}" + hx-target="#genericModalBody" + data-toggle="oh-modal-toggle" + data-target="#genericModal" + class="oh-btn oh-btn--info w-50" + """, + }, + { + "action": "Delete", + "icon": "trash-outline", + "attrs": """ + class="oh-btn oh-btn--danger w-50" + onclick=" + confirm('Do you want to delete the automation?','{delete_url}') + " + """, + }, + ] diff --git a/horilla_automations/views/views.py b/horilla_automations/views/views.py new file mode 100644 index 000000000..b41f416de --- /dev/null +++ b/horilla_automations/views/views.py @@ -0,0 +1,60 @@ +""" +horilla_automation/views/views.py +""" + +from django import forms +from django.http import JsonResponse +from horilla.decorators import login_required +from django.shortcuts import redirect +from django.urls import reverse +from django.contrib import messages +from horilla_automations.methods.methods import generate_choices +from horilla_automations.models import MailAutomation +from horilla.decorators import permission_required +from recruitment.models import Candidate +from employee.models import Employee + +from horilla_automations.methods.serialize import serialize_form + + +@login_required +def get_to_field(request): + """ + This method is to render `mail to` fields + """ + model_path = request.GET["model"] + to_fields, mail_details_choice, model_class = generate_choices(model_path) + + class InstantModelForm(forms.ModelForm): + """ + InstantModelForm + """ + + class Meta: + model = model_class + fields = "__all__" + + serialized_form = serialize_form(InstantModelForm(), "automation_multiple_") + + return JsonResponse( + { + "choices": to_fields, + "mail_details_choice": mail_details_choice, + "serialized_form": serialized_form, + } + ) + + +@login_required +@permission_required("horilla_automation") +def delete_automation(request, pk): + """ + Automation delete view + """ + try: + MailAutomation.objects.get(id=pk).delete() + messages.success(request, "Automation deleted") + except Exception as e: + print(e) + messages.error(request, "Something went wrong") + return redirect(reverse("mail-automations")) diff --git a/horilla_views/__init__.py b/horilla_views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_views/admin.py b/horilla_views/admin.py new file mode 100644 index 000000000..656effc43 --- /dev/null +++ b/horilla_views/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from horilla_views.models import ( + ToggleColumn, + ParentModel, + childModel, + ActiveTab, + ActiveGroup, +) + +admin.site.register([ToggleColumn, ParentModel, childModel, ActiveTab, ActiveGroup]) diff --git a/horilla_views/apps.py b/horilla_views/apps.py new file mode 100644 index 000000000..d5819ff6f --- /dev/null +++ b/horilla_views/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HorillaViewsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'horilla_views' diff --git a/horilla_views/cbv_methods.py b/horilla_views/cbv_methods.py new file mode 100644 index 000000000..37339a0e3 --- /dev/null +++ b/horilla_views/cbv_methods.py @@ -0,0 +1,292 @@ +""" +horilla/cbv_methods.py +""" + +from urllib.parse import urlencode +import uuid +from venv import logger +from django import template +from django.shortcuts import redirect, render +from django.template import loader +from django.template.loader import render_to_string +from django.template.defaultfilters import register +from django.urls import reverse +from django.contrib import messages +from django.http import HttpResponse +from django.core.paginator import Paginator +from django.middleware.csrf import get_token +from django.utils.html import format_html +from django.utils.functional import lazy +from django.utils.safestring import SafeString + +from horilla import settings +from horilla_views.templatetags.generic_template_filters import getattribute +from base.thread_local_middleware import _thread_locals + + +def decorator_with_arguments(decorator): + """ + Decorator that allows decorators to accept arguments and keyword arguments. + + Args: + decorator (function): The decorator function to be wrapped. + + Returns: + function: The wrapper function. + + """ + + def wrapper(*args, **kwargs): + """ + Wrapper function that captures the arguments and keyword arguments. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + function: The inner wrapper function. + + """ + + def inner_wrapper(func): + """ + Inner wrapper function that applies the decorator to the function. + + Args: + func (function): The function to be decorated. + + Returns: + function: The decorated function. + + """ + return decorator(func, *args, **kwargs) + + return inner_wrapper + + return wrapper + + +def login_required(view_func): + def wrapped_view(self, *args, **kwargs): + request = getattr(_thread_locals, "request") + if not getattr(self, "request", None): + self.request = request + path = request.path + res = path.split("/", 2)[1].capitalize().replace("-", " ").upper() + if res == "PMS": + res = "Performance" + request.session["title"] = res + if path == "" or path == "/": + request.session["title"] = "Dashboard".upper() + if not request.user.is_authenticated: + login_url = reverse("login") + params = urlencode(request.GET) + url = f"{login_url}?next={request.path}" + if params: + url += f"&{params}" + return redirect(url) + try: + func = view_func(self, request, *args, **kwargs) + except Exception as e: + logger.exception(e) + if not settings.DEBUG: + return render(request, "went_wrong.html") + return view_func(self, *args, **kwargs) + return func + + return wrapped_view + + +@decorator_with_arguments +def permission_required(function, perm): + def _function(self, *args, **kwargs): + request = getattr(_thread_locals, "request") + if not getattr(self, "request", None): + self.request = request + + if request.user.has_perm(perm): + return function(self, *args, **kwargs) + else: + messages.info(request, "You dont have permission.") + previous_url = request.META.get("HTTP_REFERER", "/") + key = "HTTP_HX_REQUEST" + if key in request.META.keys(): + return render(request, "decorator_404.html") + script = f'' + return HttpResponse(script) + + return _function + + + +def csrf_input(request): + return format_html( + '', + get_token(request), + ) + + +@register.simple_tag(takes_context=True) +def csrf_token(context): + """ + to access csrf token inside the render_template method + """ + request = context["request"] + csrf_input_lazy = lazy(csrf_input, SafeString, str) + return csrf_input_lazy(request) + + +def get_all_context_variables(request) -> dict: + """ + This method will return dictionary format of context processors + """ + if getattr(request, "all_context_variables", None) is None: + all_context_variables = {} + for processor_path in settings.TEMPLATES[0]["OPTIONS"]["context_processors"]: + module_path, func_name = processor_path.rsplit(".", 1) + module = __import__(module_path, fromlist=[func_name]) + func = getattr(module, func_name) + context = func(request) + all_context_variables.update(context) + all_context_variables["csrf_token"] = csrf_token(all_context_variables) + request.all_context_variables = all_context_variables + return request.all_context_variables + + +def render_template( + path: str, + context: dict, + decoding: str = "utf-8", + status: int = None, + _using=None, +) -> str: + """ + This method is used to render HTML text with context. + """ + + request = getattr(_thread_locals, "request", None) + context.update(get_all_context_variables(request)) + template_loader = loader.get_template(path) + template_body = template_loader.template.source + template_bdy = template.Template(template_body) + context_instance = template.Context(context) + rendered_content = template_bdy.render(context_instance) + return HttpResponse(rendered_content, status=status).content.decode(decoding) + + +def paginator_qry(qryset, page_number, records_per_page=50): + """ + This method is used to paginate queryset + """ + paginator = Paginator(qryset, records_per_page) + qryset = paginator.get_page(page_number) + return qryset + + +def get_short_uuid(length: int, prefix: str = "hlv"): + """ + Short uuid generating method + """ + uuid_str = str(uuid.uuid4().hex) + return prefix + str(uuid_str[:length]).replace("-", "") + + +def update_initial_cache(request: object, cache: dict, view: object): + if cache.get(request.session.session_key): + cache[request.session.session_key].update({view: {}}) + return + cache.update({request.session.session_key: {view: {}}}) + 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 = "" + + +cache = {} + + +def sortby( + query_dict, queryset, key: str, page: str = "page", is_first_sort: bool = False +): + """ + New simplified method to sort the queryset/lists + """ + request = getattr(_thread_locals, "request", None) + sort_key = query_dict[key] + if not cache.get(request.session.session_key): + cache[request.session.session_key] = Reverse() + cache[request.session.session_key].page = ( + "1" if not query_dict.get(page) else query_dict.get(page) + ) + reverse = cache[request.session.session_key].reverse + none_ids = [] + + def _sortby(object): + result = getattribute(object, attr=sort_key) + if result is None: + none_ids.append(object.pk) + return result + + order = not reverse + current_page = query_dict.get(page) + if current_page or is_first_sort: + order = not order + if ( + cache[request.session.session_key].page == current_page + and not is_first_sort + ): + order = not order + cache[request.session.session_key].page = current_page + try: + queryset = sorted(queryset, key=_sortby, reverse=order) + except TypeError: + none_queryset = list(queryset.filter(id__in=none_ids)) + queryset = sorted(queryset.exclude(id__in=none_ids), key=_sortby, reverse=order) + queryset = queryset + none_queryset + + cache[request.session.session_key].reverse = order + order = "asc" if not order else "desc" + setattr(request, "sort_order", order) + setattr(request, "sort_key", sort_key) + return queryset + + +def update_saved_filter_cache(request, cache): + """ + Method to save filter on cache + """ + if cache.get(request.session.session_key): + cache[request.session.session_key].update( + { + "path": request.path, + "query_dict": request.GET, + "request": request, + } + ) + return cache + cache.update( + { + request.session.session_key: { + "path": request.path, + "query_dict": request.GET, + "request": request, + } + } + ) + return cache diff --git a/horilla_views/forms.py b/horilla_views/forms.py new file mode 100644 index 000000000..4726429b2 --- /dev/null +++ b/horilla_views/forms.py @@ -0,0 +1,35 @@ +""" +horilla_views/forms.py +""" + +from django import forms +from django.utils.safestring import SafeText +from django.template.loader import render_to_string +from base.thread_local_middleware import _thread_locals + + +class ToggleColumnForm(forms.Form): + """ + Toggle column form + """ + + def __init__(self, columns, hidden_fields: list, *args, **kwargs): + request = getattr(_thread_locals, "request", {}) + self.request = request + super().__init__(*args, **kwargs) + for column in columns: + initial = True + if column[1] in hidden_fields: + initial = False + + self.fields[column[1]] = forms.BooleanField( + label=column[0], initial=initial + ) + + def as_list(self) -> SafeText: + """ + Render the form fields as HTML table rows with. + """ + context = {"form": self, "request": self.request} + table_html = render_to_string("generic/as_list.html", context) + return table_html diff --git a/horilla_views/generic/__init__.py b/horilla_views/generic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_views/generic/cbv/__init__.py b/horilla_views/generic/cbv/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_views/generic/cbv/views.py b/horilla_views/generic/cbv/views.py new file mode 100644 index 000000000..e47f999b3 --- /dev/null +++ b/horilla_views/generic/cbv/views.py @@ -0,0 +1,681 @@ +""" +horilla/generic/views.py +""" + +import json +from django import forms +from django.http import HttpRequest, HttpResponse, QueryDict +from django.shortcuts import render +from django.urls import reverse +from typing import Any +from django.urls import resolve +from urllib.parse import parse_qs +from django.core.paginator import Page +from django.views.generic import ListView, DetailView, TemplateView, FormView +from attendance.methods.group_by import group_by_queryset +from base.methods import ( + closest_numbers, + get_key_instances, +) +from horilla.filters import FilterSet +from horilla_views import models +from horilla_views.cbv_methods import ( + get_short_uuid, + paginator_qry, + update_initial_cache, + sortby, + update_saved_filter_cache, +) +from base.thread_local_middleware import _thread_locals +from horilla_views.cbv_methods import structured +from horilla_views.forms import ToggleColumnForm +from horilla_views.templatetags.generic_template_filters import getattribute + + +cache = {} +saved_filters = {} + + +class HorillaListView(ListView): + """ + HorillaListView + """ + + filter_class: FilterSet = None + + view_id: str = """""" + + export_file_name: str = None + + template_name: str = "generic/horilla_list.html" + context_object_name = "queryset" + # column = [("Verbose Name","field_name","avatar_mapping")], opt: avatar_mapping + columns: list = [] + search_url: str = "" + bulk_select_option: bool = True + + action_method: str = """""" + actions: list = [] + + option_method: str = "" + options: list = [] + row_attrs: str = """""" + row_status_class: str = """""" + row_status_indications: list = [] + + sortby_key: str = "sortby" + sortby_mapping: list = [] + + show_filter_tags: bool = True + filter_keys_to_remove: list = [] + + records_per_page: int = 50 + + def __init__(self, **kwargs: Any) -> None: + self.view_id = get_short_uuid(4) + super().__init__(**kwargs) + + request = getattr(_thread_locals, "request", None) + self.request = request + update_initial_cache(request, cache, HorillaListView) + + # hidden columns configuration + existing_instance = models.ToggleColumn.objects.filter( + user_id=request.user, path=request.path_info + ).first() + + hidden_fields = ( + [] if not existing_instance else existing_instance.excluded_columns + ) + + self.visible_column = self.columns.copy() + + self.toggle_form = ToggleColumnForm(self.columns, hidden_fields) + for column in self.columns: + if column[1] in hidden_fields: + self.visible_column.remove(column) + + def get_queryset(self): + queryset = super().get_queryset() + + if self.filter_class: + query_dict = self.request.GET + if "filter_applied" in query_dict.keys(): + update_saved_filter_cache(self.request, saved_filters) + elif saved_filters.get(self.request.session.session_key): + query_dict = saved_filters[self.request.session.session_key][ + "query_dict" + ] + + self._saved_filters = query_dict + queryset = self.filter_class(query_dict, queryset).qs + return queryset + + def get_context_data(self, **kwargs: Any): + context = super().get_context_data(**kwargs) + context["view_id"] = self.view_id + context["columns"] = self.visible_column + context["toggle_form"] = self.toggle_form + context["search_url"] = self.search_url + + context["action_method"] = self.action_method + context["actions"] = self.actions + + context["option_method"] = self.option_method + context["options"] = self.options + context["row_attrs"] = self.row_attrs + + context["show_filter_tags"] = self.show_filter_tags + context["bulk_select_option"] = self.bulk_select_option + context["row_status_class"] = self.row_status_class + context["sortby_key"] = self.sortby_key + context["sortby_mapping"] = self.sortby_mapping + context["row_status_indications"] = self.row_status_indications + context["saved_filters"] = self._saved_filters + + context["select_all_ids"] = self.select_all + if self._saved_filters.get("field"): + active_group = models.ActiveGroup.objects.filter( + created_by=self.request.user, + path=self.request.path, + group_by_field=self._saved_filters["field"], + ).first() + if active_group: + context["active_target"] = active_group.group_target + + queryset = self.get_queryset() + + if self.show_filter_tags: + data_dict = parse_qs(self._saved_filters.urlencode()) + data_dict = get_key_instances(self.model, data_dict) + keys_to_remove = [ + key + for key, value in data_dict.items() + if value[0] in ["unknown", "on"] + self.filter_keys_to_remove + ] + + for key in keys_to_remove: + data_dict.pop(key) + context["filter_dict"] = data_dict + + request = self.request + ordered_ids = list(queryset.values_list("id", flat=True)) + model = queryset.model + is_first_sort = False + query_dict = self.request.GET + if ( + not request.GET.get(self.sortby_key) + and not self._saved_filters.get(self.sortby_key) + ) or ( + not request.GET.get(self.sortby_key) + and self._saved_filters.get(self.sortby_key) + ): + is_first_sort = True + query_dict = self._saved_filters + + if query_dict.get(self.sortby_key): + queryset = sortby( + query_dict, queryset, self.sortby_key, is_first_sort=is_first_sort + ) + ordered_ids = [instance.id for instance in queryset] + setattr(model, "ordered_ids", ordered_ids) + + context["queryset"] = paginator_qry( + queryset, self._saved_filters.get("page"), self.records_per_page + ) + + if request and self._saved_filters.get("field"): + field = self._saved_filters.get("field") + self.template_name = "generic/group_by.html" + if isinstance(queryset, Page): + queryset = self.filter_class( + request.GET, queryset=queryset.object_list.model.objects.all() + ).qs + groups = group_by_queryset( + queryset, field, self._saved_filters.get("page"), "page" + ) + context["groups"] = paginator_qry( + groups, self._saved_filters.get("page"), 10 + ) + cache[self.request.session.session_key][HorillaListView] = context + from horilla.urls import urlpatterns, path + + self.export_path = f"export-list-view-{get_short_uuid(4)}/" + + urlpatterns.append(path(self.export_path, self.export_data)) + context["export_path"] = self.export_path + return context + + def select_all(self, *args, **kwargs): + """ + Select all method + """ + return json.dumps(list(self.get_queryset().values_list("id", flat=True))) + + def export_data(self, *args, **kwargs): + """ + Export list view visible columns + """ + from import_export import resources, fields + + request = getattr(_thread_locals, "request", None) + ids = eval(request.GET["ids"]) + queryset = self.model.objects.filter(id__in=ids) + + MODEL = self.model + FIELDS = self.visible_column + + class HorillaListViewResorce(resources.ModelResource): + id = fields.Field(column_name="ID") + + class Meta: + model = MODEL + fields = [] + + def dehydrate_id(self, instance): + return instance.pk + + def before_export(self, queryset, *args, **kwargs): + return super().before_export(queryset, *args, **kwargs) + + for field_tuple in FIELDS: + dynamic_fn_str = f"def dehydrate_{field_tuple[1]}(self, instance):return str(getattribute(instance, '{field_tuple[1]}'))" + exec(dynamic_fn_str) + dynamic_fn = locals()[f"dehydrate_{field_tuple[1]}"] + locals()[field_tuple[1]] = fields.Field(column_name=field_tuple[0]) + + book_resource = HorillaListViewResorce() + + # Export the data using the resource + dataset = book_resource.export(queryset) + + excel_data = dataset.export("xls") + + # Set the response headers + file_name = self.export_file_name + if not file_name: + file_name = "quick_export" + response = HttpResponse(excel_data, content_type="application/vnd.ms-excel") + response["Content-Disposition"] = f'attachment; filename="{file_name}.xls"' + return response + + +class HorillaSectionView(TemplateView): + """ + Horilla Template View + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + request = getattr(_thread_locals, "request", None) + self.request = request + update_initial_cache(request, cache, HorillaListView) + + nav_url: str = "" + view_url: str = "" + + # view container id is used to wrap the component view with th id + view_container_id: str = "" + + script_static_paths: list = [] + style_static_paths: list = [] + + template_name = "generic/horilla_section.html" + + def get_context_data(self, **kwargs) -> dict: + context = super().get_context_data(**kwargs) + context["nav_url"] = self.nav_url + context["view_url"] = self.view_url + context["view_container_id"] = self.view_container_id + context["script_static_paths"] = self.script_static_paths + context["style_static_paths"] = self.style_static_paths + return context + + +class HorillaDetailedView(DetailView): + """ + HorillDetailedView + """ + + title = "Detailed View" + template_name = "generic/horilla_detailed_view.html" + header: dict = {"title": "Horilla", "subtitle": "Horilla Detailed View"} + body: list = [] + + action_method: list = [] + actions: list = [] + + ids_key: str = "instance_ids" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + request = getattr(_thread_locals, "request", None) + self.request = request + update_initial_cache(request, cache, HorillaDetailedView) + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + def get_context_data(self, **kwargs: Any): + context = super().get_context_data(**kwargs) + + instance_ids = eval(str(self.request.GET.get(self.ids_key))) + + pk = context["object"].pk + + url = resolve(self.request.path) + key = list(url.kwargs.keys())[0] + + url_name = url.url_name + + previous_id, next_id = closest_numbers(instance_ids, pk) + + next_url = reverse(url_name, kwargs={key: next_id}) + previous_url = reverse(url_name, kwargs={key: previous_id}) + if instance_ids: + context["instance_ids"] = str(instance_ids) + context["ids_key"] = self.ids_key + + context["next_url"] = next_url + context["previous_url"] = previous_url + + context["title"] = self.title + context["header"] = self.header + context["body"] = self.body + context["actions"] = self.actions + context["action_method"] = self.action_method + + cache[self.request.session.session_key][HorillaDetailedView] = context + + return context + + +class HorillaTabView(TemplateView): + """ + HorillaTabView + """ + + template_name = "generic/horilla_tabs.html" + + tabs: list = [] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + request = getattr(_thread_locals, "request", None) + self.request = request + update_initial_cache(request, cache, HorillaTabView) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.user: + active_tab = models.ActiveTab.objects.filter( + created_by=self.request.user, path=self.request.path + ).first() + if active_tab: + context["active_target"] = active_tab.tab_target + context["tabs"] = self.tabs + + cache[self.request.session.session_key][HorillaTabView] = context + + return context + + +class HorillaCardView(ListView): + """ + HorillaCardView + """ + + filter_class: FilterSet = None + + view_id: str = get_short_uuid(4, prefix="hcv") + + template_name = "generic/horilla_card.html" + context_object_name = "queryset" + + search_url: str = "" + + details: dict = {} + + actions: list = [] + + card_attrs: str = """""" + + show_filter_tags: bool = True + filter_keys_to_remove: list = [] + + records_per_page: int = 50 + card_status_class: str = """""" + card_status_indications: list = [] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + request = getattr(_thread_locals, "request", None) + self.request = request + update_initial_cache(request, cache, HorillaCardView) + self._saved_filters = QueryDict() + + def get_queryset(self): + queryset = super().get_queryset() + if self.filter_class: + query_dict = self.request.GET + if "filter_applied" in query_dict.keys(): + update_saved_filter_cache(self.request, saved_filters) + elif saved_filters.get(self.request.session.session_key): + query_dict = saved_filters[self.request.session.session_key][ + "query_dict" + ] + + self._saved_filters = query_dict + queryset = self.filter_class(query_dict, queryset).qs + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + queryset = self.get_queryset() + + context["view_id"] = self.view_id + context["search_url"] = self.search_url + context["card_attrs"] = self.card_attrs + context["actions"] = self.actions + context["details"] = self.details + context["show_filter_tags"] = self.show_filter_tags + context["card_status_class"] = self.card_status_class + context["card_status_indications"] = self.card_status_indications + + context["queryset"] = paginator_qry( + queryset, self.request.GET.get("page"), self.records_per_page + ) + + if self.show_filter_tags: + data_dict = parse_qs(self._saved_filters.urlencode()) + data_dict = get_key_instances(self.model, data_dict) + keys_to_remove = [ + key + for key, value in data_dict.items() + if value[0] in ["unknown", "on"] + self.filter_keys_to_remove + ] + + for key in keys_to_remove: + data_dict.pop(key) + context["filter_dict"] = data_dict + + cache[self.request.session.session_key][HorillaCardView] = context + return context + + +class ReloadMessages(TemplateView): + template_name = "generic/messages.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + return context + + +dynamic_create_cache = {} + + +def save(self: forms.ModelForm, commit=True, *args, **kwargs): + """ + This method is used to super save the form using custom logic + """ + request = getattr(_thread_locals, "request", None) + + dynamic_field = request.GET["dynamic_field"] + response = None + if commit: + response = super(type(self), self).save(*args, **kwargs) + new_isntance_pk = self.instance.pk + dynamic_create_cache[request.session.session_key + dynamic_field] = { + "dynamic_field": dynamic_field, + "value": new_isntance_pk, + "form": self, + } + return response + + +from django.views.generic import UpdateView + + +class HorillaFormView(FormView): + """ + HorillaFormView + """ + + class HttpResponse: + def __new__( + self, content: str = "", targets_to_reload: list = [], script: str = "" + ) -> HttpResponse: + targets_to_reload = list(set(targets_to_reload)) + targets_to_reload.append("#reloadMessagesButton") + script_id = get_short_uuid(4) + script = ( + f"" + ) + + reload_response = HttpResponse(script).content + + user_response = HttpResponse(content).content + response = HttpResponse(reload_response + user_response) + self = response + return response + + model: object = None + view_id: str = get_short_uuid(4) + form_class: forms.ModelForm = None + template_name = "generic/horilla_form.html" + form_disaply_attr: str = "" + new_display_title: str = "Add New" + close_button_attrs: str = """""" + submit_button_attrs: str = """""" + + # NOTE: Dynamic create view's forms save method will be overwritten + is_dynamic_create_view: bool = False + dynamic_create_fields: list = [] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + request = getattr(_thread_locals, "request", None) + self.request = request + update_initial_cache(request, cache, HorillaFormView) + + if self.form_class: + setattr(self.form_class, "structured", structured) + + def get( + self, request: HttpRequest, pk=None, *args: str, **kwargs: Any + ) -> HttpResponse: + response = super().get(request, *args, **kwargs) + return response + + def post( + self, request: HttpRequest, pk=None, *args: str, **kwargs: Any + ) -> HttpResponse: + self.get_form() + response = super().post(request, *args, **kwargs) + return response + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["dynamic_create_fields"] = self.dynamic_create_fields + context["form_class_path"] = self.form_class_path + context["view_id"] = self.view_id + return context + + def get_form(self, form_class=None): + pk = self.kwargs.get("pk") + instance = self.model.objects.filter(pk=pk).first() + + data = None + files = None + if self.request.method == "POST": + data = self.request.POST + files = self.request.FILES + form = self.form_class(data, files, instance=instance) + + if self.is_dynamic_create_view: + setattr(type(form), "save", save) + + self.form_class_path = form.__class__.__module__ + "." + form.__class__.__name__ + if self.request.method == "GET": + for dynamic_tuple in self.dynamic_create_fields: + view = dynamic_tuple[1] + view.display_title = "Dynamic create" + + field = dynamic_tuple[0] + dynamic_create_cache[self.request.session.session_key + field] = { + "dynamic_field": field, + "value": getattr(form.instance, field, ""), + "form": form, + } + + from horilla.urls import urlpatterns, path + + urlpatterns.append( + path( + f"dynamic-path-{field}-{self.request.session.session_key}", + view.as_view(), + name=f"dynamic-path-{field}-{self.request.session.session_key}", + ) + ) + queryset = form.fields[field].queryset + choices = [(instance.id, instance) for instance in queryset] + choices.insert(0, ("", "Select option")) + choices.append(("dynamic_create", "Dynamic create")) + form.fields[field] = forms.ChoiceField( + choices=choices, + label=form.fields[field].label, + required=form.fields[field].required, + widget=forms.Select(attrs=form.fields[field].widget.attrs), + ) + if pk: + form.instance = instance + title = str(instance) + if self.form_disaply_attr: + title = getattribute(instance, self.form_disaply_attr) + if instance: + self.form_class.verbose_name = title + else: + self.form_class.verbose_name = self.new_display_title + form.close_button_attrs = self.close_button_attrs + form.submit_button_attrs = self.submit_button_attrs + cache[self.request.session.session_key][HorillaFormView] = form + self.form = form + return form + + +class HorillaNavView(TemplateView): + """ + HorillaNavView + + filter form submit button id: applyFilter + """ + + template_name = "generic/horilla_nav.html" + + nav_title: str = "" + search_url: str = "" + search_swap_target: str = "" + search_input_attrs: str = "" + search_in: list = [] + actions: list = [] + group_by_fields: list = [] + filter_form_context_name: str = "" + filter_instance: FilterSet = None + filter_instance_context_name: str = "" + filter_body_template: str = "" + view_types: list = [] + create_attrs: str = """""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + request = getattr(_thread_locals, "request", None) + self.request = request + update_initial_cache(request, cache, HorillaNavView) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["nav_title"] = self.nav_title + context["search_url"] = self.search_url + context["search_swap_target"] = self.search_swap_target + context["search_input_attrs"] = self.search_input_attrs + context["group_by_fields"] = self.group_by_fields + context["actions"] = self.actions + context["filter_body_template"] = self.filter_body_template + context["view_types"] = self.view_types + context["create_attrs"] = self.create_attrs + context["search_in"] = self.search_in + context["filter_instance_context_name"] = self.filter_instance + if self.filter_instance: + context[self.filter_form_context_name] = self.filter_instance.form + cache[self.request.session.session_key][HorillaNavView] = context + return context diff --git a/horilla_views/migrations/__init__.py b/horilla_views/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_views/models.py b/horilla_views/models.py new file mode 100644 index 000000000..3899ce772 --- /dev/null +++ b/horilla_views/models.py @@ -0,0 +1,95 @@ +import json +from django.db import models +from django.contrib.auth.models import User +from horilla.models import HorillaModel +from base.thread_local_middleware import _thread_locals + +# Create your models here. + + +class ToggleColumn(HorillaModel): + """ + ToggleColumn + """ + + user_id = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="user_excluded_column", + editable=False, + ) + path = models.CharField(max_length=256) + excluded_columns = models.JSONField(default=list) + + def save(self, *args, **kwargs): + request = getattr(_thread_locals, "request", {}) + user = request.user + self.user_id = user + return super().save(*args, **kwargs) + + def __str__(self) -> str: + return str(self.user_id.employee_get) + + +class ActiveTab(HorillaModel): + """ + ActiveTab + """ + + path = models.CharField(max_length=256) + tab_target = models.CharField(max_length=256) + + +class ActiveGroup(HorillaModel): + """ + ActiveGroup + """ + path = models.CharField(max_length=256) + group_target = models.CharField(max_length=256) + group_by_field = models.CharField(max_length=256) + + +class ParentModel(models.Model): + """ """ + + title = models.CharField(max_length=50) + + def __str__(self) -> str: + return self.title + + +class DemoCompany(models.Model): + title = models.CharField(max_length=20) + + def __str__(self) -> str: + return self.title + + +class DemoDepartment(models.Model): + """ + DemoDepartment + """ + + title = models.CharField(max_length=20) + company_id = models.ForeignKey( + DemoCompany, + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name="Company", + ) + + def __str__(self) -> str: + return self.title + + +class childModel(models.Model): + """ """ + + title_id = models.ForeignKey( + ParentModel, on_delete=models.CASCADE, verbose_name="Title" + ) + department_id = models.ForeignKey( + DemoDepartment, on_delete=models.CASCADE, null=True, verbose_name="Department" + ) + description = models.TextField() diff --git a/horilla_views/templates/cbv/filter_form.html b/horilla_views/templates/cbv/filter_form.html new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_views/templates/generic/as_list.html b/horilla_views/templates/generic/as_list.html new file mode 100644 index 000000000..205984c3b --- /dev/null +++ b/horilla_views/templates/generic/as_list.html @@ -0,0 +1,32 @@ +{% load i18n %} +
+ +
    +
    + + +
    + + {% for field in form.visible_fields %} +
  • + {{field.label}} + + + + +
  • + {% endfor %} +
+ +
\ No newline at end of file diff --git a/horilla_views/templates/generic/components.html b/horilla_views/templates/generic/components.html new file mode 100644 index 000000000..61f21c9cf --- /dev/null +++ b/horilla_views/templates/generic/components.html @@ -0,0 +1,54 @@ + + + diff --git a/horilla_views/templates/generic/custom_list.html b/horilla_views/templates/generic/custom_list.html new file mode 100644 index 000000000..237433c14 --- /dev/null +++ b/horilla_views/templates/generic/custom_list.html @@ -0,0 +1,9 @@ +
+

+ Header +

+ {% include "generic/horilla_list.html" %} +

+ Footer +

+
\ No newline at end of file diff --git a/horilla_views/templates/generic/filter_tags.html b/horilla_views/templates/generic/filter_tags.html new file mode 100644 index 000000000..fbc18ba85 --- /dev/null +++ b/horilla_views/templates/generic/filter_tags.html @@ -0,0 +1,80 @@ +{% load i18n %} {% load basefilters %} + + +
{{filter_dict}}
+ +
diff --git a/horilla_views/templates/generic/form.html b/horilla_views/templates/generic/form.html new file mode 100644 index 000000000..d0edf7056 --- /dev/null +++ b/horilla_views/templates/generic/form.html @@ -0,0 +1,61 @@ +{% load widget_tweaks %} {% load i18n %} + +{% if form.verbose_name %} +
+

+ {{form.verbose_name}} +

+ +
+{% endif %} +
+
+
+
+
{{ 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' }} {% endif %} {{ field.errors }} +
+
+ {% endfor %} +
+ + {% for field in form.hidden_fields %} {{ field }} {% endfor %} + +
+ +
+
+
+
\ No newline at end of file diff --git a/horilla_views/templates/generic/group_by.html b/horilla_views/templates/generic/group_by.html new file mode 100644 index 000000000..a93c6dc72 --- /dev/null +++ b/horilla_views/templates/generic/group_by.html @@ -0,0 +1,415 @@ +{% load static i18n generic_template_filters %} +
+ {% if bulk_select_option %} +
+
+
+ {% trans "Select All" %}{{select_all_path}} +
+
+ {% trans "Unselect All" %} +
+
+ + 0 + {% trans "Selected" %} +
+
+ {% trans "Export" %} +
+
+ {% if row_status_indications %} +
+ {% for indication in row_status_indications %} + + + {{indication.1}} + + {% endfor %} +
+ {% endif %} +
+ {% endif %} + + {% if show_filter_tags %} {% include "generic/filter_tags.html" %} {% endif %} +
+ {% for group in groups %} +
+
+
+ + +
+ + {{group.list.paginator.count}} + + {{group.grouper|capfirst}} +
+
+
+
+
+
+
+
+
+ {% if bulk_select_option %} +
+
+ +
+
+ {% endif %} + {% for cell in columns %} +
{{cell.0}}
+ {% endfor %}{% if options or option_method%} +
+
+ {% trans "Options" %} +
+
+ {% endif %} {% if actions or action_method %} +
+
+ {% trans "Actions" %} +
+
+ {% endif %} +
+
+
+ {% for instance in group.list %} +
+ {% if bulk_select_option %} +
+
+ +
+
+ {% endif %} + {% for cell in columns %} + {% with attribute=cell.1 index=forloop.counter %} {% if not cell.2 %} +
+ {{instance|getattribute:attribute|safe}} +
+ {% else %} +
+
+
+ +
+ + {{instance|getattribute:attribute}} + +
+
+ {% endif %} {% endwith %} {% endfor %} {% if options or option_method %} +
+ {% if not option_method %} +
+ {% for option in options %} + + + + {% endfor %} +
+ {% else %} {{instance|getattribute:option_method|safe}} {% endif %} +
+ {% endif %} {% if actions or action_method %} +
+ {% if not action_method %} +
+ {% for action in actions %} + + + + {% endfor %} +
+ {% else %} {{instance|getattribute:action_method|safe}} {% endif %} +
+ {% endif %} +
+ {% endfor %} +
+
+
+
+ + {% trans "Page" %} {{ group.list.number }} + {% trans "of" %} {{ group.list.paginator.num_pages }}. + + +
+
+
+
+ {% endfor %} +
+ {% if groups.paginator.count %} +
+ {% trans "Page" %} {{groups.number}} {% trans "of" %} {{groups.paginator.num_pages}} + + +
+ + {% if bulk_select_option %} + + {% endif %} + {% endif %} + + +
\ No newline at end of file diff --git a/horilla_views/templates/generic/hello.html b/horilla_views/templates/generic/hello.html new file mode 100644 index 000000000..d18125b98 --- /dev/null +++ b/horilla_views/templates/generic/hello.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/horilla_views/templates/generic/horilla_card.html b/horilla_views/templates/generic/horilla_card.html new file mode 100644 index 000000000..b1e62d37d --- /dev/null +++ b/horilla_views/templates/generic/horilla_card.html @@ -0,0 +1,156 @@ +{% load static i18n generic_template_filters %} +
+ {% if queryset|length %} + {% if card_status_indications %} +
+ {% for indication in card_status_indications %} + + + {{indication.1}} + + {% endfor %} +
+ {% endif %} + + {% if show_filter_tags %} + {% include "generic/filter_tags.html" %} + {% endif %} +
+ {% for instance in queryset %} +
+
+
+ Username +
+
+
+ {{details.title|format:instance|safe}} + {{details.subtitle|format:instance|safe}} +
+ {% if actions %} +
+
+ +
+ +
+
+
+ {% endif %} +
+ {% endfor %} +
+ {% if queryset.paginator.count %} +
+ {% trans "Page" %} {{queryset.number}} {% trans "of" %} + {{queryset.paginator.num_pages}} + + +
+ + {% endif %} + {% else %} +
+
+ Page not found. 404. +

{% trans "No Records found" %}

+

+ {% trans "No records found." %} +

+
+
+ {% endif %} +
diff --git a/horilla_views/templates/generic/horilla_detailed_view.html b/horilla_views/templates/generic/horilla_detailed_view.html new file mode 100644 index 000000000..c56d8b8fb --- /dev/null +++ b/horilla_views/templates/generic/horilla_detailed_view.html @@ -0,0 +1,97 @@ +{% load generic_template_filters %} +
+ + {{title}} + + +
+ +
+ {% if instance_ids %} +
+ + + +
+ {% endif %} +
+
+
+ +
+
+ +
+
+ + {{object|getattribute:header.title}} + + + {{object|getattribute:header.subtitle}} +
+
+
+ + +
+
+
+
+ diff --git a/horilla_views/templates/generic/horilla_form.html b/horilla_views/templates/generic/horilla_form.html new file mode 100644 index 000000000..c8010794a --- /dev/null +++ b/horilla_views/templates/generic/horilla_form.html @@ -0,0 +1,37 @@ +{% for field_tuple in dynamic_create_fields %} + +{% endfor %} +
{{form.structured}}
+{% for field_tuple in dynamic_create_fields %} + + + +{% endfor %} + diff --git a/horilla_views/templates/generic/horilla_list.html b/horilla_views/templates/generic/horilla_list.html new file mode 100644 index 000000000..199fcf49a --- /dev/null +++ b/horilla_views/templates/generic/horilla_list.html @@ -0,0 +1,379 @@ +{% load static i18n generic_template_filters %} +
+ + {% if show_filter_tags %} {% include "generic/filter_tags.html" %} {% endif %} + {% if queryset|length %} + {% if bulk_select_option %} +
+
+
+ {% trans "Select All" %}{{select_all_path}} +
+
+ {% trans "Unselect All" %} +
+
+ + 0 + {% trans "Selected" %} +
+
+ {% trans "Export" %} +
+
+ {% if row_status_indications %} +
+ {% for indication in row_status_indications %} + + + {{indication.1}} + + {% endfor %} +
+ {% endif %} +
+ {% endif %} + + +
+
+
+ +
+ {{toggle_form.as_list}} +
+
+
+
+
+
+
+ {% if bulk_select_option %} +
+
+ +
+
+ {% endif %} {% for cell in columns %} +
+
+ {{cell.0}} +
+
+ {% endfor %} {% if options or option_method%} +
+
+ {% trans "Options" %} +
+
+ {% endif %} {% if actions or action_method %} +
+
+ {% trans "Actions" %} +
+
+ {% endif %} +
+
+
+ {% for instance in queryset %} +
+ {% if bulk_select_option %} +
+
+ +
+
+ {% endif %} {% for cell in columns %} + {% with attribute=cell.1 index=forloop.counter %} {% if not cell.2 %} +
+ {{instance|getattribute:attribute|safe}} +
+ {% else %} +
+
+
+ +
+ + {{instance|getattribute:attribute}} + +
+
+ {% endif %} {% endwith %} {% endfor %} {% if options or option_method %} +
+ {% if not option_method %} +
+ {% for option in options %} + + + + {% endfor %} +
+ {% else %} {{instance|getattribute:option_method|safe}} {% endif %} +
+ {% endif %} {% if actions or action_method %} +
+ {% if not action_method %} +
+ {% for action in actions %} + + + + {% endfor %} +
+ {% else %} {{instance|getattribute:action_method|safe}} {% endif %} +
+ {% endif %} +
+ {% endfor %} +
+
+
+
+ {% if queryset.paginator.count %} +
+ {% trans "Page" %} {{queryset.number}} {% trans "of" %} + {{queryset.paginator.num_pages}} + + +
+ + {% if bulk_select_option %} + + {% endif %} + {% endif %} +
+ + {% else %} +
+
+ Page not found. 404. +

{% trans "No Records found" %}

+

+ {% trans "No records found." %} +

+
+
+ {% endif %} + diff --git a/horilla_views/templates/generic/horilla_nav.html b/horilla_views/templates/generic/horilla_nav.html new file mode 100644 index 000000000..e0ed9fbcc --- /dev/null +++ b/horilla_views/templates/generic/horilla_nav.html @@ -0,0 +1,223 @@ +{% load i18n %} +
+
+

{{nav_title}}

+ + + +
+
+
+ + + {% if search_in %} + + + {% endif %} +
+ +
+ {% if view_types %} +
    + {% for type in view_types %} +
  • + +
  • + {% endfor %} +
+ {% endif %} + + {% if filter_body_template %} +
+ +
+ {% include filter_body_template %} + + +
+
+ {% else %} + + {% endif %} + {% if group_by_fields %} +
+ + +
+ {% endif %} + {% if actions %} +
+
+ +
+ +
+
+
+ {% endif %} + + + + {% if create_attrs %} + + {% trans "Create" %} + + {% endif %} + +
+ +
+
+ \ No newline at end of file diff --git a/horilla_views/templates/generic/horilla_section.html b/horilla_views/templates/generic/horilla_section.html new file mode 100644 index 000000000..bb5ece020 --- /dev/null +++ b/horilla_views/templates/generic/horilla_section.html @@ -0,0 +1,49 @@ +{% extends "index.html" %} +{% block content %} +{% load static %} + +{% for path in style_path %} + +{% endfor %} +{% for path in script_static_paths %} + +{% endfor %} + + +{% include "generic/components.html" %} + + + +
+
+
+
+ +
+
+
+
+ + +{% endblock content %} diff --git a/horilla_views/templates/generic/horilla_tabs.html b/horilla_views/templates/generic/horilla_tabs.html new file mode 100644 index 000000000..cb059c49a --- /dev/null +++ b/horilla_views/templates/generic/horilla_tabs.html @@ -0,0 +1,100 @@ +{% comment %} {% extends "index.html" %} +{% block content %} +{% 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 %} +
    + + +
    + {% endif %} +
    +
  • + {% endfor %} +
+
+ {% for tab in tabs %} +
+
+
+ {% endfor %} +
+
+ + \ No newline at end of file diff --git a/horilla_views/templates/generic/index.html b/horilla_views/templates/generic/index.html new file mode 100644 index 000000000..00d586b6b --- /dev/null +++ b/horilla_views/templates/generic/index.html @@ -0,0 +1,38 @@ +{% extends "index.html" %} +{% block content %} + +
+
+{% include "generic/components.html" %} + + {% comment %} path to the horilla tab view {% endcomment %} +
+
+
+{% endblock content %} \ No newline at end of file diff --git a/horilla_views/templates/generic/messages.html b/horilla_views/templates/generic/messages.html new file mode 100644 index 000000000..62f6586cb --- /dev/null +++ b/horilla_views/templates/generic/messages.html @@ -0,0 +1,7 @@ +{% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+{% endif %} diff --git a/horilla_views/templates/generic/reload_select_field.html b/horilla_views/templates/generic/reload_select_field.html new file mode 100644 index 000000000..4c2093e37 --- /dev/null +++ b/horilla_views/templates/generic/reload_select_field.html @@ -0,0 +1,13 @@ +
+ {{field}} + + + + \ No newline at end of file diff --git a/horilla_views/templatetags/__init__.py b/horilla_views/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_views/templatetags/generic_template_filters.py b/horilla_views/templatetags/generic_template_filters.py new file mode 100644 index 000000000..bceb34a3b --- /dev/null +++ b/horilla_views/templatetags/generic_template_filters.py @@ -0,0 +1,61 @@ +""" +attendancefilters.py + +This module is used to write custom template filters. + +""" + +import re, types +from django import template +from django.template.defaultfilters import register +from django.conf import settings + + +register = template.Library() + + +numeric_test = re.compile("^\d+$") + + +@register.filter(name="getattribute") +def getattribute(value, attr: str): + """ + Gets an attribute of an object dynamically from a string name + """ + result = "" + attrs = attr.split("__") + for attr in attrs: + if hasattr(value, str(attr)): + result = getattr(value, attr) + if isinstance(result, types.MethodType): + result = result() + value = result + + return result + + +@register.filter(name="format") +def format(string: str, instance: object): + """ + format + """ + attr_placeholder_regex = r"{([^}]*)}" + attr_placeholders = re.findall(attr_placeholder_regex, string) + + if not attr_placeholders: + return string + flag = instance + format_context = {} + for attr_placeholder in attr_placeholders: + attr_name: str = attr_placeholder + attrs = attr_name.split("__") + for attr in attrs: + value = getattr(instance, attr, "") + if isinstance(value, types.MethodType): + value = value() + instance = value + format_context[attr_name] = value + instance = flag + formatted_string = string.format(**format_context) + + return formatted_string diff --git a/horilla_views/templatetags/migrations/__init__.py b/horilla_views/templatetags/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_views/tests.py b/horilla_views/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/horilla_views/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/horilla_views/urls.py b/horilla_views/urls.py new file mode 100644 index 000000000..927b0835c --- /dev/null +++ b/horilla_views/urls.py @@ -0,0 +1,16 @@ +""" +horilla_views/urls.py +""" + +from django.urls import path +from horilla_views import views +from horilla_views.generic.cbv.views import ReloadMessages + + +urlpatterns = [ + path("toggle-columns", views.ToggleColumn.as_view(), name="toggle-columns"), + path("active-tab", views.ActiveTab.as_view(), name="active-tab"), + path("active-group", views.ActiveGroup.as_view(), name="cbv-active-group"), + path("reload-field", views.ReloadField.as_view(), name="reload-field"), + path("reload-messages", ReloadMessages.as_view(), name="reload-messages"), +] diff --git a/horilla_views/views.py b/horilla_views/views.py new file mode 100644 index 000000000..09989021b --- /dev/null +++ b/horilla_views/views.py @@ -0,0 +1,133 @@ +import importlib +from django import forms +from django.http import HttpResponse, JsonResponse +from django.shortcuts import render +from django.views import View +from horilla_views import models +from horilla_views.cbv_methods import get_short_uuid +from horilla_views.generic.cbv.views import dynamic_create_cache + +# Create your views here. + + +class ToggleColumn(View): + """ + ToggleColumn + """ + def get(self, *args, **kwargs): + """ + method to toggle columns + """ + + query_dict = self.request.GET + path = query_dict["path"] + query_dict = dict(query_dict) + del query_dict["path"] + + hidden_fields = [key for key, value in query_dict.items() if value[0]] + + existing_instance = models.ToggleColumn.objects.filter( + user_id=self.request.user, path=path + ).first() + + instance = models.ToggleColumn() if not existing_instance else existing_instance + instance.path = path + instance.excluded_columns = hidden_fields + + instance.save() + + return HttpResponse("success") + + +class ReloadField(View): + """ + ReloadField + """ + def get(self, request, *args, **kwargs): + """ + Http method to reload dynamic create fields + """ + class_path = request.GET["form_class_path"] + reload_field = request.GET["dynamic_field"] + + module_name, class_name = class_path.rsplit(".", 1) + module = importlib.import_module(module_name) + parent_form = getattr(module, class_name)() + + dynamic_cache = dynamic_create_cache.get( + request.session.session_key + reload_field + ) + form: forms.ModelForm = dynamic_cache["form"] + + cache_field = dynamic_cache["dynamic_field"] + if cache_field != reload_field: + cache_field = reload_field + field = parent_form.fields[cache_field] + + queryset = form._meta.model.objects.all() + queryset = field.queryset + choices = [(instance.id, instance) for instance in queryset] + choices.insert(0, ("", "Select option")) + choices.append(("dynamic_create", "Dynamic create")) + + parent_form.fields[cache_field] = forms.ChoiceField( + choices=choices, + label=field.label, + required=field.required, + widget=forms.Select(attrs=field.widget.attrs), + ) + parent_form.fields[cache_field].initial = dynamic_cache["value"] + + field = parent_form[cache_field] + dynamic_id: str = get_short_uuid(4) + return render( + request, + "generic/reload_select_field.html", + {"field": field, "dynamic_id": dynamic_id}, + ) + + +class ActiveTab(View): + def get(self, *args, **kwargs): + """ + CBV method to handle active tab + """ + path = self.request.GET.get("path") + target = self.request.GET.get("target") + if path and target and self.request.user: + existing_instance = models.ActiveTab.objects.filter( + created_by=self.request.user, path=path + ).first() + + instance = ( + models.ActiveTab() if not existing_instance else existing_instance + ) + instance.path = path + instance.tab_target = target + instance.save() + return JsonResponse({"message": "Success"}) + + +class ActiveGroup(View): + def get(self, *args, **kwargs): + """ + ActiveGroup + """ + path = self.request.GET.get("path") + target = self.request.GET.get("target") + group_field = self.request.GET.get("field") + if path and target and group_field and self.request.user: + existing_instance = models.ActiveGroup.objects.filter( + created_by=self.request.user, + path=path, + group_by_field=group_field, + ).first() + + instance = ( + models.ActiveGroup() if not existing_instance else existing_instance + ) + instance.path = path + instance.group_by_field = group_field + instance.group_target = target + instance.save() + return JsonResponse({"message": "Success"}) diff --git a/recruitment/models.py b/recruitment/models.py index d4a7f7599..d1e282eaa 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -437,6 +437,10 @@ class Candidate(HorillaModel): """ return self.email + def get_mail(self): + """ """ + return self.get_email() + def tracking(self): """ This method is used to return the tracked history of the instance @@ -523,6 +527,9 @@ class Candidate(HorillaModel): ordering = ["sequence"] +from horilla.signals import pre_bulk_update + + class RejectReason(HorillaModel): """ RejectReason diff --git a/static/build/js/clearFilter.js b/static/build/js/clearFilter.js new file mode 100644 index 000000000..91465a033 --- /dev/null +++ b/static/build/js/clearFilter.js @@ -0,0 +1,39 @@ +var formButton = "#applyFilter"; +function clearFilterFromTag(element) { + let field_id = element.attr("data-x-field"); + $(`[name=${field_id}]`).val(""); + $(`[name=${field_id}]`).change(); + // Update all elements with the same ID to have null values + let elementId = $(`[name=${field_id}]:last`).attr("id"); + let spanElement = $( + `.oh-dropdown__filter-body:first #select2-id_${field_id}-container, #select2-${elementId}-container` + ); + if (spanElement.length) { + spanElement.attr("title", "---------"); + spanElement.text("---------"); + } + $(formButton).click(); + console.log($(formButton)); +} +function clearAllFilter(element) { + $('[role="tooltip"]').remove(); + let field_ids = $("[data-x-field]"); + for (var i = 0; i < field_ids.length; i++) { + let item_id = field_ids[i].getAttribute("data-x-field"); + + $(`[name=${item_id}]`).val(""); + $(`[name=${item_id}]`).change(); + let elementId = $(`[name=${item_id}]:last`).attr("id"); + let spanElement = $( + `.oh-dropdown__filter-body:first #select2-id_${item_id}-container, #select2-${elementId}-container` + ); + if (spanElement.length) { + spanElement.attr("title", "---------"); + spanElement.text("---------"); + } + $(formButton).click(); + localStorage.removeItem("savedFilters"); + var url = window.location.href.split("?")[0]; + window.history.replaceState({}, document.title, url); + } +} diff --git a/templates/index.html b/templates/index.html index cb98b105f..b8e2f1905 100755 --- a/templates/index.html +++ b/templates/index.html @@ -36,6 +36,7 @@ + @@ -54,6 +55,9 @@ {% comment %}
{% endcomment %} +
+ {% include "generic/messages.html" %} +
{% if messages %}
{% for message in messages %} @@ -63,7 +67,7 @@ {% endfor %}
{% endif %} - +
`); } @@ -531,6 +535,76 @@ setTimeout(()=>{$("[name='search']").focus()},100) + diff --git a/templates/sidebar.html b/templates/sidebar.html index af1240884..dec974d00 100755 --- a/templates/sidebar.html +++ b/templates/sidebar.html @@ -248,6 +248,16 @@ function initializeSummernote(candId,searchWords,) { > {% endif %} + {% if perms.recruitment.view_mailautomation %} +
  • + {% trans "Mail Automations" %} +
  • + {% endif %} {% if perms.leave.add_holiday %}
  • 0) { + $("#filterCount").text(`(${count})`); + } + }