WIP: Lot groups search/filtering and lot groups panel ui changes #61

Draft
rskthomas wants to merge 17 commits from rework/lots into main
22 changed files with 539 additions and 166 deletions

View file

@ -16,8 +16,13 @@
<div class="col">
{% if lot_tags_edit %}
<table class="table table-hover table-bordered align-middle">
<caption class="text-muted small">
{% trans 'Inbox order CANNOT be changed' %}
</caption>
<thead class="table-light">
<tr>
<th scope="col" class="text-center" width="5%"> #
</th>
<th scope="col">{% trans "Lot Group Name" %}
</th>
<th scope="col" width="15%" class="text-center">{% trans "Actions" %}
@ -26,7 +31,15 @@
</thead>
<tbody id="sortable_list">
{% for tag in lot_tags_edit %}
<tr>
<tr {% if tag.id == 1 %} class="bg-light no-sort"{% endif %}
data-lookup="{{ tag.id }}"
style="cursor: grab;" >
<td class="">
<i class="bi bi-grip-vertical" aria-hidden="true" >
<strong class="ps-2">{{ tag.order }}</strong>
</i>
</td>
<td class="font-monospace">
{{ tag.name }}
</td>
@ -44,7 +57,8 @@
<button
type="button" class="btn btn-sm btn-outline-danger d-flex align-items-center"
data-bs-toggle="modal"
data-bs-target="#deleteLotTagModal{{ tag.id }}" >
data-bs-target="#deleteLotTagModal{{ tag.id }}"
{% if tag.id == 1 %} disabled {% endif %}>
<i class="bi bi-trash me-1"></i>
{% trans 'Delete' %}
</button>
@ -55,6 +69,11 @@
</tbody>
</table>
<form id="orderingForm" method="post" action="{% url 'admin:update_lot_tag_order' %}">
{% csrf_token %}
<input type="hidden" id="orderingInput" name="ordering">
<button id="saveOrderBtn" class="btn btn-success mt-5 float-start collapse" >{% trans "Update Order" %}</button>
</form>
{% else %}
<div class="alert alert-primary text-center mt-5" role="alert">
@ -110,6 +129,9 @@
<label for="editLotTagInput{{ tag.id }}" class="form-label">{% trans "Tag" %}</label>
<input type="text" class="form-control" id="editLotTagInput{{ tag.id }}" name="name" maxlength="50" value="{{ tag.name }}" required>
<div class="form-text">{% trans "Maximum 50 characters." %}</div>
{% if tag.id == 1 %}
<p class="text-muted text-end mt-3">{% trans "INBOX can only be edited, not deleted." %}</p>
{% endif %}
</div>
</div>
@ -136,16 +158,28 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans 'Close' %}"></button>
</div>
<div class="modal-body">
{% if tag.lot_set.first %}
<div class="alert alert-warning text-center" role="alert">
{% trans "Failed to remove Lot Group, it is not empty" %}
<strong class="text-bold mb-0"> {% trans "This lot group has" %} {{tag.lot_set.count}} {% trans "lot/s." %}</strong>
</div>
{% else %}
<p class="mb-0 text-muted mt-2">{% trans "Are you sure you want to delete this lot group?" %}</p>
{% endif %}
<div class="d-flex align-items-center border rounded p-3 mt-3">
<div>
<p class="mb-0 fw-bold">{{ tag.name }}</p>
</div>
</div>
{% if tag.lot_set.first %}
<p class="mb-0 text-muted text-end mt-3">
{% trans "This lot group is not empty and therefore cannot be deleted." %}
</p>
{% endif %}
</div>
<div class="modal-footer">
@ -155,7 +189,7 @@
{% trans "Cancel" %}
</button>
{% if tag.lot_set.first %}
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" disabled>
{% trans "Delete" %}
</button>
{% else %}
@ -170,4 +204,42 @@
</div>
{% endfor %}
<script>
//following https://dev.to/nemecek_f/django-how-to-let-user-re-order-sort-table-of-content-with-drag-and-drop-3nlp
const saveOrderingButton = document.getElementById('saveOrderBtn');
const orderingForm = document.getElementById('orderingForm');
const formInput = orderingForm.querySelector('#orderingInput');
const sortable_table = document.getElementById('sortable_list');
const inbox_row = document.getElementById('inbox');
const sortable = new Sortable(sortable_table, {
animation: 150,
swapThreshold: 0.10,
filter: '.no-sort',
onChange: () => {
//TODO: change hide/show animation to a nicer one
const collapse = new bootstrap.Collapse(saveOrderingButton, {
toggle: false
});
collapse.show();
}
});
function saveOrdering() {
const rows = sortable_table.querySelectorAll('tr');
let ids = [];
for (let row of rows) {
ids.push(row.dataset.lookup);
}
formInput.value = ids.join(',');
orderingForm.submit();
}
saveOrderingButton.addEventListener('click', saveOrdering);
</script>
{% endblock %}

View file

@ -19,4 +19,5 @@ urlpatterns = [
path("lot/add", views.AddLotTagView.as_view(), name="add_lot_tag"),
path("lot/delete/<int:pk>", views.DeleteLotTagView.as_view(), name='delete_lot_tag'),
path("lot/edit/<int:pk>/", views.UpdateLotTagView.as_view(), name='edit_lot_tag'),
path("lot/update_order/", views.UpdateLotTagOrderView.as_view(), name='update_lot_tag_order'),
]

View file

@ -206,6 +206,30 @@ class UpdateLotTagView(AdminView, UpdateView):
return response
class UpdateLotTagOrderView(AdminView, TemplateView):
success_url = reverse_lazy('admin:tag_panel')
def post(self, request, *args, **kwargs):
form = OrderingStateForm(request.POST)
if form.is_valid():
ordered_ids = form.cleaned_data["ordering"].split(',')
with transaction.atomic():
#TODO: log on institution wide logging - if implemented -
current_order = 2
for lookup_id in ordered_ids:
lot_tag = LotTag.objects.get(id=lookup_id)
lot_tag.order = current_order
lot_tag.save()
current_order += 1
messages.success(self.request, _("Order changed succesfuly."))
return redirect(self.success_url)
else:
return Http404
class InstitutionView(AdminView, UpdateView):
template_name = "institution.html"
title = _("Edit institution")

View file

@ -35,7 +35,7 @@ class DashboardView(LoginRequiredMixin):
context = super().get_context_data(**kwargs)
lot_tags = LotTag.objects.filter(
owner=self.request.user.institution,
)
).order_by('order')
context.update({
"commit_id": settings.COMMIT,
'title': self.title,

View file

@ -1,4 +1,4 @@
{% load i18n static %}
{% load i18n static startswith %}
<!doctype html>
<html lang="en">
@ -80,97 +80,122 @@
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-5">
<ul class="nav flex-column">
<!-- Admin submenu-->
{% if user.is_admin %}
{% with is_path=request.path|startswith:'/admin' %}
<li class="nav-item">
<a class="admin {% if path in 'panel users states_panel tag_panel edit_user delete_user new_user institution' %}active {% endif %}nav-link fw-bold" data-bs-toggle="collapse" data-bs-target="#ul_admin" aria-expanded="false" aria-controls="ul_admin" href="javascript:void()">
<a class="admin {% if is_path %}active {% endif %}nav-link fw-bold" data-bs-toggle="collapse" data-bs-target="#ul_admin" aria-expanded="false" aria-controls="ul_admin" href="javascript:void()">
<i class="bi bi-person-fill-gear icon_sidebar"></i>
{% trans 'Admin' %}
</a>
<ul class="flex-column mb-2 ul_sidebar accordion-collapse {% if path in 'panel users tag_panel states_panel' %}expanded{% else %}collapse{% endif %}" id="ul_admin" data-bs-parent="#sidebarMenu">
<li class="nav-item">
<a class="nav-link{% if path in 'panel institution' %} active2{% endif %}" href="{% url 'admin:panel' %}">
<a class="nav-link{% if path in 'panel institution' and is_path %} active2{% endif %}" href="{% url 'admin:panel' %}">
{% trans 'Panel' %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if path in 'users edit_user new_user delete_user' %} active2{% endif %}" href="{% url 'admin:users' %}">
<a class="nav-link{% if path in 'users edit_user new_user delete_user' and is_path %} active2{% endif %}" href="{% url 'admin:users' %}">
{% trans 'Users' %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if path == 'states_panel' %} active2{% endif %}" href="{% url 'admin:states_panel' %}">
<a class="nav-link{% if path == 'states_panel' and is_path %} active2{% endif %}" href="{% url 'admin:states_panel' %}">
{% trans 'States' %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if path == 'tag_panel' %} active2{% endif %}" href="{% url 'admin:tag_panel' %}">
<a class="nav-link{% if path == 'tag_panel' and is_path %} active2{% endif %}" href="{% url 'admin:tag_panel' %}">
{% trans 'Lot Groups' %}
</a>
</li>
</ul>
</li>
{% endwith %}
{% endif %}
<!-- Device submenu-->
{% with is_path=request.path|startswith:'/dashboard' %}
<li class="nav-item">
<a class="admin {% if path in 'all_device' %}active {% endif %}nav-link fw-bold" data-bs-toggle="collapse" data-bs-target="#ul_device" aria-expanded="false" aria-controls="ul_lots" href="javascript:void()">
<a class="admin {% if is_path %}active {% endif %}nav-link fw-bold" data-bs-toggle="collapse" data-bs-target="#ul_device" aria-expanded="false" aria-controls="ul_lots" href="javascript:void()">
<i class="bi bi-laptop icon_sidebar"></i>
{% trans 'Device' %}
</a>
<ul class="flex-column mb-2 ul_sidebar accordion-collapse {% if path in 'all_device' %}expanded{% else %}collapse{% endif %}" id="ul_device" data-bs-parent="#sidebarMenu">
<li class="nav-item">
<a class="nav-link{% if path == 'all_device' %} active2{% endif %}" href="{% url 'dashboard:all_device' %}">
<a class="nav-link{% if path == 'all_device' and is_path %} active2{% endif %}" href="{% url 'dashboard:all_device' %}">
{% trans 'All' %}
</a>
</li>
</ul>
</li>
{% endwith %}
<!-- Lot submenu-->
{% with is_path=request.path|startswith:'/lot' %}
<li class="nav-item">
<a class="admin {% if path == 'tags' or path == 'lot' or path in 'unassigned dashboard' %}active {% endif %}nav-link fw-bold" data-bs-toggle="collapse" data-bs-target="#ul_lots" aria-expanded="false" aria-controls="ul_lots" href="javascript:void()">
<a class="admin {% if is_path %}active{% endif %} nav-link fw-bold" data-bs-toggle="collapse" data-bs-target="#ul_lots" aria-expanded="false" aria-controls="ul_lots" href="javascript:void()">
<i class="bi bi-database icon_sidebar"></i>
{% trans 'Lots' %}
</a>
<ul class="flex-column mb-2 ul_sidebar accordion-collapse {% if path == 'tags' or path == 'lot' or path in 'unassigned dashboard' %}expanded{% else %}collapse{% endif %}" id="ul_lots" data-bs-parent="#sidebarMenu">
<ul class="flex-column mb-2 ul_sidebar accordion-collapse {% if is_path %}expanded{% else %}collapse{% endif %}" id="ul_lots" data-bs-parent="#sidebarMenu">
<!-- If current path is lot, then add a link so user knows where it is situated on the site -->
{% if path == "lot" and lot %}
<li class="nav-item">
<a class="nav-link active2" href="{% url 'lot:lot' lot.id %}">
{% trans "Lot" %} {{ lot.name }}
</a>
</li>
{% endif %}
{% for tag in lot_tags %}
<li class="nav-items">
<li class="nav-item">
{% if tag.inbox %}
<a class="nav-link{% if path == 'unassigned' %} active2{% endif %}" href="{% url 'dashboard:unassigned' %}">
<a class="nav-link{% if path == 'unassigned' %} active2{% endif %}" href="{% url 'lot:unassigned' %}">
{% else %}
<a class="nav-link{% if path == 'tags' %} active2{% endif %}" href="{% url 'lot:tags' tag.id %}">
{% endif %}
{{ tag.name }}
{{ tag.name|truncatechars:17 }}
</a>
</li>
{% endfor %}
</ul>
</li>
{% endwith %}
<!--Evidences submenu -->
{% with is_path=request.path|startswith:'/evidence,/device/add' %}
<li class="nav-item">
<a class="admin {% if path in 'upload list import add' %}active {% endif %}nav-link fw-bold" data-bs-toggle="collapse" data-bs-target="#ul_evidences" aria-expanded="false" aria-controls="ul_evidences" href="javascript:void()">
<a class="admin {% if is_path %} active {% endif %}nav-link fw-bold" data-bs-toggle="collapse" data-bs-target="#ul_evidences" aria-expanded="false" aria-controls="ul_evidences" href="javascript:void()">
<i class="bi bi-usb-drive icon_sidebar"></i>
{% trans 'Evidences' %}
</a>
<ul class="flex-column mb-2 ul_sidebar accordion-collapse {% if path in 'upload list import add' %}expanded{% else %}collapse{% endif %}" id="ul_evidences" data-bs-parent="#sidebarMenu">
<ul class="flex-column mb-2 ul_sidebar accordion-collapse {% if is_path %}expanded{% else %}collapse{% endif %}" id="ul_evidences" data-bs-parent="#sidebarMenu">
<li class="nav-item">
<a class="nav-link{% if path == 'list' %} active2{% endif %}" href="{% url 'evidence:list' %}">
<a class="nav-link{% if path == 'list' and is_path %} active2{% endif %}" href="{% url 'evidence:list' %}">
{% trans 'List of evidences' %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if path == 'upload' %} active2{% endif %}" href="{% url 'evidence:upload' %}">
<a class="nav-link{% if path == 'upload' and is_path %} active2{% endif %}" href="{% url 'evidence:upload' %}">
{% trans 'Upload with JSON file' %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if path == 'import' %} active2{% endif %}" href="{% url 'evidence:import' %}">
<a class="nav-link{% if path == 'import' and is_path%} active2{% endif %}" href="{% url 'evidence:import' %}">
{% trans 'Upload with Spreadsheet' %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if path == 'add' %} active2{% endif %}" href="{% url 'device:add' %}">
<a class="nav-link{% if path == 'add' and is_path %} active2{% endif %}" href="{% url 'device:add' %}">
{% trans 'Upload with Web Form' %}
</a>
</li>
</ul>
</li>
{% endwith %}
</ul>
</div>
</nav>
@ -214,8 +239,14 @@
</div>
</div>
<div class="d-flex flex-wrap gap-2 justify-content-end m-2 mb-4">
{% block actions %}
{% endblock %}
</div>
<div class= "mx-2">
{% block content %}
{% endblock content %}
</div>
</main>
</div>

View file

@ -0,0 +1,9 @@
#https://medium.com/@malvin.lok/add-a-custom-function-startwith-on-the-django-template-f11e1916f0d1
from django import template
register = template.Library()
@register.filter('startswith')
def startswith(value, prefixes):
return any(value.startswith(prefix) for prefix in prefixes.split(','))

View file

@ -4,8 +4,6 @@ from dashboard import views
app_name = 'dashboard'
urlpatterns = [
path("", views.UnassignedDevicesView.as_view(), name="unassigned"),
path("all", views.AllDevicesView.as_view(), name="all_device"),
path("<int:pk>/", views.LotDashboardView.as_view(), name="lot"),
path("search", views.SearchView.as_view(), name="search"),
]

View file

@ -12,16 +12,6 @@ from device.models import Device
from lot.models import Lot
class UnassignedDevicesView(InventaryMixin):
template_name = "unassigned_devices.html"
section = "Unassigned"
title = _("Unassigned Devices")
breadcrumb = "Devices / Unassigned Devices"
def get_devices(self, user, offset, limit):
return Device.get_unassigned(self.request.user.institution, offset, limit)
class AllDevicesView(InventaryMixin):
template_name = "unassigned_devices.html"
section = "All"
@ -32,40 +22,6 @@ class AllDevicesView(InventaryMixin):
return Device.get_all(self.request.user.institution, offset, limit)
class LotDashboardView(InventaryMixin, DetailsMixin):
template_name = "unassigned_devices.html"
section = "dashboard_lot"
title = _("Lot Devices")
breadcrumb = "Lot / Devices"
model = Lot
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
lot = context.get('object')
context.update({
'lot': lot,
})
return context
def get_devices(self, user, offset, limit):
chids = self.object.devicelot_set.all().values_list(
"device_id", flat=True
).distinct()
props = SystemProperty.objects.filter(
owner=self.request.user.institution,
value__in=chids
).order_by("-created")
chids_ordered = []
for x in props:
if x.value not in chids_ordered:
chids_ordered.append(x.value)
chids_page = chids_ordered[offset:offset+limit]
return [Device(id=x) for x in chids_page], len(chids_ordered)
class SearchView(InventaryMixin):
template_name = "unassigned_devices.html"
section = "Search"

View file

@ -78,7 +78,7 @@
{% endfor %}
</div>
<div class="container">
<a class="btn btn-grey" href="{% url 'dashboard:unassigned' %}">{% translate "Cancel" %}</a>
<a class="btn btn-grey" href="{% url 'lot:unassigned' %}">{% translate "Cancel" %}</a>
<input class="btn btn-green-admin" type="submit" name="submit" value="{% translate 'Save' %}" />
</div>

View file

@ -9,7 +9,7 @@
{% if lot.type == tag %}
<div class="row mb-3">
<div class="col">
<a href="{% url 'dashboard:lot' lot.id %}">{{ lot.name }}
<a href="{% url 'lot:lot' lot.id %}">{{ lot.name }}
</a>
</div>
</div>

View file

@ -40,7 +40,7 @@ class NewDeviceView(DashboardView, FormView):
template_name = "new_device.html"
title = _("New Device")
breadcrumb = "Device / New Device"
success_url = reverse_lazy('dashboard:unassigned')
success_url = reverse_lazy('lot:unassigned')
form_class = DeviceFormSet
def form_valid(self, form):
@ -57,7 +57,7 @@ class EditDeviceView(DashboardView, UpdateView):
template_name = "new_device.html"
title = _("Update Device")
breadcrumb = "Device / Update Device"
success_url = reverse_lazy('dashboard:unassigned_devices')
success_url = reverse_lazy('lot:unassigned_devices')
model = SystemProperty
def get_form_kwargs(self):
@ -74,7 +74,6 @@ class EditDeviceView(DashboardView, UpdateView):
class DetailsView(DashboardView, TemplateView):
template_name = "details.html"
title = _("Device")
breadcrumb = "Device / Details"
model = SystemProperty
@ -113,6 +112,7 @@ class DetailsView(DashboardView, TemplateView):
device_notes = Note.objects.filter(snapshot_uuid__in=uuids).order_by('-date')
context.update({
'object': self.object,
'title': _("Device {}".format(self.object.shortid)),
'snapshot': last_evidence,
'lot_tags': lot_tags,
'dpps': dpps,

View file

@ -22,7 +22,7 @@
{% bootstrap_form form alert_error_type="none" error_css_class="alert alert-danger alert-icon alert-icon-border" %}
<div class="form-actions-no-box">
<a class="btn btn-grey" href="{% url 'dashboard:unassigned' %}">{% translate "Cancel" %}</a>
<a class="btn btn-grey" href="{% url 'lot:unassigned' %}">{% translate "Cancel" %}</a>
<input class="btn btn-green-admin" type="submit" name="submit" value="{% translate 'Save' %}" />
</div>

View file

@ -17,17 +17,17 @@ class LoginView(auth_views.LoginView):
template_name = 'login.html'
extra_context = {
'title': _('Login'),
'success_url': reverse_lazy('dashboard:unassigned'),
'success_url': reverse_lazy('lot:unassigned'),
'commit_id': settings.COMMIT,
}
def get(self, request, *args, **kwargs):
self.extra_context['success_url'] = request.GET.get(
'next',
reverse_lazy('dashboard:unassigned')
reverse_lazy('lot:unassigned')
)
if not self.request.user.is_anonymous:
return redirect(reverse_lazy('dashboard:unassigned'))
return redirect(reverse_lazy('lot:unassigned'))
return super().get(request, *args, **kwargs)

View file

@ -0,0 +1,22 @@
# Generated by Django 5.0.6 on 2025-02-21 20:58
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("lot", "0007_lottag_inbox"),
("user", "0002_institution_algorithm"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddConstraint(
model_name="lot",
constraint=models.UniqueConstraint(
fields=("owner", "name"), name="unique_institution_and_name"
),
),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 5.0.6 on 2025-02-28 16:57
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("lot", "0008_lot_unique_institution_and_name"),
("user", "0002_institution_algorithm"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveConstraint(
model_name="lot",
name="unique_institution_and_name",
),
migrations.AddField(
model_name="lottag",
name="order",
field=models.PositiveIntegerField(default=0),
),
migrations.AddConstraint(
model_name="lot",
constraint=models.UniqueConstraint(
fields=("owner", "name", "type"), name="unique_institution_and_name"
),
),
]

View file

@ -1,4 +1,5 @@
from django.db import models
from django.db.models import Max
from django.utils.translation import gettext_lazy as _
from utils.constants import (
STR_SM_SIZE,
@ -16,11 +17,28 @@ class LotTag(models.Model):
owner = models.ForeignKey(Institution, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
inbox = models.BooleanField(default=False)
order = models.PositiveIntegerField(default=0)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.pk:
# set the order to be last
max_order = LotTag.objects.filter(owner=self.owner).aggregate(Max('order'))['order__max']
self.order = (max_order or 0) + 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
institution = self.owner
order = self.order
super().delete(*args, **kwargs)
# Adjust the order of other instances
LotTag.objects.filter(owner=institution, order__gt=order).update(order=models.F('order') - 1)
class DeviceLot(models.Model):
lot = models.ForeignKey("Lot", on_delete=models.CASCADE)
device_id = models.CharField(max_length=STR_EXTEND_SIZE, blank=False, null=False)
@ -37,6 +55,12 @@ class Lot(models.Model):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
type = models.ForeignKey(LotTag, on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner', 'name', 'type'], name='unique_institution_and_name')
]
def add(self, v):
if DeviceLot.objects.filter(lot=self, device_id=v).exists():
return
@ -46,6 +70,11 @@ class Lot(models.Model):
for d in DeviceLot.objects.filter(lot=self, device_id=v):
d.delete()
@property
def devices(self):
return DeviceLot.objects.filter(lot=self)
class LotProperty(Property):
lot = models.ForeignKey(Lot, on_delete=models.CASCADE)

View file

@ -1,5 +1,6 @@
{% extends "base.html" %}
{% load i18n %}
{% load django_bootstrap5 %}
{% block content %}
<div class="row">
@ -8,13 +9,34 @@
</div>
</div>
{% load django_bootstrap5 %}
<div class="row mb-3">
<div class="col">
Are you sure than want remove the lot {{ object.name }} with {{ object.devices.count }} devices.
</div>
<div class="card border{% if object.devices.count > 0 %}border-danger{% endif %}">
<div class="card-body">
<h5 class="card-title">{% trans "Delete Lot" %}</h5>
<div class="card-text mt-4">
{% blocktranslate with name=object.name count devices=object.devices.count %}
Are you sure you want to remove the lot "{{ name }}" with {{ devices }} device?
{% plural %}
Are you sure you want to remove the lot "{{ name }}" with {{ devices }} devices?
{% endblocktranslate %}
</div>
{% if object.devices.count > 0 %}
<div class="mt-3 text-danger">
<i class="bi bi-exclamation-circle-fill"></i>
{% trans "All associated devices will be deassigned." %}
</div>
{% else %}
<div class="mt-3 text-muted">
<i class="bi bi-info-circle-fill"></i>
{% trans "No devices are associated with this lot." %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<form role="form" method="post">
{% csrf_token %}
{% if form.errors %}
@ -30,8 +52,8 @@
{% endif %}
{% bootstrap_form form %}
<div class="form-actions-no-box">
<a class="btn btn-grey" href="{% url 'dashboard:unassigned' %}">{% translate "Cancel" %}</a>
<input class="btn btn-green-admin" type="submit" name="submit" value="{% translate 'Delete' %}" />
<a class="btn btn-grey" href="{{ request.META.HTTP_REFERER }}">{% translate "Cancel" %}</a>
<input class="btn btn-danger" type="submit" name="submit" value="{% translate 'Delete' %}"/>
</div>
</form>

View file

@ -0,0 +1,10 @@
{% load i18n %}
<a href="{% url 'lot:edit' record.id %}" class="btn btn-sm btn-outline-primary me-2">
<i class="bi bi-pen"></i>
{% trans 'Edit' %}
</a>
<a href="{% url 'lot:delete' record.id %}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
{% trans 'Delete' %}
</a>

View file

@ -1,43 +1,72 @@
{% extends "base.html" %}
{% load i18n %}
{% load i18n paginacion %}
{% load render_table from django_tables2 %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h3>{{ subtitle }}</h3>
</div>
<div class="col text-center">
{% if show_closed %}
<a href="?show_closed=false" class="btn btn-green-admin">
{% trans 'Hide closed lots' %}
</a>
{% else %}
<a href="?show_closed=true" class="btn btn-green-admin">
{% trans 'Show closed lots' %}
</a>
{% endif %}
<a href="{% url 'lot:add' %}" type="button" class="btn btn-green-admin">
<a href="{% url 'lot:add' %}" type="button" class="btn btn-green-admin mb-3">
<i class="bi bi-plus"></i>
{% trans 'Add new lot' %}
</a>
<h3>{{ subtitle }}</h3>
<!-- Search and Filter Section -->
<div class="row mb-4">
<!-- Search Input -->
<div class="col-md-6 mb-3">
<form method="get" class="d-flex">
<input
type="text"
name="q"
class="form-control me-2"
placeholder="{% trans 'Search by name or description...' %}"
value="{{ search_query }}">
<button type="submit" class="btn btn-outline-secondary">
<i class="bi bi-search"></i>
</button>
</form>
</div>
<div class="col-md-6 text-md-end">
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="filterDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-filter me-2"></i>
{% trans 'Filter' %}
</button>
<ul class="dropdown-menu" aria-labelledby="filterDropdown">
<li>
<a class="dropdown-item" href="?{% if search_query %}q={{ search_query }}&{% endif %}show_closed=false">
{% trans 'Open Lots' %}
{% if show_closed == 'false' %}<i class="bi bi-check"></i>{% endif %}
</a>
</li>
<li>
<a class="dropdown-item" href="?{% if search_query %}q={{ search_query }}&{% endif %}show_closed=true">
{% trans 'Closed Lots' %}
{% if show_closed == 'true' %}<i class="bi bi-check"></i>{% endif %}
</a>
</li>
<li>
<a class="dropdown-item" href="?{% if search_query %}q={{ search_query }}&{% endif %}show_closed=both">
{% trans 'All Lots' %}
{% if show_closed == 'both' %}<i class="bi bi-check"></i>{% endif %}
</a>
</li>
</ul>
</div>
</div>
<div class="row">
<table class= "table table-striped table-sm">
{% for lot in lots %}
<tr>
<td><a href="{% url 'dashboard:lot' lot.id %}">{{ lot.name }}</a></td>
<td>
<a href="{% url 'lot:edit' lot.id %}"><i class="bi bi-pen"></i></a>
</td>
<td>
<a href="{% url 'lot:delete' lot.id %}"><i class="bi bi-trash text-danger"></i></a>
</td>
</tr>
{% endfor %}
</table>
<!-- Lots list -->
{% render_table table %}
<div class="mt-5 d-flex align-items-center justify-content-center">
{% if table.page and table.paginator.num_pages > 1 %}
{% include "django_tables2/pagination.html" %}
{% endif %}
</div>
{% endblock %}

View file

@ -24,7 +24,8 @@
{% endif %}
{% bootstrap_form form %}
<div class="form-actions-no-box">
<a class="btn btn-grey" href="{% url 'dashboard:unassigned' %}">{% translate "Cancel" %}</a>
<a class="btn btn-grey" href="{{ request.META.HTTP_REFERER }}">{% translate "Cancel" %}</a>
<input class="btn btn-green-admin" type="submit" name="submit" value="{% translate 'Save' %}" />
</div>

View file

@ -4,6 +4,8 @@ from lot import views
app_name = 'lot'
urlpatterns = [
path("unasigned", views.UnassignedDevicesView.as_view(), name="unassigned"),
path("<int:pk>/", views.LotView.as_view(), name="lot"),
path("add/", views.NewLotView.as_view(), name="add"),
path("delete/<int:pk>/", views.DeleteLotView.as_view(), name="delete"),
path("edit/<int:pk>/", views.EditLotView.as_view(), name="edit"),

View file

@ -2,23 +2,45 @@ from django.db import IntegrityError
from django.urls import reverse_lazy
from django.shortcuts import get_object_or_404, redirect, Http404
from django.contrib import messages
from dashboard.mixins import InventaryMixin, DetailsMixin
from django.utils.translation import gettext_lazy as _
from django.utils.safestring import mark_safe
from django.views.generic.base import TemplateView
from django.db.models import Q
from django.views.generic.edit import (
CreateView,
DeleteView,
UpdateView,
FormView,
)
import django_tables2 as tables
from dashboard.mixins import DashboardView
from evidence.models import SystemProperty
from device.models import Device
from lot.models import Lot, LotTag, LotProperty
from lot.forms import LotsForm
class NewLotView(DashboardView, CreateView):
class LotSuccessUrlMixin():
success_url = reverse_lazy('lot:unassigned') #default_url
def get_success_url(self):
lot_group_id = LotTag.objects.only('id').get(
owner=self.object.owner,
name=self.object.type
).id
#null checking just in case
if not lot_group_id:
return self.success_url
return reverse_lazy('lot:tags', args=[lot_group_id])
class NewLotView(LotSuccessUrlMixin ,DashboardView, CreateView):
template_name = "new_lot.html"
title = _("New lot")
breadcrumb = "lot / New lot"
success_url = reverse_lazy('dashboard:unassigned')
model = Lot
fields = (
"type",
@ -37,17 +59,24 @@ class NewLotView(DashboardView, CreateView):
return form
def form_valid(self, form):
try:
form.instance.owner = self.request.user.institution
form.instance.user = self.request.user
response = super().form_valid(form)
messages.success(self.request, _("Lot created successfully."))
return response
except IntegrityError:
messages.error(self.request, _("Lot name is already defined."))
return self.form_invalid(form)
class DeleteLotView(DashboardView, DeleteView):
return response
class DeleteLotView(LotSuccessUrlMixin, DashboardView, DeleteView):
template_name = "delete_lot.html"
title = _("Delete lot")
breadcrumb = "lot / Delete lot"
success_url = reverse_lazy('dashboard:unassigned')
model = Lot
fields = (
"type",
@ -59,14 +88,20 @@ class DeleteLotView(DashboardView, DeleteView):
def form_valid(self, form):
response = super().form_valid(form)
messages.warning(self.request, _("Lot '{}' was successfully deleted.").format(self.object.name))
return response
def form_invalid(self, form):
response = super().form_invalid(form)
messages.error(self.request, _("Error deleting the lot."))
return response
class EditLotView(DashboardView, UpdateView):
class EditLotView(LotSuccessUrlMixin, DashboardView, UpdateView):
template_name = "new_lot.html"
title = _("Edit lot")
breadcrumb = "Lot / Edit lot"
success_url = reverse_lazy('dashboard:unassigned')
model = Lot
fields = (
"type",
@ -95,12 +130,22 @@ class EditLotView(DashboardView, UpdateView):
)
return form
def form_valid(self, form):
response = super().form_valid(form)
messages.warning(self.request, _("Lot '{}' was successfully edited.").format(self.object.name))
return response
def form_invalid(self, form):
response = super().form_invalid(form)
messages.error(self.request, _("Error editing the lot."))
return response
class AddToLotView(DashboardView, FormView):
template_name = "list_lots.html"
title = _("Add to lots")
breadcrumb = "lot / add to lots"
success_url = reverse_lazy('dashboard:unassigned')
success_url = reverse_lazy('lot:unassigned')
form_class = LotsForm
def get_context_data(self, **kwargs):
@ -137,31 +182,79 @@ class DelToLotView(AddToLotView):
return response
class LotsTagsView(DashboardView, TemplateView):
class LotTable(tables.Table):
name = tables.Column(linkify=("lot:lot", {"pk": tables.A("id")}), verbose_name=_("Lot Name"), attrs={"td": {"class": "fw-bold"}})
description = tables.Column(verbose_name=_("Description"), default=_("No description"),attrs={"td": {"class": "text-muted"}} )
closed = tables.Column(verbose_name=_("Status"))
created = tables.DateColumn(format="Y-m-d", verbose_name=_("Created On"))
user = tables.Column(verbose_name=("Created By"), default=_("Unknown"), attrs={"td": {"class": "text-muted"}} )
actions = tables.TemplateColumn(
template_name="lot_actions.html",
verbose_name=_(""),
attrs={"td": {"class": "text-end"}}
)
def render_closed(self, value):
if value:
return mark_safe('<span class="badge bg-danger">Closed</span>')
return mark_safe('<span class="badge bg-success">Open</span>')
class Meta:
model = Lot
fields = ("closed", "name", "description", "created", "user", "actions")
attrs = {
"class": "table table-hover align-middle",
"thead": {"class": "table-light"}
}
order_by = ("-created",)
class LotsTagsView(DashboardView, tables.SingleTableView):
template_name = "lots.html"
title = _("lots")
title = _("Lot group")
breadcrumb = _("lots") + " /"
success_url = reverse_lazy('dashboard:unassigned')
success_url = reverse_lazy('lot:unassigned')
model = Lot
table_class = LotTable
def get_queryset(self):
self.pk = self.kwargs.get('pk')
self.tag = get_object_or_404(LotTag, owner=self.request.user.institution, id=self.pk)
self.show_open = self.request.GET.get('show_open', 'false') == 'true'
self.show_closed = self.request.GET.get('show_closed', 'false')
self.search_query = self.request.GET.get('q', '').strip()
queryset = Lot.objects.filter(owner=self.request.user.institution, type=self.tag)
if self.show_closed == 'true':
queryset = queryset.filter(closed=True)
elif self.show_closed == 'false':
queryset = queryset.filter(closed=False)
if self.search_query:
queryset = queryset.filter(
Q(name__icontains=self.search_query) |
Q(description__icontains=self.search_query) |
Q(code__icontains=self.search_query)
)
sort = self.request.GET.get('sort')
if sort:
queryset = queryset.order_by(sort)
return queryset
def get_context_data(self, **kwargs):
self.pk = kwargs.get('pk')
context = super().get_context_data(**kwargs)
tag = get_object_or_404(LotTag, owner=self.request.user.institution, id=self.pk)
self.title += " {}".format(tag.name)
self.breadcrumb += " {}".format(tag.name)
show_closed = self.request.GET.get('show_closed', 'false') == 'true'
lots = Lot.objects.filter(owner=self.request.user.institution).filter(
type=tag, closed=show_closed
)
context.update({
'lots': lots,
'title': self.title,
'breadcrumb': self.breadcrumb,
'show_closed': show_closed
'title': self.title + " " + self.tag.name,
'breadcrumb': self.breadcrumb + " " + self.tag.name,
'show_closed': self.show_closed,
'search_query': self.search_query,
})
return context
class LotPropertiesView(DashboardView, TemplateView):
template_name = "properties.html"
title = _("New Lot Property")
@ -189,7 +282,7 @@ class AddLotPropertyView(DashboardView, CreateView):
template_name = "new_property.html"
title = _("New Lot Property")
breadcrumb = "Device / New property"
success_url = reverse_lazy('dashboard:unassigned_devices')
success_url = reverse_lazy('lot:unassigned_devices')
model = LotProperty
fields = ("key", "value")
@ -274,3 +367,46 @@ class DeleteLotPropertyView(DashboardView, DeleteView):
# Redirect back to the original URL
return redirect(self.success_url)
class UnassignedDevicesView(InventaryMixin):
template_name = "unassigned_devices.html"
section = "Unassigned"
title = _("Unassigned Devices")
breadcrumb = "Devices / Unassigned Devices"
def get_devices(self, user, offset, limit):
return Device.get_unassigned(self.request.user.institution, offset, limit)
class LotView(InventaryMixin, DetailsMixin):
template_name = "unassigned_devices.html"
section = "dashboard_lot"
breadcrumb = "Lot / Devices"
model = Lot
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
lot = context.get('object')
context.update({
'lot': lot,
'title': _("Lot {}".format(lot.name))
})
return context
def get_devices(self, user, offset, limit):
chids = self.object.devicelot_set.all().values_list(
"device_id", flat=True
).distinct()
props = SystemProperty.objects.filter(
owner=self.request.user.institution,
value__in=chids
).order_by("-created")
chids_ordered = []
for x in props:
if x.value not in chids_ordered:
chids_ordered.append(x.value)
chids_page = chids_ordered[offset:offset+limit]
return [Device(id=x) for x in chids_page], len(chids_ordered)