[UPDT] HORILLA_VIEWS: Updated horilla views generic classes

This commit is contained in:
Horilla
2025-07-31 15:19:09 +05:30
parent 4cb5801674
commit 500656a3e0
16 changed files with 2056 additions and 157 deletions

View File

@@ -7,5 +7,14 @@ from django.dispatch import Signal
pre_bulk_update = Signal()
post_bulk_update = Signal()
pre_model_clean = Signal()
post_model_clean = Signal()
pre_scheduler = Signal()
post_scheduler = Signal()
pre_generic_delete = Signal()
post_generic_delete = Signal()
pre_generic_import = Signal()
post_generic_import = Signal()

View File

@@ -302,7 +302,7 @@ def paginator_qry(qryset, page_number, records_per_page=50):
"""
This method is used to paginate queryset
"""
if not isinstance(qryset, Page) and not qryset.ordered:
if hasattr(qryset, "ordered") and not qryset.ordered:
qryset = (
qryset.order_by("-created_at")
if hasattr(qryset.model, "created_at")
@@ -378,7 +378,10 @@ def sortby(
none_queryset = []
model = queryset.model
model_attr = getmodelattribute(model, sort_key)
is_method = isinstance(model_attr, types.FunctionType)
is_method = (
isinstance(model_attr, types.FunctionType)
or model_attr not in model._meta.get_fields()
)
if not is_method:
none_queryset = queryset.filter(**{f"{sort_key}__isnull": True})
none_ids = list(none_queryset.values_list("id", flat=True))
@@ -480,6 +483,20 @@ def structured(self):
return table_html
def get_original_model_field(historical_model):
"""
Given a historical model and a field name,
return the actual model field from the original model.
"""
model_name = historical_model.__name__.replace("Historical", "")
app_label = historical_model._meta.app_label
try:
original_model = apps.get_model(app_label, model_name)
return original_model
except Exception as e:
return historical_model
def value_to_field(field: object, value: list) -> Any:
"""
return value according to the format of the field
@@ -666,3 +683,244 @@ def export_xlsx(json_data, columns, file_name="quick_export"):
)
response["Content-Disposition"] = f'attachment; filename="{file_name}.xlsx"'
return response
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist
from django.db.models import Model
from django.db.models.fields.related import (
ForeignKey,
ManyToManyRel,
ManyToOneRel,
OneToOneField,
OneToOneRel,
)
from openpyxl import Workbook
def get_verbose_name_from_field_path(model, field_path, import_mapping):
"""
Get verbose name
"""
parts = field_path.split("__")
current_model = model
verbose_name = None
for i, part in enumerate(parts):
try:
field = current_model._meta.get_field(part)
# Skip reverse relations (e.g., OneToOneRel)
if isinstance(field, (OneToOneRel, ManyToOneRel, ManyToManyRel)):
related_model = field.related_model
field = getattr(related_model, parts[-1]).field
return field.verbose_name.title()
verbose_name = field.verbose_name
if isinstance(field, (ForeignKey, OneToOneField)):
current_model = field.related_model
except FieldDoesNotExist:
return f"[Invalid: {field_path}]"
return verbose_name.title() if verbose_name else field_path
def generate_import_excel(
base_model, import_fields, reference_field="id", import_mapping={}, queryset=[]
):
"""
Generate import excel
"""
wb = Workbook()
ws = wb.active
ws.title = "Import Sheet"
# Style definitions
header_fill = PatternFill(
start_color="FFD700", end_color="FFD700", fill_type="solid"
)
bold_font = Font(bold=True)
wrap_alignment = Alignment(wrap_text=True, vertical="center", horizontal="center")
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
# Generate headers
headers = [
get_verbose_name_from_field_path(base_model, field, import_mapping)
for field in import_fields
]
headers = [
f"{get_verbose_name_from_field_path(base_model, reference_field,import_mapping)} | Reference"
] + headers
ws.append(headers)
# Apply styles to header row
for col_num, _ in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num)
cell.font = bold_font
cell.fill = header_fill
cell.alignment = wrap_alignment
cell.border = thin_border
col_letter = get_column_letter(col_num)
ws.column_dimensions[col_letter].width = 30
for obj in queryset:
row = [str(getattribute(obj, reference_field))] + [
str(getattribute(obj, import_mapping.get(field, field)))
for field in import_fields
]
ws.append(row)
ws.freeze_panes = "A2"
ws.freeze_panes = "B2"
return wb
def split_by_import_reference(employee_data):
with_import_reference = []
without_import_reference = []
for record in employee_data:
if record.get("id_import_reference") is not None:
with_import_reference.append(record)
else:
without_import_reference.append(record)
return with_import_reference, without_import_reference
def resolve_foreign_keys(
base_model,
record,
import_column_mapping,
model_lookup,
primary_key_mapping,
pk_values_mapping,
prefix="",
):
resolved = {}
for key, value in record.items():
full_key = f"{prefix}__{key}" if prefix else key
if isinstance(value, dict):
try:
field = base_model._meta.get_field(key)
related_model = field.related_model
except Exception:
resolved[key] = value
continue
# Recursively resolve nested foreign keys
nested_data = resolve_foreign_keys(
related_model,
value,
import_column_mapping,
model_lookup,
primary_key_mapping,
pk_values_mapping,
prefix=full_key,
)
instance = related_model.objects.create(**nested_data)
resolved[key] = instance
else:
model_class = model_lookup.get(full_key)
lookup_field = primary_key_mapping.get(full_key)
if model_class and lookup_field:
if value in [None, ""]:
resolved[key] = None
continue
try:
instance, _ = model_class.objects.get_or_create(
**{lookup_field: value}
)
resolved[key] = instance
except Exception as e:
raise ValueError(
f"Failed to get_or_create '{model_class.__name__}' using {lookup_field}={value}: {e}"
)
else:
resolved[key] = value
return resolved
def update_related(
obj,
record,
primary_key_mapping,
reverse_model_relation_to_base_model,
):
related_objects = {
key: getattribute(obj, key) or None
for key in reverse_model_relation_to_base_model
}
for relation in reverse_model_relation_to_base_model:
related_record_info = record.get(relation)
for key, value in related_record_info.items():
related_object = related_objects[relation]
obj_related_field = relation + "__" + key
pk_mapping = primary_key_mapping.get(obj_related_field)
if obj_related_field in primary_key_mapping and pk_mapping:
previous_obj = getattr(related_object, key, None)
if previous_obj and value is not None:
new_obj = previous_obj._meta.model.objects.get(
**{pk_mapping: value}
)
setattr(related_object, key, new_obj)
else:
if value is not None:
setattr(related_object, key, value)
if related_object:
related_object.save()
def assign_related(
record,
reverse_field,
pk_values_mapping,
pk_field_mapping,
):
"""
Method to assign related records
"""
reverse_obj_dict = {}
if reverse_field in record:
if isinstance(record[reverse_field], dict):
for field, value in record[reverse_field].items():
full_field = reverse_field + "__" + field
if full_field in pk_values_mapping:
reverse_obj_dict.update(
{
field: data
for data in pk_values_mapping[full_field]
if getattr(data, pk_field_mapping[full_field], None)
== value
}
)
else:
reverse_obj_dict[field] = value
else:
instances = [
data
for data in pk_values_mapping[reverse_field]
if getattr(
data,
pk_field_mapping[reverse_field],
record[reverse_field],
)
== record[reverse_field]
]
if instances:
instance = instances[0]
reverse_obj_dict.update({reverse_field: instance})
return reverse_obj_dict

View File

@@ -19,6 +19,7 @@ from horilla_views.cbv_methods import (
FIELD_WIDGET_MAP,
MODEL_FORM_FIELD_MAP,
get_field_class_map,
get_original_model_field,
structured,
)
from horilla_views.templatetags.generic_template_filters import getattribute
@@ -202,6 +203,8 @@ class DynamicBulkUpdateForm(forms.Form):
fiels_mapping = {}
parent_model = self.root_model
for key, val in mappings.items():
if val.model.__name__.startswith("Historical"):
val.model = get_original_model_field(val.model)
field = MODEL_FORM_FIELD_MAP.get(type(val))
if field:
if not fiels_mapping.get(val.model):
@@ -216,6 +219,8 @@ class DynamicBulkUpdateForm(forms.Form):
else:
related_key = key.split("__")[-2]
field = getattribute(parent_model, related_key)
if not hasattr(field, "related"):
continue
relation_mapp[val.model] = (
field.related.field.name
+ "__"
@@ -246,6 +251,8 @@ class DynamicBulkUpdateForm(forms.Form):
data_mapp[val.model][key] = value[0]
for model, data in data_mapp.items():
if not model in relation_mapp:
continue
queryset = model.objects.filter(**{relation_mapp[model]: self.ids})
# here fields, files, and related fields-
# get updated but need to save the files manually

View File

@@ -0,0 +1,3 @@
"""
horilla_views/generic/cbv/kanban.py
"""

View File

@@ -32,6 +32,8 @@ class Pipeline(ListView):
super().__init__(**kwargs)
self.request = getattr(_thread_locals, "request", None)
self.grouper = self.request.GET.get("grouper", self.grouper)
if kwargs.get("view") == "kanban":
self.template_name = "generic/pipeline/kanban.html"
for allowed_field in self.allowed_fields:
if self.grouper == allowed_field["field"]:
self.field_model = allowed_field["model"]
@@ -40,7 +42,23 @@ class Pipeline(ListView):
self.parameters = allowed_field["parameters"]
self.actions = allowed_field["actions"]
@classmethod
def as_view(cls, **initkwargs):
def view(request, *args, **kwargs):
# Inject URL params into initkwargs
initkwargs_with_url = {**initkwargs, **kwargs}
self = cls(**initkwargs_with_url)
self.request = request
self.args = args
self.kwargs = kwargs
return self.dispatch(request, *args, **kwargs)
return view
def get_queryset(self):
if self.kwargs.get("view"):
del self.kwargs["view"]
if not self.queryset:
self.queryset = self.field_filter_class(self.request.GET).qs.filter(
**self.kwargs

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,10 @@ class SavedFilter(HorillaModel):
path = models.CharField(max_length=256)
referrer = models.CharField(max_length=256, default="")
xss_exempt_fields = [
"urlencode",
]
def save(self, *args, **kwargs):
SavedFilter.objects.filter(
is_default=True, path=self.path, created_by=self.created_by

View File

@@ -0,0 +1,69 @@
{% load i18n %}
<style>
table.import {
border-collapse: collapse;
width: 100%;
}
table.import th,
table.import td {
border: 1px solid #cdd5df;
padding: 8px;
text-align: left;
}
table.import th {
background-color: #f2f2f2;
}
</style>
<table class="table import">
<tr>
<th>Status</th>
<th>Imported</th>
<th>Updated</th>
</tr>
<tr>
<td>{{ status }}</td>
<td>{{ imported }}</td>
<td>{{ updated }}</td>
</tr>
</table>
{% if traceback_message %}
<h6 class="mt-2">{{error_message}}</h6>
<button id="toggleErrorBtn" class="toggle-button" onclick="
$(this).next().toggleClass('d-none');
">Show Error Details</button>
<div id="errorDetails" class="error-details d-none">
<pre style="white-space: pre-wrap;">{{ traceback_message }}</pre>
</div>
<style>
.toggle-button {
background-color: #eee;
border: none;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
margin-top: 5px;
}
.toggle-button:hover {
background-color: #ddd;
}
.error-details {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
padding: 10px;
margin-top: 10px;
border-radius: 4px;
font-family: monospace;
}
</style>
{% endif %}
<div class="modal-footer d-flex flex-row-reverse">
<input type="button" onclick="$('#{{ view_id }} .reload-record').click()" class="oh-btn oh-btn--small oh-btn--success w-100 mr-1 mt-2" value="Refresh" />
</div>

View File

@@ -76,3 +76,5 @@
}
}
</script>
{% block generic_components %}
{% endblock generic_components %}

View File

@@ -1,76 +1,80 @@
{% load generic_template_filters %}
<div id="{{view_id}}">
{% for field_tuple in dynamic_create_fields %}
<div class="oh-modal" id="dynamicModal{{field_tuple.0}}" role="dialog"
aria-labelledby="dynamicModal{{field_tuple.0}}" aria-hidden="true">
<div class="oh-modal__dialog" id="dynamicModal{{field_tuple.0}}Body"></div>
</div>
{% for field_tuple in dynamic_create_fields %}
<div
class="oh-modal"
id="dynamicModal{{field_tuple.0}}"
role="dialog"
aria-labelledby="dynamicModal{{field_tuple.0}}"
aria-hidden="true"
>
<div
class="oh-modal__dialog"
id="dynamicModal{{field_tuple.0}}Body"
></div>
</div>
</div>
{% endfor %}
<form id="{{view_id}}Form" hx-post="{{request.path}}?{{request.GET.urlencode}}" hx-encoding="multipart/form-data" hx-swap="outerHTML" {% if hx_confirm %} hx-confirm="{{hx_confirm}}" {% endif %}>{{form.structured}}</form>
{% for field_tuple in dynamic_create_fields %}
<div >
<script class="dynamic_{{field_tuple.0}}_scripts">
{{form.initial|get_item:field_tuple.0}}
$("#{{view_id}}Form [name={{field_tuple.0}}]").val({{form.initial|get_item:field_tuple.0|safe}}).change()
</script>
<form
hidden
id="modalButton{{field_tuple.0}}Form"
hx-get="/dynamic-path-{{field_tuple.0}}-{{request.session.session_key}}?dynamic_field={{field_tuple.0}}"
hx-target="#dynamicModal{{field_tuple.0}}Body"
>
<input type="text" name="dynamic_initial" data-dynamic-field="{{field_tuple.0}}">
<input type="text" name="view_id" value="{{view_id}}">
{% for field in field_tuple.2 %}
<input type="text" name="{{field}}">
{% endfor %}
</div>
<form id="{{view_id}}Form" hx-post="{{request.path}}?{{request.GET.urlencode}}" hx-target="#{{ hx_target }}"
hx-encoding="multipart/form-data" {% if hx_target == "this" %} hx-swap="outerHTML" {% endif %}
{% if hx_confirm %} hx-confirm="{{hx_confirm}}" {% endif %}>
{{form.structured}}
</form>
{% for field_tuple in dynamic_create_fields %}
<div>
<script class="dynamic_{{field_tuple.0}}_scripts">
{{ form.initial | get_item:field_tuple.0 }}
$("#{{view_id}}Form [name={{field_tuple.0}}]").val({{form.initial|get_item:field_tuple.0|safe}}).change()
</script>
<form hidden id="modalButton{{field_tuple.0}}Form"
hx-get="/dynamic-path-{{field_tuple.0}}-{{request.session.session_key}}?dynamic_field={{field_tuple.0}}"
hx-target="#dynamicModal{{field_tuple.0}}Body">
<input type="text" name="dynamic_initial" data-dynamic-field="{{field_tuple.0}}">
<input type="text" name="view_id" value="{{view_id}}">
{% for field in field_tuple.2 %}
<input type="text" name="{{field}}">
{% endfor %}
<button type="submit" id="modalButton{{field_tuple.0}}"
onclick="$('#dynamicModal{{field_tuple.0}}').addClass('oh-modal--show');">
{{field_tuple.0}}
</button>
</form>
<form hidden id="reload-field{{field_tuple.0}}{{view_id}}" hx-target="#dynamic_field_{{field_tuple.0}}"
hx-get="{% url 'reload-field' %}?form_class_path={{form_class_path}}&dynamic_field={{field_tuple.0}}" >
<input type="text" name="dynamic_initial" data-dynamic-field="{{field_tuple.0}}">
<input type="text" name="view_id" value="{{view_id}}">
<button class="reload-field" data-target="{{field_tuple.0}}">
{{field_tuple.0}}
</button>
</form>
<script class="dynamic_{{field_tuple.0}}_scripts">
$("#{{view_id}}Form [name={{field_tuple.0}}]").change(function (e) {
values = $(this).val();
if (!values) {
values = ""
}
if (values == "dynamic_create") {
$("#modalButton{{field_tuple.0}}").click()
$(this).val("")
} else if (values.includes("dynamic_create")) {
let index = values.indexOf("dynamic_create");
values.splice(index, 1);
$(this).val(values).change();
$("#modalButton{{field_tuple.0}}").parent().find('input[name=dynamic_initial]').val(values)
$("#reload-field{{field_tuple.0}}{{view_id}}").find('input[name=dynamic_initial]').val(values)
$("#modalButton{{field_tuple.0}}").click()
} else if (values) {
$("#modalButton{{field_tuple.0}}").parent().find('input[name=dynamic_initial]').val(values)
$("#reload-field{{field_tuple.0}}{{view_id}}").find('input[name=dynamic_initial]').val(values)
}
});
$("#reload-field{{field_tuple.0}}{{view_id}}").submit(function (e) {
e.preventDefault();
$(this).find("[name=dynamic_initial]").val();
});
</script>
</div>
{% endfor %}
<button
type="submit"
id="modalButton{{field_tuple.0}}"
onclick="$('#dynamicModal{{field_tuple.0}}').addClass('oh-modal--show');"
>
{{field_tuple.0}}
</button>
</form>
<form hidden id="reload-field{{field_tuple.0}}{{view_id}}" hx-get="{% url "reload-field" %}?form_class_path={{form_class_path}}&dynamic_field={{field_tuple.0}}" hx-target="#dynamic_field_{{field_tuple.0}}">
<input type="text" name="dynamic_initial" data-dynamic-field="{{field_tuple.0}}">
<input type="text" name="view_id" value="{{view_id}}">
<button class="reload-field" data-target="{{field_tuple.0}}">
{{field_tuple.0}}
</button>
</form>
<script class="dynamic_{{field_tuple.0}}_scripts">
$("#{{view_id}}Form [name={{field_tuple.0}}]").change(function (e) {
values = $(this).val();
if (!values) {
values = ""
}
if (values == "dynamic_create") {
$("#modalButton{{field_tuple.0}}").click()
$(this).val("")
}else if (values.includes("dynamic_create")) {
let index = values.indexOf("dynamic_create");
values.splice(index, 1);
$(this).val(values).change();
$("#modalButton{{field_tuple.0}}").parent().find('input[name=dynamic_initial]').val(values)
$("#reload-field{{field_tuple.0}}{{view_id}}").find('input[name=dynamic_initial]').val(values)
$("#modalButton{{field_tuple.0}}").click()
}else if(values) {
$("#modalButton{{field_tuple.0}}").parent().find('input[name=dynamic_initial]').val(values)
$("#reload-field{{field_tuple.0}}{{view_id}}").find('input[name=dynamic_initial]').val(values)
}
});
$("#reload-field{{field_tuple.0}}{{view_id}}").submit(function (e) {
e.preventDefault();
$(this).find("[name=dynamic_initial]").val();
});
</script>
</div>
{% endfor %}
</div>

View File

@@ -362,6 +362,7 @@
<h1 class="oh-404__title">{% trans "No Records found" %}</h1>
<p class="oh-404__subtitle">
{% trans "No records found." %}
{% include "generic/import_block.html" %}
</p>
</div>
</div>

View File

@@ -21,6 +21,7 @@
>
</div>
{% if nav_url %}
<div
hx-get="{{nav_url}}?{{request.GET.urlencode}}"
hx-trigger="load"
@@ -31,7 +32,9 @@
>
</div>
</div>
{% endif %}
{% if view_url %}
<div
class="oh-wrapper"
hx-get="{{view_url}}?{{request.GET.urlencode}}"
@@ -44,6 +47,7 @@
>
</div>
</div>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,82 @@
{% load generic_template_filters i18n %}
<style>
.opacity-50{
opacity: .5!important;
}
</style>
{% if import_fields and import_accessibility %}
<div id="import_{{ view_id }}" class="oh-checkpoint-badge text-warning" style="cursor: pointer;color: hsl(211.72deg 91% 60%) !important;" data-toggle="oh-modal-toggle" data-target="#importModal{{ view_id }}" onclick="
ids = $('#{{ selected_instances_key_id }}').attr('data-ids');
$('#submitGetImportSheet{{ view_id }}Form').find('[name=selected_ids]').val(ids);">
<span class="label">{% trans 'Import' %}</span>
(<ion-icon name="arrow-down-outline" class="m-0"></ion-icon>)
</div>
<div class="oh-modal" id="importModal{{ view_id }}">
<div class="oh-modal__dialog" id="objectCreateModalTarget">
<div class="oh-modal__dialog-header">
<h2 class="oh-modal__dialog-title">Import Records</h2>
<button class="oh-modal__close" aria-label="Close"><ion-icon name="close-outline" role="img"></ion-icon></button>
<div class="oh-modal__dialog-body p-0 pb-4">
<form hx-post="/{{ post_import_sheet_path }}" hx-encoding="multipart/form-data" class="oh-profile-section">
<div class="oh-modal__dialog-body mr-5" id="uploading{{view_id}}" style="display: none">
<div class="loader-container">
<div class="loader"></div>
<div class="loader-text">Uploading...</div>
</div>
</div>
<div id="error-container" style="color: red"></div>
<div class="modal-body">
<div class="oh-dropdown__import-form">
<label class="oh-dropdown__import-label">
<ion-icon name="cloud-upload" class="oh-dropdown__import-form-icon md hydrated" role="img" aria-label="cloud upload"></ion-icon>
<span class="oh-dropdown__import-form-title">Upload a File</span>
<span class="oh-dropdown__import-form-text">Drag and drop files here</span>
</label>
<input id="resumeUpload{{ view_id }}" type="file" name="file" required="" />
<div class="d-inline float-end">
<a onclick="
$('#submitGetImportSheet{{ view_id }}').click();
" style="text-decoration:none; display: inline-block;" class="oh-dropdown__link" hx-on:click="template_download(event)">
<ion-icon name="cloud-download-outline" style="font-size:20px; vertical-align: middle;" role="img" class="md hydrated"></ion-icon>
<span>Download Template</span>
</a>
</div>
</div>
</div>
{% if import_help %}
<h6 class="mt-3"><b>{% trans 'Import Help' %}</b></h6>
<div class="row">
{% for key in import_help %}
<div class="mt-1 col-3">
<b>{{ key }}</b>
{% for val in import_help|get_item:key %}
<li>{{ val }}</li>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
<div class="modal-footer d-flex flex-row-reverse">
<input
onclick="
if($('#resumeUpload{{ view_id }}').val()){
$('#uploading{{view_id}}').show();
$(this).addClass('opacity-50');
setTimeout(() => {
$(this).attr('type','button');
}, 100);
}
"
type="submit" class="oh-btn oh-btn--small oh-btn--secondary w-100 mt-3" value="Upload" />
</div>
</form>
<form id="submitGetImportSheet{{ view_id }}Form" action="/{{ get_import_sheet_path }}" method="post">
{% csrf_token %}
<input type="text" name="selected_ids" hidden />
<input type="submit" hidden id="submitGetImportSheet{{ view_id }}" />
</form>
</div>
</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,575 @@
{% load i18n recruitmentfilters %}
{% load generic_template_filters %}
<div id="{{ view_id }}">
<div class="kanban-board">
{% for group in groups %}
<div class="kanban-column" data-stage="todo">
<div class="column-header">
<div class="column-title">
<h3 title="{{group}}">{{ group|stringformat:"s"|truncatechars:22 }}</h3>
</div>
<div div class="d-flex" style="width: 50px;">
<span class="task-count">2</span><div class="dropdown">
{% if actions %}
<div>
<button class="dropbtn"></button>
<div class="dropdown-content">
{% for action in actions %}
{% if action.accessibility|accessibility:group %}
<a {{action.attrs|format:group|safe}}>{{action.action}}</a>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="cards-container">
<div class="kanban-card" draggable="true" data-id="1">
<div class="card-title">Design Homepage</div>
<div class="card-description">Create wireframes and mockups for the new homepage design.</div>
<div class="card-meta">
<span class="card-priority priority-high">High Priority</span>
<div class="meta-content">
<span>May 16</span>
<div class="profile-container">
<div class="avatar-placeholder" title="Unassigned">+</div>
<div class="assignee-dropdown">
<div class="assignee-option" data-initials="JD">
<div class="avatar" style="background-color: #4f46e5;">JD</div>
<span>John Doe</span>
</div>
<div class="assignee-option" data-initials="AS">
<div class="avatar" style="background-color: #10b981;">AS</div>
<span>Alice Smith</span>
</div>
<div class="assignee-option" data-initials="RJ">
<div class="avatar" style="background-color: #f43f5e;">RJ</div>
<span>Robert Johnson</span>
</div>
<div class="assignee-option" data-initials="UN">
<div class="avatar-placeholder">+</div>
<span>Unassigned</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="kanban-card" draggable="true" data-id="2">
<div class="card-title">Update Documentation</div>
<div class="card-description">Review and update the API documentation for v2.0 release.</div>
<div class="card-meta">
<span class="card-priority priority-medium">Medium Priority</span>
<div class="meta-content">
<span>May 20</span>
<div class="profile-container">
<div class="avatar" style="background-color: #10b981;" title="Alice Smith">AS</div>
<div class="assignee-dropdown">
<div class="assignee-option" data-initials="JD">
<div class="avatar" style="background-color: #4f46e5;">JD</div>
<span>John Doe</span>
</div>
<div class="assignee-option" data-initials="AS">
<div class="avatar" style="background-color: #10b981;">AS</div>
<span>Alice Smith</span>
</div>
<div class="assignee-option" data-initials="RJ">
<div class="avatar" style="background-color: #f43f5e;">RJ</div>
<span>Robert Johnson</span>
</div>
<div class="assignee-option" data-initials="UN">
<div class="avatar-placeholder">+</div>
<span>Unassigned</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="add-task">+ Add new task</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
$(document).ready(function () {
// Define the viewId
var viewId = '#{{view_id}}'
let draggedCard = null
// Initialize drag events for cards
function initDragAndDrop() {
// Drag start
$(viewId + ' .kanban-card').on('dragstart', function () {
draggedCard = $(this)
setTimeout(function () {
draggedCard.addClass('dragging')
}, 0)
})
// Drag end
$(viewId + ' .kanban-card').on('dragend', function () {
$(this).removeClass('dragging')
draggedCard = null
updateTaskCounts()
})
// Handle dragover to allow drops
$(viewId + ' .cards-container').on('dragover', function (e) {
e.preventDefault()
$(this).addClass('drop-zone')
})
// Remove drop zone highlighting
$(viewId + ' .cards-container').on('dragleave', function () {
$(this).removeClass('drop-zone')
})
// Handle drops
$(viewId + ' .cards-container').on('drop', function (e) {
e.preventDefault()
$(this).removeClass('drop-zone')
if (draggedCard) {
// Find the "Add task" button and insert before it
const addTaskButton = $(this).find('.add-task')
draggedCard.insertBefore(addTaskButton)
const itemId = draggedCard.data('id')
const column = $(this).closest('.kanban-column')
const newStage = column.data('stage')
console.log(`Item ${itemId} moved to ${newStage}`)
// Update card styling based on new column
updateCardStyling(draggedCard, newStage)
// 🔁 Replace with AJAX to update backend
/*
$.ajax({
url: '/api/kanban/update-stage/',
type: 'POST',
contentType: 'application/json',
headers: {
'X-CSRFToken': '{{ csrf_token }}'
},
data: JSON.stringify({
item_id: itemId,
new_stage: newStage
})
});
*/
}
})
}
// Initialize profile dropdowns for cards
function initProfileDropdowns() {
// Avatar click to show dropdown
$(viewId).on('click', '.avatar, .avatar-placeholder', function (e) {
e.stopPropagation()
const dropdown = $(this).siblings('.assignee-dropdown')
// Close all other dropdowns first
$(viewId + ' .assignee-dropdown.active')
.not(dropdown)
.removeClass('active')
// Toggle this dropdown
dropdown.toggleClass('active')
})
// Assignee selection
$(viewId).on('click', '.assignee-option', function () {
const profileContainer = $(this).closest('.profile-container')
const initials = $(this).data('initials')
if (initials === 'UN') {
// Unassign
profileContainer.find('.avatar, .avatar-placeholder').replaceWith($('<div class="avatar-placeholder" title="Unassigned">+</div>'))
} else {
// Assign to selected person
const personName = $(this).find('span').text()
const avatar = $(this).find('.avatar').clone()
avatar.attr('title', personName)
profileContainer.find('.avatar, .avatar-placeholder').replaceWith(avatar)
}
// Close dropdown
profileContainer.find('.assignee-dropdown').removeClass('active')
})
// Close dropdowns when clicking elsewhere
$(document).on('click', function () {
$(viewId + ' .assignee-dropdown.active').removeClass('active')
})
}
// Initialize column collapse/expand functionality
function initColumnCollapse() {
$(viewId + ' .column-header').on('click', function () {
$(this).closest('.kanban-column').toggleClass('collapsed')
})
}
// Handle adding new tasks
function initAddTask() {
$(viewId + ' .add-task').on('click', function () {
const column = $(this).closest('.kanban-column')
const stage = column.data('stage')
// Create a new card
const newCard = createNewTaskCard(stage)
// Add it before the add button
$(this).before(newCard)
// Initialize drag events for the new card
newCard
.on('dragstart', function () {
draggedCard = $(this)
setTimeout(function () {
draggedCard.addClass('dragging')
}, 0)
})
.on('dragend', function () {
$(this).removeClass('dragging')
draggedCard = null
updateTaskCounts()
})
// Update counts
updateTaskCounts()
console.log(`New task created in ${stage}`)
})
}
// Create a new task card
function createNewTaskCard(stage) {
// Create unique ID for the card
const newId = Date.now()
// Create the card with proper structure
const newCard = $('<div class="kanban-card" draggable="true"></div>').attr('data-id', newId).append(`
<div class="card-title">New Task</div>
<div class="card-description">Click to edit this task description.</div>
<div class="card-meta">
<span class="card-priority priority-medium">Medium Priority</span>
<div class="meta-content">
<span>Today</span>
<div class="profile-container">
<div class="avatar-placeholder" title="Unassigned">+</div>
<div class="assignee-dropdown">
<div class="assignee-option" data-initials="JD">
<div class="avatar" style="background-color: #4f46e5;">JD</div>
<span>John Doe</span>
</div>
<div class="assignee-option" data-initials="AS">
<div class="avatar" style="background-color: #10b981;">AS</div>
<span>Alice Smith</span>
</div>
<div class="assignee-option" data-initials="RJ">
<div class="avatar" style="background-color: #f43f5e;">RJ</div>
<span>Robert Johnson</span>
</div>
<div class="assignee-option" data-initials="UN">
<div class="avatar-placeholder">+</div>
<span>Unassigned</span>
</div>
</div>
</div>
</div>
</div>
`)
// Apply styling based on column
updateCardStyling(newCard, stage)
return newCard
}
// Update card styling based on column
function updateCardStyling(card, stage) {
if (stage === 'todo') {
card.css({
'border-left-color': '#f43f5e',
background: '#fff1f2'
})
} else if (stage === 'inprogress') {
card.css({
'border-left-color': '#f59e0b',
background: '#fffbeb'
})
} else if (stage === 'done') {
card.css({
'border-left-color': '#10b981',
background: '#ecfdf5'
})
}
}
// Update task counts for each column
function updateTaskCounts() {
$(viewId + ' .kanban-column').each(function () {
const cardCount = $(this).find('.kanban-card').length
$(this).find('.task-count').text(cardCount)
})
}
// Initialize everything
function init() {
initDragAndDrop()
initProfileDropdowns()
initColumnCollapse()
initAddTask()
updateTaskCounts()
}
// Run initialization
init()
})
</script>
<script>
const cards = document.querySelectorAll('.kanban-card')
const columns = document.querySelectorAll('.kanban-column')
const cardsContainers = document.querySelectorAll('.cards-container')
const addTaskButtons = document.querySelectorAll('.add-task')
let draggedCard = null
// Initialize card dragging
cards.forEach((card) => {
card.addEventListener('dragstart', () => {
draggedCard = card
setTimeout(() => {
card.classList.add('dragging')
}, 0)
})
card.addEventListener('dragend', () => {
draggedCard = null
card.classList.remove('dragging')
updateTaskCounts()
})
// Set up profile dropdown functionality
const profileContainer = card.querySelector('.profile-container')
if (profileContainer) {
const avatar = profileContainer.querySelector('.avatar, .avatar-placeholder')
const dropdown = profileContainer.querySelector('.assignee-dropdown')
avatar.addEventListener('click', (e) => {
e.stopPropagation()
// Close all other dropdowns first
document.querySelectorAll('.assignee-dropdown.active').forEach((el) => {
if (el !== dropdown) {
el.classList.remove('active')
}
})
dropdown.classList.toggle('active')
})
// Handle assignee selection
const assigneeOptions = dropdown.querySelectorAll('.assignee-option')
assigneeOptions.forEach((option) => {
option.addEventListener('click', () => {
const initials = option.getAttribute('data-initials')
const currentAvatar = profileContainer.querySelector('.avatar, .avatar-placeholder')
if (initials === 'UN') {
// Unassign
profileContainer.innerHTML = `
<div class="avatar-placeholder" title="Unassigned">+</div>
<div class="assignee-dropdown">
${dropdown.innerHTML}
</div>
`
} else {
// Assign to selected person
const newAvatar = option.querySelector('.avatar').cloneNode(true)
newAvatar.title = option.querySelector('span').textContent
profileContainer.innerHTML = `
${newAvatar.outerHTML}
<div class="assignee-dropdown">
${dropdown.innerHTML}
</div>
`
}
// Reset event listeners for the new elements
const newDropdown = profileContainer.querySelector('.assignee-dropdown')
const newAvatar = profileContainer.querySelector('.avatar, .avatar-placeholder')
newAvatar.addEventListener('click', (e) => {
e.stopPropagation()
newDropdown.classList.toggle('active')
})
// Reset assignee option event listeners
const newOptions = newDropdown.querySelectorAll('.assignee-option')
newOptions.forEach((newOpt) => {
newOpt.addEventListener('click', function () {
const newInitials = this.getAttribute('data-initials')
// Recursive call to handle this selection
this.click() // This is a simplified approach
})
})
dropdown.classList.remove('active')
})
})
}
})
// Handle column collapse
document.querySelectorAll('.column-header').forEach((header) => {
header.addEventListener('click', () => {
const column = header.closest('.kanban-column')
column.classList.toggle('collapsed')
})
})
// Close dropdowns when clicking outside
document.addEventListener('click', () => {
document.querySelectorAll('.assignee-dropdown.active').forEach((dropdown) => {
dropdown.classList.remove('active')
})
})
// Handle drag and drop for columns
cardsContainers.forEach((container) => {
container.addEventListener('dragover', (e) => {
e.preventDefault()
container.classList.add('drop-zone')
})
container.addEventListener('dragleave', () => {
container.classList.remove('drop-zone')
})
container.addEventListener('drop', (e) => {
e.preventDefault()
container.classList.remove('drop-zone')
if (draggedCard) {
// Find the "Add task" button and insert before it
const addTaskButton = container.querySelector('.add-task')
container.insertBefore(draggedCard, addTaskButton)
const itemId = draggedCard.getAttribute('data-id')
const column = container.closest('.kanban-column')
const newStage = column.getAttribute('data-stage')
console.log(`Item ${itemId} moved to ${newStage}`)
// Update card styling based on new column
updateCardStyling(draggedCard, newStage)
// 🔁 Replace with AJAX to update backend
/*
fetch('/api/kanban/update-stage/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
item_id: itemId,
new_stage: newStage
})
});
*/
}
})
})
// Handle add task button clicks
addTaskButtons.forEach((button) => {
button.addEventListener('click', () => {
const column = button.closest('.kanban-column')
const stage = column.getAttribute('data-stage')
const container = column.querySelector('.cards-container')
// Create a new card with a unique ID (this is just for demo purposes)
const newId = Date.now()
const newCard = document.createElement('div')
newCard.className = 'kanban-card'
newCard.setAttribute('draggable', 'true')
newCard.setAttribute('data-id', newId)
// Set the default content for the new card
newCard.innerHTML = `
<div class="card-title">New Task</div>
<div class="card-description">Click to edit this task description.</div>
<div class="card-meta">
<span class="card-priority priority-medium">Medium Priority</span>
<span>Today</span>
</div>
`
// Apply styling based on the column
updateCardStyling(newCard, stage)
// Add the new card before the add task button
container.insertBefore(newCard, button)
// Set up drag events for the new card
newCard.addEventListener('dragstart', () => {
draggedCard = newCard
setTimeout(() => {
newCard.classList.add('dragging')
}, 0)
})
newCard.addEventListener('dragend', () => {
draggedCard = null
newCard.classList.remove('dragging')
updateTaskCounts()
})
// Update counts
updateTaskCounts()
console.log(`New task created in ${stage}`)
})
})
// Function to update card styling based on column
function updateCardStyling(card, stage) {
// Remove all potential styling classes first
card.style.borderLeftColor = ''
card.style.background = ''
// Apply styling based on stage
if (stage === 'todo') {
card.style.borderLeftColor = '#f43f5e'
card.style.background = '#fff1f2'
} else if (stage === 'inprogress') {
card.style.borderLeftColor = '#f59e0b'
card.style.background = '#fffbeb'
} else if (stage === 'done') {
card.style.borderLeftColor = '#10b981'
card.style.background = '#ecfdf5'
}
}
// Function to update task counts
function updateTaskCounts() {
columns.forEach((column) => {
const cards = column.querySelectorAll('.kanban-card').length
const countSpan = column.querySelector('.task-count')
countSpan.textContent = cards
})
}
// Initial count update
updateTaskCounts()
</script>

View File

@@ -1,4 +1,4 @@
{% load i18n %}
{% load i18n generic_template_filters %}
{% if request.actual_ids and request.session.prev_path == request.path %}
<script>
var ids = {{request.session.hlv_selected_ids|safe}}
@@ -105,6 +105,7 @@
</button>
</form>
{% endif %}
{% include "generic/import_block.html" %}
{% if filter_selected %}
<div
id="filter_selected_{{view_id}}"

View File

@@ -15,6 +15,7 @@ from django.conf import settings
from django.contrib.auth.context_processors import PermWrapper
from django.db.models.utils import AltersData
from django.template.defaultfilters import register
from django.utils.translation import gettext_lazy as _
from horilla.config import import_method
from horilla.horilla_middlewares import _thread_locals
@@ -46,16 +47,14 @@ time_format_mapping = {
@register.filter(name="selected_format")
def selected_format(date: datetime.date, company: object = None) -> str:
if isinstance(date, datetime.date):
format = (
company.date_format if company and company.date_format else "MMM. D, YYYY"
)
strftime_format = date_format_mapping.get(format, "%b. %d, %Y")
return date.strftime(strftime_format)
elif isinstance(date, datetime.time):
format = company.time_format if company and company.time_format else "hh:mm A"
strftime_format = time_format_mapping.get(format, "%I:%M %p")
return date.strftime(strftime_format)
if company and (company.date_format or company.time_format):
if isinstance(date, datetime.date):
format = company.date_format if company.date_format else "MMM. D, YYYY"
date_format_mapping.get(format)
return date.strftime(date_format_mapping[format])
elif isinstance(date, datetime.time):
format = company.time_format if company.time_format else "hh:mm A"
return date.strftime(time_format_mapping[format])
return date
@@ -82,38 +81,36 @@ def getattribute(value, attr: str):
value = result
else:
return getattr(value, attr, "")
if isinstance(result, bool):
return _("Yes") if result else _("No")
return result
@register.filter(name="format")
def format(string: str, instance: object):
"""
Format a string by resolving instance attributes, including method calls like get_status_display.
format
"""
attr_placeholder_regex = r"{([^}]*)}"
attr_placeholders = re.findall(attr_placeholder_regex, string)
if not attr_placeholders:
return string
original_instance = instance
flag = instance
format_context = {}
for attr_placeholder in attr_placeholders:
instance = original_instance # reset each time
attrs = attr_placeholder.split("__")
try:
for attr in attrs:
instance = getattr(instance, attr, "")
# If final resolved attr is a method, call it
if callable(instance):
instance = instance()
except Exception:
instance = ""
format_context[attr_placeholder] = instance
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 string.format(**format_context)
return formatted_string
@register.filter("accessibility")
@@ -155,3 +152,11 @@ def get_id(string: str):
Generate target/id for the generic delete summary
"""
return string.split("-")[0].lower().replace(" ", "")
@register.filter
def is_image_file(filename):
"""
Django template filter to check if a given filename is an image file.
"""
return filename.lower().endswith((".png", ".jpg", ".jpeg", ".svg"))