diff --git a/CHANGELOG.md b/CHANGELOG.md index 542b5491..bf4a03f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ ml). ## master ## testing +- [added] #219 Add functionality to searchbar (Lots and devices). - [changed] #211 Print DHID-QR label for selected devices. +- [changed] #218 Add reactivity to device lots. - [fixed] #214 Login workflow ## [2.0.0] - 2022-03-15 diff --git a/development-setup.md b/development-setup.md index c900574a..eb0fd221 100755 --- a/development-setup.md +++ b/development-setup.md @@ -30,11 +30,6 @@ Create a demo table export dhi=dbtest; dh dummy ``` -copy `examples/app.py` to project directory: -```bash -copy examples/app.py . -``` - ## Run project Run the app diff --git a/ereuse_devicehub/inventory/forms.py b/ereuse_devicehub/inventory/forms.py index e594e50d..c2ca71cd 100644 --- a/ereuse_devicehub/inventory/forms.py +++ b/ereuse_devicehub/inventory/forms.py @@ -103,62 +103,6 @@ class FilterForm(FlaskForm): return ['Desktop', 'Laptop', 'Server', 'Computer'] -class LotDeviceForm(FlaskForm): - lot = StringField('Lot', [validators.UUID()]) - devices = StringField('Devices', [validators.length(min=1)]) - - def validate(self, extra_validators=None): - is_valid = super().validate(extra_validators) - - if not is_valid: - return False - - self._lot = ( - Lot.query.outerjoin(Trade) - .filter(Lot.id == self.lot.data) - .filter( - or_( - Trade.user_from == g.user, - Trade.user_to == g.user, - Lot.owner_id == g.user.id, - ) - ) - .one() - ) - - devices = set(self.devices.data.split(",")) - self._devices = ( - Device.query.filter(Device.id.in_(devices)) - .filter(Device.owner_id == g.user.id) - .distinct() - .all() - ) - - return bool(self._devices) - - def save(self, commit=True): - trade = self._lot.trade - if trade: - for dev in self._devices: - if trade not in dev.actions: - trade.devices.add(dev) - - if self._devices: - self._lot.devices.update(self._devices) - db.session.add(self._lot) - - if commit: - db.session.commit() - - def remove(self, commit=True): - if self._devices: - self._lot.devices.difference_update(self._devices) - db.session.add(self._lot) - - if commit: - db.session.commit() - - class LotForm(FlaskForm): name = StringField('Name', [validators.length(min=1)]) diff --git a/ereuse_devicehub/inventory/views.py b/ereuse_devicehub/inventory/views.py index 339eea2b..ac91ac44 100644 --- a/ereuse_devicehub/inventory/views.py +++ b/ereuse_devicehub/inventory/views.py @@ -16,7 +16,6 @@ from ereuse_devicehub.inventory.forms import ( AllocateForm, DataWipeForm, FilterForm, - LotDeviceForm, LotForm, NewActionForm, NewDeviceForm, @@ -109,7 +108,6 @@ class DeviceListMix(GenericMixView): self.context = { 'devices': devices, 'lots': lots, - 'form_lot_device': LotDeviceForm(), 'form_tag_device': TagDeviceForm(), 'form_new_action': form_new_action, 'form_new_allocate': form_new_allocate, @@ -153,46 +151,6 @@ class DeviceDetailView(GenericMixView): return flask.render_template(self.template_name, **context) -class LotDeviceAddView(View): - methods = ['POST'] - decorators = [login_required] - template_name = 'inventory/device_list.html' - - def dispatch_request(self): - form = LotDeviceForm() - if form.validate_on_submit(): - form.save(commit=False) - messages.success( - 'Add devices to lot "{}" successfully!'.format(form._lot.name) - ) - db.session.commit() - else: - messages.error('Error adding devices to lot!') - - next_url = request.referrer or url_for('inventory.devicelist') - return flask.redirect(next_url) - - -class LotDeviceDeleteView(View): - methods = ['POST'] - decorators = [login_required] - template_name = 'inventory/device_list.html' - - def dispatch_request(self): - form = LotDeviceForm() - if form.validate_on_submit(): - form.remove(commit=False) - messages.success( - 'Remove devices from lot "{}" successfully!'.format(form._lot.name) - ) - db.session.commit() - else: - messages.error('Error removing devices from lot!') - - next_url = request.referrer or url_for('inventory.devicelist') - return flask.redirect(next_url) - - class LotCreateView(GenericMixView): methods = ['GET', 'POST'] decorators = [login_required] @@ -608,12 +566,6 @@ devices.add_url_rule( devices.add_url_rule( '/lot//device/', view_func=DeviceListView.as_view('lotdevicelist') ) -devices.add_url_rule( - '/lot/devices/add/', view_func=LotDeviceAddView.as_view('lot_devices_add') -) -devices.add_url_rule( - '/lot/devices/del/', view_func=LotDeviceDeleteView.as_view('lot_devices_del') -) devices.add_url_rule('/lot/add/', view_func=LotCreateView.as_view('lot_add')) devices.add_url_rule( '/lot//del/', view_func=LotDeleteView.as_view('lot_del') diff --git a/ereuse_devicehub/static/js/api.js b/ereuse_devicehub/static/js/api.js new file mode 100644 index 00000000..ee98a08f --- /dev/null +++ b/ereuse_devicehub/static/js/api.js @@ -0,0 +1,76 @@ +const Api = { + /** + * get lots id + * @returns get lots + */ + async get_lots() { + var request = await this.doRequest(API_URLS.lots, "GET", null); + if (request != undefined) return request.items; + throw request; + }, + + /** + * Get filtered devices info + * @param {number[]} ids devices ids + * @returns full detailed device list + */ + async get_devices(ids) { + var request = await this.doRequest(API_URLS.devices + '?filter={"id": [' + ids.toString() + ']}', "GET", null); + if (request != undefined) return request.items; + throw request; + }, + + /** + * Get filtered devices info + * @param {number[]} ids devices ids + * @returns full detailed device list + */ + async search_device(id) { + var request = await this.doRequest(API_URLS.devices + '?filter={"devicehub_id": ["' + id + '"]}', "GET", null) + if (request != undefined) return request.items + throw request + }, + + /** + * Add devices to lot + * @param {number} lotID lot id + * @param {number[]} listDevices list devices id + */ + async devices_add(lotID, listDevices) { + var queryURL = API_URLS.devices_modify.replace("UUID", lotID) + "?" + listDevices.map(deviceID => "id=" + deviceID).join("&"); + return await Api.doRequest(queryURL, "POST", null); + }, + + /** + * Remove devices from a lot + * @param {number} lotID lot id + * @param {number[]} listDevices list devices id + */ + async devices_remove(lotID, listDevices) { + var queryURL = API_URLS.devices_modify.replace("UUID", lotID) + "?" + listDevices.map(deviceID => "id=" + deviceID).join("&"); + return await Api.doRequest(queryURL, "DELETE", null); + }, + + /** + * + * @param {string} url URL to be requested + * @param {String} type Action type + * @param {String | Object} body body content + * @returns + */ + async doRequest(url, type, body) { + var result; + try { + result = await $.ajax({ + url: url, + type: type, + headers: { "Authorization": API_URLS.Auth_Token }, + body: body + }); + return result; + } catch (error) { + console.error(error); + throw error; + } + } +} \ No newline at end of file diff --git a/ereuse_devicehub/static/js/main.js b/ereuse_devicehub/static/js/main.js index 695378db..5eaec3ea 100644 --- a/ereuse_devicehub/static/js/main.js +++ b/ereuse_devicehub/static/js/main.js @@ -4,7 +4,7 @@ * Author: BootstrapMade.com * License: https://bootstrapmade.com/license/ */ -(function() { +(function () { "use strict"; /** @@ -41,7 +41,7 @@ * Sidebar toggle */ if (select('.toggle-sidebar-btn')) { - on('click', '.toggle-sidebar-btn', function(e) { + on('click', '.toggle-sidebar-btn', function (e) { select('body').classList.toggle('toggle-sidebar') }) } @@ -50,7 +50,7 @@ * Search bar toggle */ if (select('.search-bar-toggle')) { - on('click', '.search-bar-toggle', function(e) { + on('click', '.search-bar-toggle', function (e) { select('.search-bar').classList.toggle('search-bar-show') }) } @@ -111,7 +111,7 @@ * Initiate tooltips */ var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) - var tooltipList = tooltipTriggerList.map(function(tooltipTriggerEl) { + var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl) }) @@ -141,31 +141,31 @@ }], ["bold", "italic", "underline", "strike"], [{ - color: [] - }, - { - background: [] - } + color: [] + }, + { + background: [] + } ], [{ - script: "super" - }, - { - script: "sub" - } + script: "super" + }, + { + script: "sub" + } ], [{ - list: "ordered" - }, - { - list: "bullet" - }, - { - indent: "-1" - }, - { - indent: "+1" - } + list: "ordered" + }, + { + list: "bullet" + }, + { + indent: "-1" + }, + { + indent: "+1" + } ], ["direction", { align: [] @@ -184,8 +184,8 @@ var needsValidation = document.querySelectorAll('.needs-validation') Array.prototype.slice.call(needsValidation) - .forEach(function(form) { - form.addEventListener('submit', function(event) { + .forEach(function (form) { + form.addEventListener('submit', function (event) { if (!form.checkValidity()) { event.preventDefault() event.stopPropagation() @@ -209,7 +209,7 @@ const mainContainer = select('#main'); if (mainContainer) { setTimeout(() => { - new ResizeObserver(function() { + new ResizeObserver(function () { select('.echart', true).forEach(getEchart => { echarts.getInstanceByDom(getEchart).resize(); }) @@ -217,4 +217,167 @@ }, 200); } + /** + * Select all functionality + */ + var btnSelectAll = document.getElementById("SelectAllBTN"); + var tableListCheckboxes = document.querySelectorAll(".deviceSelect"); + + function itemListCheckChanged(event) { + let isAllChecked = Array.from(tableListCheckboxes).map(itm => itm.checked); + if (isAllChecked.every(bool => bool == true)) { + btnSelectAll.checked = true; + btnSelectAll.indeterminate = false; + } else if (isAllChecked.every(bool => bool == false)) { + btnSelectAll.checked = false; + btnSelectAll.indeterminate = false; + } else { + btnSelectAll.indeterminate = true; + } + } + + tableListCheckboxes.forEach(item => { + item.addEventListener("click", itemListCheckChanged); + }) + + btnSelectAll.addEventListener("click", event => { + let checkedState = event.target.checked; + tableListCheckboxes.forEach(ckeckbox => ckeckbox.checked = checkedState); + }) + + /** + * Avoid hide dropdown when user clicked inside + */ + document.getElementById("dropDownLotsSelector").addEventListener("click", event => { + event.stopPropagation(); + }) + + /** + * Search form functionality + */ + window.addEventListener("DOMContentLoaded", () => { + var searchForm = document.getElementById("SearchForm") + var inputSearch = document.querySelector("#SearchForm > input") + var doSearch = true + + searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + }) + + let timeoutHandler = setTimeout(() => { }, 1) + let dropdownList = document.getElementById("dropdown-search-list") + let defaultEmptySearch = document.getElementById("dropdown-search-list").innerHTML + + + inputSearch.addEventListener("input", (e) => { + clearTimeout(timeoutHandler) + let searchText = e.target.value + if (searchText == '') { + document.getElementById("dropdown-search-list").innerHTML = defaultEmptySearch; + return + } + + let resultCount = 0; + function searchCompleted() { + resultCount++; + setTimeout(() => { + if (resultCount == 2 && document.getElementById("dropdown-search-list").children.length == 2) { + document.getElementById("dropdown-search-list").innerHTML = ` + ` + } + }, 100) + } + + timeoutHandler = setTimeout(async () => { + dropdownList.innerHTML = ` + + `; + + + try { + Api.search_device(searchText.toUpperCase()).then(devices => { + dropdownList.querySelector("#deviceSearchLoader").style = "display: none" + + for (let i = 0; i < devices.length; i++) { + const device = devices[i]; + + // See: ereuse_devicehub/resources/device/models.py + var verboseName = `${device.type} ${device.manufacturer} ${device.model}` + + const templateString = ` +
  • + + + ${verboseName} + ${device.devicehubID} + +
  • `; + dropdownList.innerHTML += templateString + if (i == 4) { // Limit to 4 resullts + break; + } + } + + searchCompleted(); + }) + } catch (error) { + dropdownList.innerHTML += ` + `; + console.log(error); + } + + try { + Api.get_lots().then(lots => { + dropdownList.querySelector("#lotSearchLoader").style = "display: none" + for (let i = 0; i < lots.length; i++) { + const lot = lots[i]; + if (lot.name.toUpperCase().includes(searchText.toUpperCase())) { + const templateString = ` +
  • + + + ${lot.name} + +
  • `; + dropdownList.innerHTML += templateString + if (i == 4) { // Limit to 4 resullts + break; + } + } + } + searchCompleted(); + }) + + } catch (error) { + dropdownList.innerHTML += ` + `; + console.log(error); + } + }, 1000) + }) + }) + })(); diff --git a/ereuse_devicehub/static/js/main_inventory.js b/ereuse_devicehub/static/js/main_inventory.js index 86c25a82..bf2fdf43 100644 --- a/ereuse_devicehub/static/js/main_inventory.js +++ b/ereuse_devicehub/static/js/main_inventory.js @@ -180,3 +180,205 @@ function export_file(type_file) { $("#exportAlertModal").click(); } } + + +/** + * Reactive lots button + */ +async function processSelectedDevices() { + class Actions { + + constructor() { + this.list = []; // list of petitions of requests @item --> {type: ["Remove" | "Add"], "LotID": string, "devices": number[]} + } + + /** + * Manage the actions that will be performed when applying the changes + * @param {*} ev event (Should be a checkbox type) + * @param {string} lotID lot id + * @param {number} deviceID device id + */ + manage(event, lotID, deviceListID) { + event.preventDefault(); + const indeterminate = event.srcElement.indeterminate; + const checked = !event.srcElement.checked; + + var found = this.list.filter(list => list.lotID == lotID)[0]; + var foundIndex = found != undefined ? this.list.findLastIndex(x => x.lotID == found.lotID) : -1; + + if (checked) { + if (found != undefined && found.type == "Remove") { + if (found.isFromIndeterminate == true) { + found.type = "Add"; + this.list[foundIndex] = found; + } else { + this.list = this.list.filter(list => list.lotID != lotID); + } + } else { + this.list.push({ type: "Add", lotID: lotID, devices: deviceListID, isFromIndeterminate: indeterminate }); + } + } else { + if (found != undefined && found.type == "Add") { + if (found.isFromIndeterminate == true) { + found.type = "Remove"; + this.list[foundIndex] = found; + } else { + this.list = this.list.filter(list => list.lotID != lotID); + } + } else { + this.list.push({ type: "Remove", lotID: lotID, devices: deviceListID, isFromIndeterminate: indeterminate }); + } + } + + if (this.list.length > 0) { + document.getElementById("ApplyDeviceLots").classList.remove("disabled"); + } else { + document.getElementById("ApplyDeviceLots").classList.add("disabled"); + } + } + + /** + * Creates notification to give feedback to user + * @param {string} title notification title + * @param {string | null} toastText notification text + * @param {boolean} isError defines if a toast is a error + */ + notifyUser(title, toastText, isError) { + let toast = document.createElement("div"); + toast.classList = "alert alert-dismissible fade show " + (isError ? "alert-danger" : "alert-success"); + toast.attributes["data-autohide"] = !isError; + toast.attributes["role"] = "alert"; + toast.style = "margin-left: auto; width: fit-content;"; + toast.innerHTML = `${title}`; + if (toastText && toastText.length > 0) { + toast.innerHTML += `
    ${toastText}`; + } + + document.getElementById("NotificationsContainer").appendChild(toast); + if (!isError) { + setTimeout(() => toast.classList.remove("show"), 3000); + } + setTimeout(() => document.getElementById("NotificationsContainer").innerHTML == "", 3500); + } + + /** + * Get actions and execute call request to add or remove devices from lots + */ + doActions() { + var requestCount = 0; // This is for count all requested api count, to perform reRender of table device list + this.list.forEach(async action => { + if (action.type == "Add") { + try { + await Api.devices_add(action.lotID, action.devices); + this.notifyUser("Devices sucefully aded to selected lot/s", "", false); + } catch (error) { + this.notifyUser("Failed to add devices to selected lot/s", error.responseJSON.message, true); + } + } else if (action.type == "Remove") { + try { + await Api.devices_remove(action.lotID, action.devices); + this.notifyUser("Devices sucefully removed from selected lot/s", "", false); + } catch (error) { + this.notifyUser("Fail to remove devices from selected lot/s", error.responseJSON.message, true); + } + } + requestCount += 1 + if (requestCount == this.list.length) { + this.reRenderTable(); + this.list = []; + } + }) + document.getElementById("dropDownLotsSelector").classList.remove("show"); + } + + /** + * Re-render list in table + */ + async reRenderTable() { + var newRequest = await Api.doRequest(window.location) + + var tmpDiv = document.createElement("div") + tmpDiv.innerHTML = newRequest + + var oldTable = Array.from(document.querySelectorAll("table.table > tbody > tr .deviceSelect")).map(x => x.attributes["data-device-dhid"].value) + var newTable = Array.from(tmpDiv.querySelectorAll("table.table > tbody > tr .deviceSelect")).map(x => x.attributes["data-device-dhid"].value) + + for (let i = 0; i < oldTable.length; i++) { + if (!newTable.includes(oldTable[i])) { + // variable from device_list.html --> See: ereuse_devicehub\templates\inventory\device_list.html (Ln: 411) + table.rows().remove(i) + } + } + } + } + + var eventClickActions; + + /** + * Generates a list item with a correspondient checkbox state + * @param {String} lotID + * @param {String} lotName + * @param {Array} selectedDevicesIDs + * @param {HTMLElement} target + */ + function templateLot(lotID, lot, selectedDevicesIDs, elementTarget, actions) { + elementTarget.innerHTML = "" + + var htmlTemplate = ` + `; + + var existLotList = selectedDevicesIDs.map(selected => lot.devices.includes(selected)); + + var doc = document.createElement('li'); + doc.innerHTML = htmlTemplate; + + if (selectedDevicesIDs.length <= 0) { + doc.children[0].disabled = true; + } else if (existLotList.every(value => value == true)) { + doc.children[0].checked = true; + } else if (existLotList.every(value => value == false)) { + doc.children[0].checked = false; + } else { + doc.children[0].indeterminate = true; + } + + doc.children[0].addEventListener('mouseup', (ev) => actions.manage(ev, lotID, selectedDevicesIDs)); + elementTarget.append(doc); + } + + var listHTML = $("#LotsSelector") + + // Get selected devices + var selectedDevicesIDs = $.map($(".deviceSelect").filter(':checked'), function (x) { return parseInt($(x).attr('data')) }); + if (selectedDevicesIDs.length <= 0) { + listHTML.html('
  • No devices selected
  • '); + return; + } + + // Initialize Actions list, and set checkbox triggers + var actions = new Actions(); + if (eventClickActions) { + document.getElementById("ApplyDeviceLots").removeEventListener(eventClickActions); + } + eventClickActions = document.getElementById("ApplyDeviceLots").addEventListener("click", () => actions.doActions()); + document.getElementById("ApplyDeviceLots").classList.add("disabled"); + + try { + listHTML.html('
  • ') + var devices = await Api.get_devices(selectedDevicesIDs); + var lots = await Api.get_lots(); + + lots = lots.map(lot => { + lot.devices = devices + .filter(device => device.lots.filter(devicelot => devicelot.id == lot.id).length > 0) + .map(device => parseInt(device.id)); + return lot; + }) + + listHTML.html(''); + lots.forEach(lot => templateLot(lot.id, lot, selectedDevicesIDs, listHTML, actions)); + } catch (error) { + console.log(error); + listHTML.html('
  • Error feching devices and lots
    (see console for more details)
  • '); + } +} diff --git a/ereuse_devicehub/templates/ereuse_devicehub/base.html b/ereuse_devicehub/templates/ereuse_devicehub/base.html index 7b56a1ec..8e147828 100644 --- a/ereuse_devicehub/templates/ereuse_devicehub/base.html +++ b/ereuse_devicehub/templates/ereuse_devicehub/base.html @@ -50,6 +50,20 @@ + + + + diff --git a/ereuse_devicehub/templates/ereuse_devicehub/base_site.html b/ereuse_devicehub/templates/ereuse_devicehub/base_site.html index 1ffddfa0..4e721730 100644 --- a/ereuse_devicehub/templates/ereuse_devicehub/base_site.html +++ b/ereuse_devicehub/templates/ereuse_devicehub/base_site.html @@ -12,9 +12,21 @@ @@ -101,81 +113,82 @@