Merge pull request #196 from eReuse/feature/server-side-render-actions

Feature: server side render actions
This commit is contained in:
Santiago L 2022-02-08 11:54:05 +01:00 committed by GitHub
commit 0eec5b549f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 447 additions and 87 deletions

View File

@ -1,24 +1,31 @@
import json
from flask_wtf import FlaskForm
from wtforms import StringField, validators, MultipleFileField, FloatField, IntegerField, \
SelectField
from flask import g, request
from sqlalchemy.util import OrderedSet
from json.decoder import JSONDecodeError
from flask import g, request
from flask_wtf import FlaskForm
from sqlalchemy.util import OrderedSet
from wtforms import (DateField, FloatField, HiddenField, IntegerField,
MultipleFileField, SelectField, StringField,
TextAreaField, validators)
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.models import Device, Computer, Smartphone, Cellphone, \
Tablet, Monitor, Mouse, Keyboard, \
MemoryCardReader, SAI
from ereuse_devicehub.resources.action.models import RateComputer, Snapshot, VisualTest
from ereuse_devicehub.resources.action.schemas import Snapshot as SnapshotSchema
from ereuse_devicehub.resources.action.models import (Action, RateComputer,
Snapshot, VisualTest)
from ereuse_devicehub.resources.action.rate.v1_0 import CannotRate
from ereuse_devicehub.resources.action.schemas import \
Snapshot as SnapshotSchema
from ereuse_devicehub.resources.action.views.snapshot import (move_json,
save_json)
from ereuse_devicehub.resources.device.models import (SAI, Cellphone, Computer,
Device, Keyboard,
MemoryCardReader,
Monitor, Mouse,
Smartphone, Tablet)
from ereuse_devicehub.resources.device.sync import Sync
from ereuse_devicehub.resources.enums import Severity, SnapshotSoftware
from ereuse_devicehub.resources.lot.models import Lot
from ereuse_devicehub.resources.tag.model import Tag
from ereuse_devicehub.resources.enums import SnapshotSoftware, Severity
from ereuse_devicehub.resources.user.exceptions import InsufficientPermission
from ereuse_devicehub.resources.action.rate.v1_0 import CannotRate
from ereuse_devicehub.resources.device.sync import Sync
from ereuse_devicehub.resources.action.views.snapshot import save_json, move_json
class LotDeviceForm(FlaskForm):
@ -418,7 +425,6 @@ class TagDeviceForm(FlaskForm):
self.delete = kwargs.pop('delete', None)
self.device_id = kwargs.pop('device', None)
# import pdb; pdb.set_trace()
super().__init__(*args, **kwargs)
if self.delete:
@ -466,7 +472,68 @@ class TagDeviceForm(FlaskForm):
class NewActionForm(FlaskForm):
name = StringField(u'Name')
date = StringField(u'Date')
severity = StringField(u'Severity')
description = StringField(u'Description')
name = StringField(u'Name', [validators.length(max=50)])
devices = HiddenField()
date = DateField(u'Date', validators=(validators.Optional(),))
severity = SelectField(u'Severity', choices=[(v.name, v.name) for v in Severity])
description = TextAreaField(u'Description')
lot = HiddenField()
type = HiddenField()
def validate(self, extra_validators=None):
is_valid = super().validate(extra_validators)
if not is_valid:
return False
if self.devices.data:
devices = set(self.devices.data.split(","))
self._devices = OrderedSet(Device.query.filter(Device.id.in_(devices)).filter(
Device.owner_id == g.user.id).all())
if not self._devices:
return False
return True
def save(self):
Model = db.Model._decl_class_registry.data[self.type.data]()
self.instance = Model()
devices = self.devices.data
severity = self.severity.data
self.devices.data = self._devices
self.severity.data = Severity[self.severity.data]
self.populate_obj(self.instance)
db.session.add(self.instance)
db.session.commit()
self.devices.data = devices
self.severity.data = severity
return self.instance
class AllocateForm(NewActionForm):
start_time = DateField(u'Start time')
end_time = DateField(u'End time')
final_user_code = StringField(u'Final user code', [validators.length(max=50)])
transaction = StringField(u'Transaction', [validators.length(max=50)])
end_users = IntegerField(u'End users')
def validate(self, extra_validators=None):
is_valid = super().validate(extra_validators)
start_time = self.start_time.data
end_time = self.end_time.data
if start_time and end_time and end_time < start_time:
error = ['The action cannot finish before it starts.']
self.start_time.errors = error
self.end_time.errors = error
is_valid = False
if not self.end_users.data:
self.end_users.errors = ["You need to specify a number of users"]
is_valid = False
return is_valid

View File

@ -1,23 +1,27 @@
import flask
from flask import Blueprint, request, url_for
from flask.views import View
from flask import Blueprint, url_for, request
from flask_login import login_required, current_user
from flask_login import current_user, login_required
from ereuse_devicehub import messages
from ereuse_devicehub.inventory.forms import (AllocateForm, LotDeviceForm,
LotForm, NewActionForm,
NewDeviceForm, TagDeviceForm,
TagForm, TagUnnamedForm,
UploadSnapshotForm)
from ereuse_devicehub.resources.device.models import Device
from ereuse_devicehub.resources.lot.models import Lot
from ereuse_devicehub.resources.tag.model import Tag
from ereuse_devicehub.resources.device.models import Device
from ereuse_devicehub.inventory.forms import LotDeviceForm, LotForm, UploadSnapshotForm, \
NewDeviceForm, TagForm, TagUnnamedForm, TagDeviceForm
# TODO(@slamora): rename base 'inventory.devices' --> 'inventory'
devices = Blueprint('inventory.devices', __name__, url_prefix='/inventory')
class DeviceListView(View):
class DeviceListMix(View):
decorators = [login_required]
template_name = 'inventory/device_list.html'
def dispatch_request(self, lot_id=None):
def get_context(self, lot_id):
# TODO @cayop adding filter
# https://github.com/eReuse/devicehub-teal/blob/testing/ereuse_devicehub/resources/device/views.py#L56
filter_types = ['Desktop', 'Laptop', 'Server']
@ -30,19 +34,41 @@ class DeviceListView(View):
lot = lots.filter(Lot.id == lot_id).one()
devices = [dev for dev in lot.devices if dev.type in filter_types]
devices = sorted(devices, key=lambda x: x.updated, reverse=True)
form_new_action = NewActionForm(lot=lot.id)
form_new_allocate = AllocateForm(lot=lot.id)
else:
devices = Device.query.filter(
Device.owner_id == current_user.id).filter(
Device.type.in_(filter_types)).filter(Device.lots == None).order_by(
Device.updated.desc())
form_new_action = NewActionForm()
form_new_allocate = AllocateForm()
context = {'devices': devices,
action_devices = form_new_action.devices.data
list_devices = []
if action_devices:
list_devices.extend([int(x) for x in action_devices.split(",")])
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,
'lot': lot,
'tags': tags}
return flask.render_template(self.template_name, **context)
'tags': tags,
'list_devices': list_devices
}
return self.context
class DeviceListView(DeviceListMix):
def dispatch_request(self, lot_id=None):
self.get_context(lot_id)
return flask.render_template(self.template_name, **self.context)
class DeviceDetailView(View):
@ -261,6 +287,52 @@ class TagUnlinkDeviceView(View):
return flask.render_template(self.template_name, form=form, referrer=request.referrer)
class NewActionView(View):
methods = ['POST']
decorators = [login_required]
form_class = NewActionForm
def dispatch_request(self):
self.form = self.form_class()
if self.form.validate_on_submit():
instance = self.form.save()
messages.success('Action "{}" created successfully!'.format(instance.type))
next_url = self.get_next_url()
return flask.redirect(next_url)
def get_next_url(self):
lot_id = self.form.lot.data
if lot_id:
return url_for('inventory.devices.lotdevicelist', lot_id=lot_id)
return url_for('inventory.devices.devicelist')
class NewAllocateView(NewActionView, DeviceListMix):
methods = ['POST']
form_class = AllocateForm
def dispatch_request(self):
self.form = self.form_class()
if self.form.validate_on_submit():
instance = self.form.save()
messages.success('Action "{}" created successfully!'.format(instance.type))
next_url = self.get_next_url()
return flask.redirect(next_url)
lot_id = self.form.lot.data
self.get_context(lot_id)
self.context['form_new_allocate'] = self.form
return flask.render_template(self.template_name, **self.context)
devices.add_url_rule('/action/add/', view_func=NewActionView.as_view('action_add'))
devices.add_url_rule('/action/allocate/add/', view_func=NewAllocateView.as_view('allocate_add'))
devices.add_url_rule('/device/', view_func=DeviceListView.as_view('devicelist'))
devices.add_url_rule('/device/<string:id>/', view_func=DeviceDetailView.as_view('device_details'))
devices.add_url_rule('/lot/<string:lot_id>/device/', view_func=DeviceListView.as_view('lotdevicelist'))

View File

@ -0,0 +1,64 @@
from flask import flash, session
DEBUG = 10
INFO = 20
SUCCESS = 25
WARNING = 30
ERROR = 40
DEFAULT_LEVELS = {
'DEBUG': DEBUG,
'INFO': INFO,
'SUCCESS': SUCCESS,
'WARNING': WARNING,
'ERROR': ERROR,
}
DEFAULT_TAGS = {
DEBUG: 'light',
INFO: 'info',
SUCCESS: 'success',
WARNING: 'warning',
ERROR: 'danger',
}
DEFAULT_ICONS = {
DEFAULT_TAGS[DEBUG]: 'tools',
DEFAULT_TAGS[INFO]: 'info-circle',
DEFAULT_TAGS[SUCCESS]: 'check-circle',
DEFAULT_TAGS[WARNING]: 'exclamation-triangle',
DEFAULT_TAGS[ERROR]: 'exclamation-octagon',
}
def add_message(level, message):
level_tag = DEFAULT_TAGS[level]
if '_message_icon' not in session:
session['_message_icon'] = DEFAULT_ICONS
flash(message, level_tag)
def debug(message):
"""Add a message with the ``DEBUG`` level."""
add_message(DEBUG, message)
def info(message):
"""Add a message with the ``INFO`` level."""
add_message(INFO, message)
def success(message):
"""Add a message with the ``SUCCESS`` level."""
add_message(SUCCESS, message)
def warning(message):
"""Add a message with the ``WARNING`` level."""
add_message(WARNING, message)
def error(message):
"""Add a message with the ``ERROR`` level."""
add_message(ERROR, message)

View File

@ -213,18 +213,6 @@ class Action(Thing):
args[INHERIT_COND] = cls.id == Action.id
return args
@validates('end_time')
def validate_end_time(self, _, end_time: datetime):
if self.start_time and end_time <= self.start_time:
raise ValidationError('The action cannot finish before it starts.')
return end_time
@validates('start_time')
def validate_start_time(self, _, start_time: datetime):
if self.end_time and start_time >= self.end_time:
raise ValidationError('The action cannot start after it finished.')
return start_time
@property
def date_str(self):
return '{:%c}'.format(self.end_time)

View File

@ -55,6 +55,10 @@ class Action(Thing):
if 'start_time' in data and data['start_time'] < unix_time:
data['start_time'] = unix_time
if data.get('end_time') and data.get('start_time'):
if data['start_time'] > data['end_time']:
raise ValidationError('The action cannot finish before it starts.')
class ActionWithOneDevice(Action):
__doc__ = m.ActionWithOneDevice.__doc__

View File

@ -164,6 +164,10 @@ class Device(Thing):
super().__init__(**kw)
self.set_hid()
@property
def reverse_actions(self) -> list:
return reversed(self.actions)
@property
def actions(self) -> list:
"""All the actions where the device participated, including:

View File

@ -1,31 +1,46 @@
$(document).ready(function() {
var show_action_form = $("#allocateModal").data('show-action-form');
if (show_action_form != "None") {
$("#allocateModal .btn-primary").show();
newAllocate(show_action_form);
} else {
$(".deviceSelect").on("change", deviceSelect);
}
// $('#selectLot').selectpicker();
})
function deviceSelect() {
var devices = $(".deviceSelect").filter(':checked');
var devices_id = $.map(devices, function(x) { return $(x).attr('data')}).join(",");
if (devices_id == "") {
$("#addingLotModal .text-danger").show();
var devices_count = $(".deviceSelect").filter(':checked').length;
if (devices_count == 0) {
$("#addingLotModal .pol").show();
$("#addingLotModal .btn-primary").hide();
$("#removeLotModal .text-danger").show();
$("#removeLotModal .pol").show();
$("#removeLotModal .btn-primary").hide();
$("#addingTagModal .text-danger").show();
$("#addingTagModal .pol").show();
$("#addingTagModal .btn-primary").hide();
$("#actionModal .pol").show();
$("#actionModal .btn-primary").hide();
$("#allocateModal .pol").show();
$("#allocateModal .btn-primary").hide();
} else {
$("#addingLotModal .text-danger").hide();
$("#addingLotModal .btn-primary").removeClass('d-none');
$("#addingLotModal .pol").hide();
$("#addingLotModal .btn-primary").show();
$("#removeLotModal .text-danger").hide();
$("#removeLotModal .btn-primary").removeClass('d-none');
$("#removeLotModal .pol").hide();
$("#removeLotModal .btn-primary").show();
$("#addingTagModal .text-danger").hide();
$("#addingTagModal .btn-primary").removeClass('d-none');
$("#actionModal .pol").hide();
$("#actionModal .btn-primary").show();
$("#allocateModal .pol").hide();
$("#allocateModal .btn-primary").show();
$("#addingTagModal .pol").hide();
}
$.map($(".devicesList"), function(x) {
$(x).val(devices_id);
});
}
function removeTag() {
@ -39,5 +54,50 @@ function removeTag() {
}
function newAction(action) {
console.log(action);
$("#actionModal #type").val(action);
$("#actionModal #title-action").html(action);
get_device_list();
deviceSelect();
$("#activeActionModal").click();
}
function newAllocate(action) {
$("#allocateModal #type").val(action);
$("#allocateModal #title-action").html(action);
get_device_list();
deviceSelect();
$("#activeAllocateModal").click();
}
function get_device_list() {
var devices = $(".deviceSelect").filter(':checked');
/* Insert the correct count of devices in actions form */
var devices_count = devices.length;
$("#allocateModal .devices-count").html(devices_count);
$("#actionModal .devices-count").html(devices_count);
/* Insert the correct value in the input devicesList */
var devices_id = $.map(devices, function(x) { return $(x).attr('data')}).join(",");
$.map($(".devicesList"), function(x) {
$(x).val(devices_id);
});
/* Create a list of devices for human representation */
var computer = {
"Desktop": "<i class='bi bi-building'></i>",
"Laptop": "<i class='bi bi-laptop'></i>",
};
list_devices = devices.map(function (x) {
var typ = $(devices[x]).data("device-type");
var manuf = $(devices[x]).data("device-manufacturer");
var dhid = $(devices[x]).data("device-dhid");
if (computer[typ]) {
typ = computer[typ];
};
return typ + " " + manuf + " " + dhid;
});
description = $.map(list_devices, function(x) { return x }).join(", ");
$(".enumeration-devices").html(description);
}

View File

@ -175,6 +175,15 @@
</aside><!-- End Sidebar-->
<main id="main" class="main">
{% block messages %}
{% for level, message in get_flashed_messages(with_categories=true) %}
<div class="alert alert-{{ level}} alert-dismissible fade show" role="alert">
<i class="bi bi-{{ session['_message_icon'][level]}} me-1"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endblock %}
{% block main %}
{% endblock main %}

View File

@ -1,25 +1,51 @@
<div class="modal fade" id="actionModal" tabindex="-1" style="display: none;" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Action</h5>
<h5 class="modal-title">New Action <span id="title-action"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('inventory.devices.lot_devices_add') }}" method="post">
{{ form_lot_device.csrf_token }}
<form action="{{ url_for('inventory.devices.action_add') }}" method="post">
{{ form_new_action.csrf_token }}
<div class="modal-body">
Please write a name of a lot
<input class="devicesList" type="hidden" name="devices" />
<p class="text-danger">
{% for field in form_new_action %}
{% if field != form_new_action.csrf_token %}
{% if field == form_new_action.devices %}
<div class="col-12">
{{ field.label(class_="form-label") }}: <span class="devices-count"></span>
{{ field(class_="devicesList") }}
<p class="text-danger pol">
You need select first some device before to do one action
</p>
<p class="enumeration-devices"></p>
</div>
{% elif field == form_new_action.lot %}
{{ field }}
{% elif field == form_new_action.type %}
{{ field }}
{% else %}
<div class="col-12">
{{ field.label(class_="form-label") }}
{{ field(class_="form-control") }}
{% if field.errors %}
<p class="text-danger">
{% for error in field.errors %}
{{ error }}<br/>
{% endfor %}
</p>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary d-none" value="Create" />
<input type="submit" class="btn btn-primary" style="display: none;" value="Create" />
</div>
</form>

View File

@ -17,14 +17,14 @@
{% endfor %}
</select>
<input class="devicesList" type="hidden" name="devices" />
<p class="text-danger">
<p class="text-danger pol">
You need select first some device for adding this in a lot
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary d-none" value="Save changes" />
<input type="submit" class="btn btn-primary" style="display: none;" value="Save changes" />
</div>
</form>

View File

@ -17,7 +17,7 @@
{% endfor %}
</select>
<input class="devicesList" type="hidden" name="device" />
<p class="text-danger">
<p class="text-danger pol">
You need select first some device for adding this in a tag
</p>
</div>

View File

@ -0,0 +1,55 @@
<div class="modal fade" id="allocateModal" tabindex="-1" style="display: none;" aria-hidden="true"
data-show-action-form="{{ form_new_allocate.type.data }}">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Action <span id="title-action"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{{ url_for('inventory.devices.allocate_add') }}" method="post">
{{ form_new_allocate.csrf_token }}
<div class="modal-body">
{% for field in form_new_allocate %}
{% if field != form_new_allocate.csrf_token %}
{% if field == form_new_allocate.devices %}
<div class="col-12">
{{ field.label(class_="form-label") }}: <span class="devices-count"></span>
{{ field(class_="devicesList") }}
<p class="text-danger pol" style="display: none;">
You need select first some device before to do one action
</p>
<p class="enumeration-devices"></p>
</div>
{% elif field == form_new_allocate.lot %}
{{ field }}
{% elif field == form_new_allocate.type %}
{{ field }}
{% else %}
<div class="col-12">
{{ field.label(class_="form-label") }}
{{ field(class_="form-control") }}
{% if field.errors %}
<p class="text-danger">
{% for error in field.errors %}
{{ error }}<br/>
{% endfor %}
</p>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary" style="display: none;" value="Create" />
</div>
</form>
</div>
</div>
</div>

View File

@ -109,7 +109,7 @@
<div class="tab-pane fade profile-overview" id="traceability">
<h5 class="card-title">Traceability log Details</h5>
<div class="list-group col-6">
{% for action in device.actions %}
{% for action in device.reverse_actions %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ action.type }} {{ action.severity }}
<small class="text-muted">{{ action.created.strftime('%H:%M %d-%m-%Y') }}</small>

View File

@ -40,7 +40,7 @@
</div>
{% endif %}
<div class="card">
<div class="card-body pt-3">
<div class="card-body pt-3" style="min-height: 650px;">
<!-- Bordered Tabs -->
<div class="btn-group dropdown ml-1">
@ -57,7 +57,7 @@
<span class="caret"></span>
</button>
{% endif %}
<ul class="dropdown-menu" aria-labelledby="btnLots"">
<ul class="dropdown-menu" aria-labelledby="btnLots">
<li>
<a href="javascript:void()" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#addingLotModal">
<i class="bi bi-plus"></i>
@ -78,12 +78,14 @@
<i class="bi bi-plus"></i>
New Actions
</button>
<ul class="dropdown-menu" aria-labelledby="btnActions"">
<span class="d-none" id="activeActionModal" data-bs-toggle="modal" data-bs-target="#actionModal"></span>
<span class="d-none" id="activeAllocateModal" data-bs-toggle="modal" data-bs-target="#allocateModal"></span>
<ul class="dropdown-menu" aria-labelledby="btnActions">
<li>
Status actions
</li>
<li>
<a href="javascript:newAction('Recycling')" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#actionModal">
<a href="javascript:newAction('Recycling')" class="dropdown-item">
<i class="bi bi-recycle"></i>
Recycling
</a>
@ -110,13 +112,13 @@
Allocation
</li>
<li>
<a href="javascript:newAction('Allocate')" class="dropdown-item">
<a href="javascript:newAllocate('Allocate')" class="dropdown-item">
<i class="bi bi-house-fill"></i>
Allocate
</a>
</li>
<li>
<a href="javascript:newAction('Deallocate')" class="dropdown-item">
<a href="javascript:newAllocate('Deallocate')" class="dropdown-item">
<i class="bi bi-house"></i>
Deallocate
</a>
@ -137,7 +139,7 @@
</a>
</li>
<li>
<a href="javascript:newAction('DataWipe')" class="dropdown-item">
<a href="javascript:void()" class="dropdown-item">
<i class="bi bi-eraser-fill"></i>
DataWipe
</a>
@ -232,7 +234,15 @@
<tbody>
{% for dev in devices %}
<tr>
<td><input type="checkbox" class="deviceSelect" data="{{ dev.id }}"/></td>
<td>
<input type="checkbox" class="deviceSelect" data="{{ dev.id }}"
data-device-type="{{ dev.type }}" data-device-manufacturer="{{ dev.manufacturer }}"
data-device-dhid="{{ dev.devicehub_id }}"
{% if form_new_allocate.type.data and dev.id in list_devices %}
checked="checked"
{% endif %}
/>
</td>
<td>
<a href="{{ url_for('inventory.devices.device_details', id=dev.devicehub_id)}}">
{{ dev.type }} {{ dev.manufacturer }} {{ dev.model }}
@ -269,6 +279,7 @@
{% include "inventory/removeDeviceslot.html" %}
{% include "inventory/removelot.html" %}
{% include "inventory/actions.html" %}
{% include "inventory/allocate.html" %}
<!-- CDN -->
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@latest"></script>

View File

@ -16,14 +16,14 @@
{% endfor %}
</select>
<input class="devicesList" type="hidden" name="devices" />
<p class="text-danger">
<p class="text-danger pol">
You need select first some device for remove this from a lot
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary d-none" value="Save changes" />
<input type="submit" class="btn btn-primary" style="display: none;" value="Save changes" />
</div>
</form>