[UPDT] HORILLA_VIEWS: Updated horilla views generic classes
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
3
horilla_views/generic/cbv/kanban.py
Normal file
3
horilla_views/generic/cbv/kanban.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
horilla_views/generic/cbv/kanban.py
|
||||
"""
|
||||
@@ -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
@@ -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
|
||||
|
||||
69
horilla_views/templates/cbv/import_response.html
Normal file
69
horilla_views/templates/cbv/import_response.html
Normal 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>
|
||||
@@ -76,3 +76,5 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% block generic_components %}
|
||||
{% endblock generic_components %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
82
horilla_views/templates/generic/import_block.html
Normal file
82
horilla_views/templates/generic/import_block.html
Normal 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 %}
|
||||
575
horilla_views/templates/generic/pipeline/kanban.html
Normal file
575
horilla_views/templates/generic/pipeline/kanban.html
Normal 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>
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user