Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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,6 +32,12 @@
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'^selectcomponents/$',
views.component_select,
name='maintenance-component-select',
),
re_path(r'^new/$', views.edit, name='maintenance-new'),
re_path(
r'^new/(?P<start_time>\d{4}-\d{2}-\d{2})/$',
Expand Down
42 changes: 42 additions & 0 deletions python/nav/web/maintenance/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ def get_component_keys(post):
return component_keys, errors


def get_component_name(model: models.Model):
"""Returns a short name for the component type based on its model class.

Used as the input name for component keys in forms and APIs.

Location is abbreviated to 'loc' to avoid XSS issues.
"""
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 +218,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
74 changes: 69 additions & 5 deletions python/nav/web/maintenance/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@
import logging
import time
from datetime import datetime
from typing import Optional

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 +45,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 +246,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 +345,74 @@ 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"""
raw_search = request.POST.get("search")
search = raw_search.strip() if raw_search else ''
if not search:
return render(
request, 'maintenance/_component-search-results.html', {'results': {}}
)

results = {}
searches: list[tuple[type[Model], Q, Optional[Model]]] = [
(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" alt="Loading spinner" /> 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
Loading
Loading