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}
+
+
+
+ ==
+ !=
+
+
+
+
+
+ And
+ Or
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ 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 = `
+
+
+ `;
+ 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 %}
+ {{to}}
+ {% 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 %}
+
\ 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 %}
+
+{% endif %}
+
+
+
+
+
{{ form.non_field_errors }}
+ {% for field in form.visible_fields %}
+
+
+ {% trans field.label %}
+ {% 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 %}
+
+
+
+ {% trans 'Save' %}
+
+
+
+
+
\ 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 %}
+
+ {% endfor %}
+
+ {% if groups.paginator.count %}
+
+
+ {% 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 @@
+
+ Hello
+
\ 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 %}
+
+ {% endfor %}
+
+ {% if queryset.paginator.count %}
+
+
+ {% endif %}
+ {% else %}
+
+
+
+
{% 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 %}
+
+
+
+ {% if instance_ids %}
+
+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
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 %}
+
+{% for field_tuple in dynamic_create_fields %}
+
+{{field_tuple.0}}
+
+ Reload Field
+
+
+{% 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 %}
+
+
+
+
+
+
+
+
+ {% if bulk_select_option %}
+
+ {% endif %} {% for cell in columns %}
+
+ {% 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 %}
+
+
+ {% if bulk_select_option %}
+
+ {% endif %}
+ {% endif %}
+
+
+ {% else %}
+
+
+
+
{% 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 %}
+
+
+
\ 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)
+