Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3425.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use HTMX for selecting and modifying components for Maintenance tasks. Replaces Quickselect implementation.
6 changes: 6 additions & 0 deletions python/nav/web/maintenance/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@
re_path(r'^active/$', views.active, name='maintenance-active'),
re_path(r'^planned/$', views.planned, name='maintenance-planned'),
re_path(r'^historic/$', views.historic, name='maintenance-historic'),
re_path(r'^search/$', views.component_search, name='maintenance-component-search'),
re_path(r'^new/$', views.edit, name='maintenance-new'),
re_path(
r'^selectcomponents/$',
views.component_select,
name='maintenance-component-select',
),
re_path(
r'^new/(?P<start_time>\d{4}-\d{2}-\d{2})/$',
views.edit,
Expand Down
36 changes: 36 additions & 0 deletions python/nav/web/maintenance/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ def get_component_keys(post):
return component_keys, errors


def get_component_name(model: models.Model):
if model._meta.db_table == 'location':
return 'loc'
return model._meta.db_table


def get_components_from_keydict(
component_keys: dict[str, List[Union[int, str]]],
) -> tuple[List[ComponentType], List[str]]:
Expand Down Expand Up @@ -206,6 +212,36 @@ def get_components_from_keydict(
return components, component_data_errors


def prefetch_and_group_components(
component_type: models.Model,
query_results: models.QuerySet,
group_by: Union[models.Model, None] = None,
):
"""
Prefetches the related model and groups components by the related model name.
"""
if not group_by:
return query_results

group_by_name = group_by._meta.db_table

if hasattr(query_results, 'prefetch_related') and hasattr(
component_type, group_by_name
):
query_results = query_results.prefetch_related(group_by_name)

grouped_results = {}
for component in query_results:
group_by_model = getattr(component, group_by_name)
group_name = str(group_by_model)

if group_name not in grouped_results:
grouped_results[group_name] = []
grouped_results[group_name].append(component)

return [(group, components) for group, components in grouped_results.items()]


class MaintenanceCalendar(HTMLCalendar):
START = 'start'
END = 'end'
Expand Down
72 changes: 67 additions & 5 deletions python/nav/web/maintenance/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@
from datetime import datetime

from django.db import connection, transaction
from django.db.models import Count, Q
from django.db.models import Count, Model, Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.views.decorators.http import require_http_methods

import nav.maintengine
from nav.django.utils import get_account
from nav.models.manage import Netbox
from nav.models.manage import Location, Netbox, NetboxGroup, Room
from nav.models.msgmaint import MaintenanceComponent, MaintenanceTask
from nav.models.service import Service
from nav.web.maintenance.forms import (
MaintenanceAddSingleNetbox,
MaintenanceCalendarForm,
Expand All @@ -42,13 +44,14 @@
MaintenanceCalendar,
component_to_trail,
get_component_keys,
get_component_name,
get_components,
get_components_from_keydict,
prefetch_and_group_components,
infodict_by_state,
task_form_initial,
)
from nav.web.message import Messages, new_message
from nav.web.quickselect import QuickSelect

INFINITY = datetime.max

Expand Down Expand Up @@ -242,7 +245,6 @@ def cancel(request, task_id):
@transaction.atomic()
def edit(request, task_id=None, start_time=None, **_):
account = get_account(request)
quickselect = QuickSelect(service=True)
components = task = None
component_keys_errors = []
component_keys = {}
Expand Down Expand Up @@ -342,13 +344,73 @@ def edit(request, task_id=None, start_time=None, **_):
'heading': heading,
'task_form': task_form,
'task_id': task_id,
'quickselect': mark_safe(quickselect),
'components': component_trail,
'selected': component_keys,
},
)


@require_http_methods(["POST"])
def component_search(request):
"""HTMX endpoint for component searches from maintenance task form"""
search = request.POST.get("search")
if not search or search == '':
return render(
request, 'maintenance/_component-search-results.html', {'results': {}}
)

results = {}
searches: list[tuple[type[Model], Q, type[Model] | None]] = [
(Location, Q(id__icontains=search), None),
(Room, Q(id__icontains=search), Location),
(Netbox, Q(sysname__icontains=search), Room),
(NetboxGroup, Q(id__icontains=search), None),
(
Service,
Q(handler__icontains=search) | Q(netbox__sysname__icontains=search),
Netbox,
),
]

for component_type, query, group_by in searches:
component_results = component_type.objects.filter(query)
grouped_results = prefetch_and_group_components(
component_type, component_results, group_by
)

if component_results:
component_title = get_component_name(component_type)
results[component_title] = {
'label': component_type._meta.verbose_name.title(),
'values': grouped_results,
'has_grouping': group_by is not None,
}

return render(
request, 'maintenance/_component-search-results.html', {'results': results}
)


@require_http_methods(["POST"])
def component_select(request):
"""HTMX endpoint for component selection from maintenance task form"""
component_keys, component_keys_errors = get_component_keys(request.POST)
for error in component_keys_errors:
new_message(request, error, Messages.ERROR)

components, components_errors = get_components_from_keydict(component_keys)
for error in components_errors:
new_message(request, error, Messages.ERROR)

component_trail = [component_to_trail(c) for c in components]

return render(
request,
'maintenance/_selected-components-list.html',
{'components': component_trail, 'selected': component_keys},
)


def add_box_to_maintenance(request):
"""
This view puts a Netbox on immediate, indefinite maintenance and
Expand Down
9 changes: 1 addition & 8 deletions python/nav/web/static/js/src/maintenance.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
require(['plugins/quickselect', 'plugins/hover_highlight', "libs/jquery-ui-timepicker-addon"], function (QuickSelect, HoverHighlight) {
require(['plugins/hover_highlight', "libs/jquery-ui-timepicker-addon"], function (HoverHighlight) {
var calendar = $('.calendar');
var quickselect = $('.quickselect');

if (calendar.length) {
new HoverHighlight(calendar);
}

if (quickselect.length) {
new QuickSelect(quickselect);
}

$(document).ready(function(){
$('#id_no_end_time').change(function(){
toggleEndTime(this);
Expand All @@ -29,6 +24,4 @@ require(['plugins/quickselect', 'plugins/hover_highlight', "libs/jquery-ui-timep
$(endTime).removeAttr('disabled');
}
}


});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{% if results %}
{% for component_type, result in results.items %}
<div class="component-group">
<label for="id_{{ component_type }}" style="font-weight: bold;">{{ result.label }}</label>
<select multiple id="id_{{ component_type }}" name="{{ component_type }}">
{% if result.has_grouping %}
{% for optgroup, options in result.values %}
<optgroup label="{{ optgroup }}">
{% for option in options %}
<option value="{{ option.id }}">{{ option }}</option>
{% endfor %}
</optgroup>
{% endfor %}
{% else %}
{% for option in result.values %}
<option value="{{ option.id }}">{{ option }}</option>
{% endfor %}
{% endif %}
</select>
<div class="row">
<button
type="button"
class="button secondary small"
hx-post="{% url 'maintenance-component-select' %}"
hx-target="#selected-components"
hx-include="#id_{{ component_type }}, #selected-components input"
hx-on::after-request="document.getElementById('id_{{ component_type }}').selectedIndex = -1;"
>
Add {{ result.label }}
</button>
<button
type="button" class="button secondary small"
onclick="Array.from(document.getElementById('id_{{ component_type }}').options).forEach(opt => opt.selected = true);"
>
Select all
</button>
</div>
</div>
{% endfor %}
{% else %}
<strong>No hits</strong>
{% endif %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% load maintenance %}
{% for key, identifiers in selected.items %}
{% for id in identifiers %}
<input type="hidden" name="{{ key }}" value="{{ id }}"/>
{% endfor %}
{% endfor %}
{% if components %}
<div class="row">
<button
type="button"
class="button tiny secondary"
onclick="document.querySelectorAll('#component-list input[type=checkbox]').forEach(cb => cb.checked = true);"
>
Select all
</button>
<button
type="button"
class="button tiny secondary"
onclick="document.querySelectorAll('#component-list input[type=checkbox]').forEach(cb => cb.checked = false);"
>
Unselect all
</button>
</div>
<ul id="component-list" class="no-bullet">
{% for trail in components %}
{% with trail|last as component %}
<li>
<div>
<input type="hidden" name="{{ component|component_db_table }}" value="{{ component.pk }}"/>
<input type="checkbox" name="remove_{{ component|component_db_table }}" value="{{ component.pk }}"/>
{{ component|model_verbose_name }}:
</div>
<div>
{% include "maintenance/frag-component-trail.html" %}
</div>
</li>
{% endwith %}
{% endfor %}
</ul>
<button
name="remove"
hx-post="{% url 'maintenance-component-select' %}"
hx-target="#selected-components"
hx-include="#selected-components input"
class="button small secondary"
>
Remove selected
</button>
{% else %}
<p>(none)</p>
<button disabled="disabled" class="button small secondary">Remove selected</button>
{% endif %}
61 changes: 25 additions & 36 deletions python/nav/web/templates/maintenance/new_task.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ <h4>{{ heading }}</h4>

<div class="panel white">
<form id="new-task-form" action="{{ request.path }}" method="post">

<div class="row">

<div class="large-4 columns">
<fieldset>
<legend>Details</legend>
Expand All @@ -41,46 +39,37 @@ <h4>{{ heading }}</h4>
</div>

<div class="large-4 columns">
<fieldset>
<legend>Select components</legend>
{{ quickselect }}
</fieldset>
<fieldset>
<legend>Select components</legend>
<label for="component-search">
Search
</label>
<input
id="component-search"
class="form-control" type="search"
name="search"
placeholder="Begin typing to search for components..."
hx-post="{% url 'maintenance-component-search' %}"
hx-trigger="input changed delay:500ms, search"
hx-target="#search-results"
hx-indicator=".htmx-indicator"
/>
<span class="htmx-indicator">
<img src="/static/images/select2/select2-spinner.gif"/> Searching...
</span>
<div id="search-results"></div>
</fieldset>

</div>

<div class="large-4 columns">
<fieldset>
<legend>Selected components</legend>
{% for key, identifiers in selected.items %}
{% for id in identifiers %}
<input type="hidden" name="{{ key }}" value="{{ id }}"/>
{% endfor %}
{% endfor %}

{% if components %}
<ul id="component-list" class="no-bullet">
{% for trail in components %}
{% with trail|last as component %}
<li>
<div>
<input type="hidden" name="{{ component|component_db_table }}" value="{{ component.pk }}"/>
<input type="checkbox" name="remove_{{ component|component_db_table }}" value="{{ component.pk }}"/>
{{ component|model_verbose_name }}:
</div>
<div>
{% include "maintenance/frag-component-trail.html" %}
</div>
</li>
{% endwith %}
{% endfor %}
</ul>
<input type="submit" name="remove"
value="Remove selected" class="button small secondary"/>
{% else %}
<p>(none)</p>
<input type="submit" name="remove"
value="Remove selected" disabled="disabled" class="button small secondary"/>
{% endif %}
<div id="selected-components">
{% include 'maintenance/_selected-components-list.html' with components=components selected=selected %}
</div>
</fieldset>

</div> {# column #}
</div> {# row #}

Expand Down