diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..416c47b1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +ereuse_devicehub/static/vendor +ereuse_devicehub/static/js/print.pdf.js +ereuse_devicehub/static/js/qrcode.js +*.min.js \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..0d9213bb --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "env": { + "browser": true, + "es2021": true, + "jquery": true + }, + "extends": [ + "airbnb", + "prettier" + ], + "plugins": [ + "prettier" + ], + "parserOptions": { + "ecmaVersion": "latest" + }, + "rules": { + "quotes": ["error","double"], + "no-use-before-define": "off", + "no-unused-vars": "warn", + "no-undef": "warn", + "camelcase": "off", + "no-console": "off", + "no-plusplus": "off", + "no-param-reassign": "off", + "no-new": "warn", + "strict": "off", + "class-methods-use-this": "off", + "eqeqeq": "warn", + "radix": "warn" + }, + "globals": { + "API_URLS": true, + "Api": true + } +} \ No newline at end of file diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 00000000..7e42feaf --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,55 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# ESLint is a tool for identifying and reporting on patterns +# found in ECMAScript/JavaScript code. +# More details at https://github.com/eslint/eslint +# and https://eslint.org + +name: ESLint + +on: + push: + branches: [master, testing] + pull_request_target: + branches: [master, testing] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '16' + - name: Install dependencies + run: npm install + - name: Run linters + uses: wearerequired/lint-action@v1 + with: + eslint: true + prettier: false + commit_message: "Fix code style issues with ${linter}" + auto_fix: true + commit: true + github_token: "${{ secrets.GITHUB_TOKEN }}" + git_name: "Lint Action" + - name: Save Code Linting Report JSON + # npm script for ESLint + # eslint --output-file eslint_report.json --format json src + # See https://eslint.org/docs/user-guide/command-line-interface#options + run: npm run lint:report + # Continue to the next step even if this fails + continue-on-error: true + - name: Annotate Code Linting Results + uses: ataylorme/eslint-annotate-action@1.2.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + report-json: "eslint_report.json" + only-pr-files: true + - name: Upload ESLint report + uses: actions/upload-artifact@v2 + with: + name: eslint_report.json + path: eslint_report.json diff --git a/.gitignore b/.gitignore index 6a130a6e..0d6f75c3 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,11 @@ ENV/ # Temporal dir tmp/ + +# NPM modules +node_modules/ +yarn.lock + +# ESLint Report +eslint_report.json + diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..f92e93ef --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 250 +} \ No newline at end of file diff --git a/ereuse_devicehub/forms.py b/ereuse_devicehub/forms.py index d88c9cf1..0f4cefbe 100644 --- a/ereuse_devicehub/forms.py +++ b/ereuse_devicehub/forms.py @@ -1,7 +1,9 @@ +from flask import g from flask_wtf import FlaskForm from werkzeug.security import generate_password_hash from wtforms import BooleanField, EmailField, PasswordField, validators +from ereuse_devicehub.db import db from ereuse_devicehub.resources.user.models import User @@ -59,3 +61,43 @@ class LoginForm(FlaskForm): self.form_errors.append(self.error_messages['inactive']) return user.is_active + + +class PasswordForm(FlaskForm): + password = PasswordField( + 'Current Password', + [validators.DataRequired()], + render_kw={'class': "form-control"}, + ) + newpassword = PasswordField( + 'New Password', + [validators.DataRequired()], + render_kw={'class': "form-control"}, + ) + renewpassword = PasswordField( + 'Re-enter New Password', + [validators.DataRequired()], + render_kw={'class': "form-control"}, + ) + + def validate(self, extra_validators=None): + is_valid = super().validate(extra_validators) + + if not is_valid: + return False + + if not g.user.check_password(self.password.data): + return False + + if self.newpassword.data != self.renewpassword.data: + return False + + return True + + def save(self, commit=True): + g.user.password = self.newpassword.data + + db.session.add(g.user) + if commit: + db.session.commit() + return diff --git a/ereuse_devicehub/labels/forms.py b/ereuse_devicehub/labels/forms.py index cd4b5bec..98427215 100644 --- a/ereuse_devicehub/labels/forms.py +++ b/ereuse_devicehub/labels/forms.py @@ -64,10 +64,7 @@ class PrintLabelsForm(FlaskForm): .all() ) - # print only tags that are DHID - dhids = [x.devicehub_id for x in self._devices] - self._tags = ( - Tag.query.filter(Tag.owner_id == g.user.id).filter(Tag.id.in_(dhids)).all() - ) + if not self._devices: + return False return is_valid diff --git a/ereuse_devicehub/labels/views.py b/ereuse_devicehub/labels/views.py index 445a4eb8..e7fc3b0d 100644 --- a/ereuse_devicehub/labels/views.py +++ b/ereuse_devicehub/labels/views.py @@ -27,7 +27,7 @@ class TagListView(View): context = { 'lots': lots, 'tags': tags, - 'page_title': 'Tags Management', + 'page_title': 'Unique Identifiers Management', 'version': __version__, } return flask.render_template(self.template_name, **context) @@ -102,7 +102,7 @@ class PrintLabelsView(View): form = PrintLabelsForm() if form.validate_on_submit(): context['form'] = form - context['tags'] = form._tags + context['devices'] = form._devices return flask.render_template(self.template_name, **context) else: messages.error('Error you need select one or more devices') diff --git a/ereuse_devicehub/resources/agent/models.py b/ereuse_devicehub/resources/agent/models.py index ab9e073a..826d0545 100644 --- a/ereuse_devicehub/resources/agent/models.py +++ b/ereuse_devicehub/resources/agent/models.py @@ -3,7 +3,9 @@ from operator import attrgetter from uuid import uuid4 from citext import CIText -from sqlalchemy import Column, Enum as DBEnum, ForeignKey, Unicode, UniqueConstraint +from sqlalchemy import Column +from sqlalchemy import Enum as DBEnum +from sqlalchemy import ForeignKey, Unicode, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import backref, relationship, validates @@ -31,7 +33,7 @@ class Agent(Thing): name = Column(CIText()) name.comment = """The name of the organization or person.""" tax_id = Column(Unicode(length=STR_SM_SIZE), check_lower('tax_id')) - tax_id.comment = """The Tax / Fiscal ID of the organization, + tax_id.comment = """The Tax / Fiscal ID of the organization, e.g. the TIN in the US or the CIF/NIF in Spain. """ country = Column(DBEnum(enums.Country)) @@ -42,7 +44,7 @@ class Agent(Thing): __table_args__ = ( UniqueConstraint(tax_id, country, name='Registration Number per country.'), UniqueConstraint(tax_id, name, name='One tax ID with one name.'), - db.Index('agent_type', type, postgresql_using='hash') + db.Index('agent_type', type, postgresql_using='hash'), ) @declared_attr @@ -63,7 +65,9 @@ class Agent(Thing): @property def actions(self) -> list: # todo test - return sorted(chain(self.actions_agent, self.actions_to), key=attrgetter('created')) + return sorted( + chain(self.actions_agent, self.actions_to), key=attrgetter('created') + ) @validates('name') def does_not_contain_slash(self, _, value: str): @@ -76,15 +80,17 @@ class Agent(Thing): class Organization(JoinedTableMixin, Agent): - default_of = db.relationship(Inventory, - uselist=False, - lazy=True, - backref=backref('org', lazy=True), - # We need to use this as we cannot do Inventory.foreign -> Org - # as foreign keys can only reference to one table - # and we have multiple organization table (one per schema) - foreign_keys=[Inventory.org_id], - primaryjoin=lambda: Organization.id == Inventory.org_id) + default_of = db.relationship( + Inventory, + uselist=False, + lazy=True, + backref=backref('org', lazy=True), + # We need to use this as we cannot do Inventory.foreign -> Org + # as foreign keys can only reference to one table + # and we have multiple organization table (one per schema) + foreign_keys=[Inventory.org_id], + primaryjoin=lambda: Organization.id == Inventory.org_id, + ) def __init__(self, name: str, **kwargs) -> None: super().__init__(**kwargs, name=name) @@ -97,12 +103,17 @@ class Organization(JoinedTableMixin, Agent): class Individual(JoinedTableMixin, Agent): active_org_id = Column(UUID(as_uuid=True), ForeignKey(Organization.id)) - active_org = relationship(Organization, primaryjoin=active_org_id == Organization.id) + + active_org = relationship( + Organization, primaryjoin=active_org_id == Organization.id + ) user_id = Column(UUID(as_uuid=True), ForeignKey(User.id), unique=True) - user = relationship(User, - backref=backref('individuals', lazy=True, collection_class=set), - primaryjoin=user_id == User.id) + user = relationship( + User, + backref=backref('individuals', lazy=True, collection_class=set), + primaryjoin=user_id == User.id, + ) class Membership(Thing): @@ -110,20 +121,29 @@ class Membership(Thing): For example, because the individual works in or because is a member of. """ - id = Column(Unicode(), check_lower('id')) - organization_id = Column(UUID(as_uuid=True), ForeignKey(Organization.id), primary_key=True) - organization = relationship(Organization, - backref=backref('members', collection_class=set, lazy=True), - primaryjoin=organization_id == Organization.id) - individual_id = Column(UUID(as_uuid=True), ForeignKey(Individual.id), primary_key=True) - individual = relationship(Individual, - backref=backref('member_of', collection_class=set, lazy=True), - primaryjoin=individual_id == Individual.id) - def __init__(self, organization: Organization, individual: Individual, id: str = None) -> None: - super().__init__(organization=organization, - individual=individual, - id=id) + id = Column(Unicode(), check_lower('id')) + organization_id = Column( + UUID(as_uuid=True), ForeignKey(Organization.id), primary_key=True + ) + organization = relationship( + Organization, + backref=backref('members', collection_class=set, lazy=True), + primaryjoin=organization_id == Organization.id, + ) + individual_id = Column( + UUID(as_uuid=True), ForeignKey(Individual.id), primary_key=True + ) + individual = relationship( + Individual, + backref=backref('member_of', collection_class=set, lazy=True), + primaryjoin=individual_id == Individual.id, + ) + + def __init__( + self, organization: Organization, individual: Individual, id: str = None + ) -> None: + super().__init__(organization=organization, individual=individual, id=id) __table_args__ = ( UniqueConstraint(id, organization_id, name='One member id per organization.'), @@ -134,6 +154,7 @@ class Person(Individual): """A person in the system. There can be several persons pointing to a real. """ + pass diff --git a/ereuse_devicehub/resources/device/models.py b/ereuse_devicehub/resources/device/models.py index 98227c4c..09451277 100644 --- a/ereuse_devicehub/resources/device/models.py +++ b/ereuse_devicehub/resources/device/models.py @@ -1,20 +1,30 @@ -import pathlib import copy +import pathlib import time -from flask import g from contextlib import suppress from fractions import Fraction from itertools import chain from operator import attrgetter from typing import Dict, List, Set -from flask_sqlalchemy import event from boltons import urlutils from citext import CIText from ereuse_utils.naming import HID_CONVERSION_DOC, Naming +from flask import g +from flask_sqlalchemy import event from more_itertools import unique_everseen -from sqlalchemy import BigInteger, Boolean, Column, Enum as DBEnum, Float, ForeignKey, Integer, \ - Sequence, SmallInteger, Unicode, inspect, text +from sqlalchemy import BigInteger, Boolean, Column +from sqlalchemy import Enum as DBEnum +from sqlalchemy import ( + Float, + ForeignKey, + Integer, + Sequence, + SmallInteger, + Unicode, + inspect, + text, +) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.hybrid import hybrid_property @@ -22,19 +32,41 @@ from sqlalchemy.orm import ColumnProperty, backref, relationship, validates from sqlalchemy.util import OrderedSet from sqlalchemy_utils import ColorType from stdnum import imei, meid -from teal.db import CASCADE_DEL, POLYMORPHIC_ID, POLYMORPHIC_ON, ResourceNotFound, URL, \ - check_lower, check_range, IntEnum +from teal.db import ( + CASCADE_DEL, + POLYMORPHIC_ID, + POLYMORPHIC_ON, + URL, + IntEnum, + ResourceNotFound, + check_lower, + check_range, +) from teal.enums import Layouts from teal.marshmallow import ValidationError from teal.resource import url_for_resource from ereuse_devicehub.db import db -from ereuse_devicehub.resources.utils import hashcode -from ereuse_devicehub.resources.enums import BatteryTechnology, CameraFacing, ComputerChassis, \ - DataStorageInterface, DisplayTech, PrinterTechnology, RamFormat, RamInterface, Severity, TransferState -from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing, listener_reset_field_updated_in_actual_time -from ereuse_devicehub.resources.user.models import User from ereuse_devicehub.resources.device.metrics import Metrics +from ereuse_devicehub.resources.enums import ( + BatteryTechnology, + CameraFacing, + ComputerChassis, + DataStorageInterface, + DisplayTech, + PrinterTechnology, + RamFormat, + RamInterface, + Severity, + TransferState, +) +from ereuse_devicehub.resources.models import ( + STR_SM_SIZE, + Thing, + listener_reset_field_updated_in_actual_time, +) +from ereuse_devicehub.resources.user.models import User +from ereuse_devicehub.resources.utils import hashcode def create_code(context): @@ -58,17 +90,21 @@ class Device(Thing): Devices can contain ``Components``, which are just a type of device (it is a recursive relationship). """ + id = Column(BigInteger, Sequence('device_seq'), primary_key=True) id.comment = """The identifier of the device for this database. Used only internally for software; users should not use this. """ type = Column(Unicode(STR_SM_SIZE), nullable=False) hid = Column(Unicode(), check_lower('hid'), unique=False) - hid.comment = """The Hardware ID (HID) is the ID traceability + hid.comment = ( + """The Hardware ID (HID) is the ID traceability systems use to ID a device globally. This field is auto-generated from Devicehub using literal identifiers from the device, so it can re-generated *offline*. - """ + HID_CONVERSION_DOC + """ + + HID_CONVERSION_DOC + ) model = Column(Unicode(), check_lower('model')) model.comment = """The model of the device in lower case. @@ -118,14 +154,18 @@ class Device(Thing): image = db.Column(db.URL) image.comment = "An image of the device." - owner_id = db.Column(UUID(as_uuid=True), - db.ForeignKey(User.id), - nullable=False, - default=lambda: g.user.id) + owner_id = db.Column( + UUID(as_uuid=True), + db.ForeignKey(User.id), + nullable=False, + default=lambda: g.user.id, + ) owner = db.relationship(User, primaryjoin=owner_id == User.id) allocated = db.Column(Boolean, default=False) allocated.comment = "device is allocated or not." - devicehub_id = db.Column(db.CIText(), nullable=True, unique=True, default=create_code) + devicehub_id = db.Column( + db.CIText(), nullable=True, unique=True, default=create_code + ) devicehub_id.comment = "device have a unique code." active = db.Column(Boolean, default=True) @@ -152,12 +192,12 @@ class Device(Thing): 'image', 'allocated', 'devicehub_id', - 'active' + 'active', } __table_args__ = ( db.Index('device_id', id, postgresql_using='hash'), - db.Index('type_index', type, postgresql_using='hash') + db.Index('type_index', type, postgresql_using='hash'), ) def __init__(self, **kw) -> None: @@ -187,7 +227,9 @@ class Device(Thing): for ac in actions_one: ac.real_created = ac.created - return sorted(chain(actions_multiple, actions_one), key=lambda x: x.real_created) + return sorted( + chain(actions_multiple, actions_one), key=lambda x: x.real_created + ) @property def problems(self): @@ -196,8 +238,9 @@ class Device(Thing): There can be up to 3 actions: current Snapshot, current Physical action, current Trading action. """ - from ereuse_devicehub.resources.device import states from ereuse_devicehub.resources.action.models import Snapshot + from ereuse_devicehub.resources.device import states + actions = set() with suppress(LookupError, ValueError): actions.add(self.last_action_of(Snapshot)) @@ -217,11 +260,13 @@ class Device(Thing): """ # todo ensure to remove materialized values when start using them # todo or self.__table__.columns if inspect fails - return {c.key: getattr(self, c.key, None) - for c in inspect(self.__class__).attrs - if isinstance(c, ColumnProperty) - and not getattr(c, 'foreign_keys', None) - and c.key not in self._NON_PHYSICAL_PROPS} + return { + c.key: getattr(self, c.key, None) + for c in inspect(self.__class__).attrs + if isinstance(c, ColumnProperty) + and not getattr(c, 'foreign_keys', None) + and c.key not in self._NON_PHYSICAL_PROPS + } @property def public_properties(self) -> Dict[str, object or None]: @@ -234,11 +279,13 @@ class Device(Thing): """ non_public = ['amount', 'transfer_state', 'receiver_id'] hide_properties = list(self._NON_PHYSICAL_PROPS) + non_public - return {c.key: getattr(self, c.key, None) - for c in inspect(self.__class__).attrs - if isinstance(c, ColumnProperty) - and not getattr(c, 'foreign_keys', None) - and c.key not in hide_properties} + return { + c.key: getattr(self, c.key, None) + for c in inspect(self.__class__).attrs + if isinstance(c, ColumnProperty) + and not getattr(c, 'foreign_keys', None) + and c.key not in hide_properties + } @property def public_actions(self) -> List[object]: @@ -260,6 +307,7 @@ class Device(Thing): """The last Rate of the device.""" with suppress(LookupError, ValueError): from ereuse_devicehub.resources.action.models import Rate + return self.last_action_of(Rate) @property @@ -268,12 +316,14 @@ class Device(Thing): ever been set.""" with suppress(LookupError, ValueError): from ereuse_devicehub.resources.action.models import Price + return self.last_action_of(Price) @property def last_action_trading(self): """which is the last action trading""" from ereuse_devicehub.resources.device import states + with suppress(LookupError, ValueError): return self.last_action_of(*states.Trading.actions()) @@ -287,6 +337,7 @@ class Device(Thing): - Management """ from ereuse_devicehub.resources.device import states + with suppress(LookupError, ValueError): return self.last_action_of(*states.Status.actions()) @@ -300,6 +351,7 @@ class Device(Thing): - Management """ from ereuse_devicehub.resources.device import states + status_actions = [ac.t for ac in states.Status.actions()] history = [] for ac in self.actions: @@ -329,13 +381,15 @@ class Device(Thing): if not hasattr(lot, 'trade'): return - Status = {0: 'Trade', - 1: 'Confirm', - 2: 'NeedConfirmation', - 3: 'TradeConfirmed', - 4: 'Revoke', - 5: 'NeedConfirmRevoke', - 6: 'RevokeConfirmed'} + Status = { + 0: 'Trade', + 1: 'Confirm', + 2: 'NeedConfirmation', + 3: 'TradeConfirmed', + 4: 'Revoke', + 5: 'NeedConfirmRevoke', + 6: 'RevokeConfirmed', + } trade = lot.trade user_from = trade.user_from @@ -408,6 +462,7 @@ class Device(Thing): """If the actual trading state is an revoke action, this property show the id of that revoke""" from ereuse_devicehub.resources.device import states + with suppress(LookupError, ValueError): action = self.last_action_of(*states.Trading.actions()) if action.type == 'Revoke': @@ -417,6 +472,7 @@ class Device(Thing): def physical(self): """The actual physical state, None otherwise.""" from ereuse_devicehub.resources.device import states + with suppress(LookupError, ValueError): action = self.last_action_of(*states.Physical.actions()) return states.Physical(action.__class__) @@ -425,6 +481,7 @@ class Device(Thing): def traking(self): """The actual traking state, None otherwise.""" from ereuse_devicehub.resources.device import states + with suppress(LookupError, ValueError): action = self.last_action_of(*states.Traking.actions()) return states.Traking(action.__class__) @@ -433,6 +490,7 @@ class Device(Thing): def usage(self): """The actual usage state, None otherwise.""" from ereuse_devicehub.resources.device import states + with suppress(LookupError, ValueError): action = self.last_action_of(*states.Usage.actions()) return states.Usage(action.__class__) @@ -470,8 +528,11 @@ class Device(Thing): test has been executed. """ from ereuse_devicehub.resources.action.models import Test - current_tests = unique_everseen((e for e in reversed(self.actions) if isinstance(e, Test)), - key=attrgetter('type')) # last test of each type + + current_tests = unique_everseen( + (e for e in reversed(self.actions) if isinstance(e, Test)), + key=attrgetter('type'), + ) # last test of each type return self._warning_actions(current_tests) @property @@ -496,7 +557,9 @@ class Device(Thing): def set_hid(self): with suppress(TypeError): - self.hid = Naming.hid(self.type, self.manufacturer, self.model, self.serial_number) + self.hid = Naming.hid( + self.type, self.manufacturer, self.model, self.serial_number + ) def last_action_of(self, *types): """Gets the last action of the given types. @@ -509,7 +572,9 @@ class Device(Thing): actions.sort(key=lambda x: x.created) return next(e for e in reversed(actions) if isinstance(e, types)) except StopIteration: - raise LookupError('{!r} does not contain actions of types {}.'.format(self, types)) + raise LookupError( + '{!r} does not contain actions of types {}.'.format(self, types) + ) def which_user_put_this_device_in_trace(self): """which is the user than put this device in this trade""" @@ -546,6 +611,32 @@ class Device(Thing): metrics = Metrics(device=self) return metrics.get_metrics() + def get_type_logo(self): + # This is used for see one logo of type of device in the frontend + types = { + "Desktop": "bi bi-file-post-fill", + "Laptop": "bi bi-laptop", + "Server": "bi bi-server", + "Processor": "bi bi-cpu", + "RamModule": "bi bi-list", + "Motherboard": "bi bi-cpu-fill", + "NetworkAdapter": "bi bi-hdd-network", + "GraphicCard": "bi bi-brush", + "SoundCard": "bi bi-volume-up-fill", + "Monitor": "bi bi-display", + "Display": "bi bi-display", + "ComputerMonitor": "bi bi-display", + "TelevisionSet": "bi bi-easel", + "TV": "bi bi-easel", + "Projector": "bi bi-camera-video", + "Tablet": "bi bi-tablet-landscape", + "Smartphone": "bi bi-phone", + "Cellphone": "bi bi-telephone", + "HardDrive": "bi bi-hdd-stack", + "SolidStateDrive": "bi bi-hdd", + } + return types.get(self.type, '') + def __lt__(self, other): return self.id < other.id @@ -571,19 +662,24 @@ class Device(Thing): class DisplayMixin: """Base class for the Display Component and the Monitor Device.""" - size = Column(Float(decimal_return_scale=1), check_range('size', 2, 150), nullable=True) + + size = Column( + Float(decimal_return_scale=1), check_range('size', 2, 150), nullable=True + ) size.comment = """The size of the monitor in inches.""" technology = Column(DBEnum(DisplayTech)) technology.comment = """The technology the monitor uses to display the image. """ - resolution_width = Column(SmallInteger, check_range('resolution_width', 10, 20000), - nullable=True) + resolution_width = Column( + SmallInteger, check_range('resolution_width', 10, 20000), nullable=True + ) resolution_width.comment = """The maximum horizontal resolution the monitor can natively support in pixels. """ - resolution_height = Column(SmallInteger, check_range('resolution_height', 10, 20000), - nullable=True) + resolution_height = Column( + SmallInteger, check_range('resolution_height', 10, 20000), nullable=True + ) resolution_height.comment = """The maximum vertical resolution the monitor can natively support in pixels. """ @@ -622,8 +718,12 @@ class DisplayMixin: def __str__(self) -> str: if self.size: - return '{0.t} {0.serial_number} {0.size}in ({0.aspect_ratio}) {0.technology}'.format(self) - return '{0.t} {0.serial_number} 0in ({0.aspect_ratio}) {0.technology}'.format(self) + return '{0.t} {0.serial_number} {0.size}in ({0.aspect_ratio}) {0.technology}'.format( + self + ) + return '{0.t} {0.serial_number} 0in ({0.aspect_ratio}) {0.technology}'.format( + self + ) def __format__(self, format_spec: str) -> str: v = '' @@ -645,6 +745,7 @@ class Computer(Device): Computer is broadly extended by ``Desktop``, ``Laptop``, and ``Server``. The property ``chassis`` defines it more granularly. """ + id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) chassis = Column(DBEnum(ComputerChassis), nullable=True) chassis.comment = """The physical form of the computer. @@ -652,16 +753,18 @@ class Computer(Device): It is a subset of the Linux definition of DMI / DMI decode. """ amount = Column(Integer, check_range('amount', min=0, max=100), default=0) - owner_id = db.Column(UUID(as_uuid=True), - db.ForeignKey(User.id), - nullable=False, - default=lambda: g.user.id) + owner_id = db.Column( + UUID(as_uuid=True), + db.ForeignKey(User.id), + nullable=False, + default=lambda: g.user.id, + ) # author = db.relationship(User, primaryjoin=owner_id == User.id) - transfer_state = db.Column(IntEnum(TransferState), default=TransferState.Initial, nullable=False) + transfer_state = db.Column( + IntEnum(TransferState), default=TransferState.Initial, nullable=False + ) transfer_state.comment = TransferState.__doc__ - receiver_id = db.Column(UUID(as_uuid=True), - db.ForeignKey(User.id), - nullable=True) + receiver_id = db.Column(UUID(as_uuid=True), db.ForeignKey(User.id), nullable=True) receiver = db.relationship(User, primaryjoin=receiver_id == User.id) def __init__(self, *args, **kwargs) -> None: @@ -684,22 +787,30 @@ class Computer(Device): @property def ram_size(self) -> int: """The total of RAM memory the computer has.""" - return sum(ram.size or 0 for ram in self.components if isinstance(ram, RamModule)) + return sum( + ram.size or 0 for ram in self.components if isinstance(ram, RamModule) + ) @property def data_storage_size(self) -> int: """The total of data storage the computer has.""" - return sum(ds.size or 0 for ds in self.components if isinstance(ds, DataStorage)) + return sum( + ds.size or 0 for ds in self.components if isinstance(ds, DataStorage) + ) @property def processor_model(self) -> str: """The model of one of the processors of the computer.""" - return next((p.model for p in self.components if isinstance(p, Processor)), None) + return next( + (p.model for p in self.components if isinstance(p, Processor)), None + ) @property def graphic_card_model(self) -> str: """The model of one of the graphic cards of the computer.""" - return next((p.model for p in self.components if isinstance(p, GraphicCard)), None) + return next( + (p.model for p in self.components if isinstance(p, GraphicCard)), None + ) @property def network_speeds(self) -> List[int]: @@ -724,16 +835,18 @@ class Computer(Device): it is not None. """ return set( - privacy for privacy in - (hdd.privacy for hdd in self.components if isinstance(hdd, DataStorage)) + privacy + for privacy in ( + hdd.privacy for hdd in self.components if isinstance(hdd, DataStorage) + ) if privacy ) @property def external_document_erasure(self): - """Returns the external ``DataStorage`` proof of erasure. - """ + """Returns the external ``DataStorage`` proof of erasure.""" from ereuse_devicehub.resources.action.models import DataWipe + urls = set() try: ev = self.last_action_of(DataWipe) @@ -756,8 +869,11 @@ class Computer(Device): if not self.hid: return components = self.components if components_snap is None else components_snap - macs_network = [c.serial_number for c in components - if c.type == 'NetworkAdapter' and c.serial_number is not None] + macs_network = [ + c.serial_number + for c in components + if c.type == 'NetworkAdapter' and c.serial_number is not None + ] macs_network.sort() mac = macs_network[0] if macs_network else '' if not mac or mac in self.hid: @@ -823,9 +939,13 @@ class Mobile(Device): """ ram_size = db.Column(db.Integer, check_range('ram_size', min=128, max=36000)) ram_size.comment = """The total of RAM of the device in MB.""" - data_storage_size = db.Column(db.Integer, check_range('data_storage_size', 0, 10 ** 8)) + data_storage_size = db.Column( + db.Integer, check_range('data_storage_size', 0, 10**8) + ) data_storage_size.comment = """The total of data storage of the device in MB""" - display_size = db.Column(db.Float(decimal_return_scale=1), check_range('display_size', min=0.1, max=30.0)) + display_size = db.Column( + db.Float(decimal_return_scale=1), check_range('display_size', min=0.1, max=30.0) + ) display_size.comment = """The total size of the device screen""" @validates('imei') @@ -855,21 +975,24 @@ class Cellphone(Mobile): class Component(Device): """A device that can be inside another device.""" + id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) parent_id = Column(BigInteger, ForeignKey(Computer.id)) - parent = relationship(Computer, - backref=backref('components', - lazy=True, - cascade=CASCADE_DEL, - order_by=lambda: Component.id, - collection_class=OrderedSet), - primaryjoin=parent_id == Computer.id) - - __table_args__ = ( - db.Index('parent_index', parent_id, postgresql_using='hash'), + parent = relationship( + Computer, + backref=backref( + 'components', + lazy=True, + cascade=CASCADE_DEL, + order_by=lambda: Component.id, + collection_class=OrderedSet, + ), + primaryjoin=parent_id == Computer.id, ) + __table_args__ = (db.Index('parent_index', parent_id, postgresql_using='hash'),) + def similar_one(self, parent: Computer, blacklist: Set[int]) -> 'Component': """Gets a component that: @@ -881,11 +1004,16 @@ class Component(Device): when looking for similar ones. """ assert self.hid is None, 'Don\'t use this method with a component that has HID' - component = self.__class__.query \ - .filter_by(parent=parent, hid=None, owner_id=self.owner_id, - **self.physical_properties) \ - .filter(~Component.id.in_(blacklist)) \ + component = ( + self.__class__.query.filter_by( + parent=parent, + hid=None, + owner_id=self.owner_id, + **self.physical_properties, + ) + .filter(~Component.id.in_(blacklist)) .first() + ) if not component: raise ResourceNotFound(self.type) return component @@ -908,7 +1036,8 @@ class GraphicCard(JoinedComponentTableMixin, Component): class DataStorage(JoinedComponentTableMixin, Component): """A device that stores information.""" - size = Column(Integer, check_range('size', min=1, max=10 ** 8)) + + size = Column(Integer, check_range('size', min=1, max=10**8)) size.comment = """The size of the data-storage in MB.""" interface = Column(DBEnum(DataStorageInterface)) @@ -919,6 +1048,7 @@ class DataStorage(JoinedComponentTableMixin, Component): This is, the last erasure performed to the data storage. """ from ereuse_devicehub.resources.action.models import EraseBasic + try: ev = self.last_action_of(EraseBasic) except LookupError: @@ -933,9 +1063,9 @@ class DataStorage(JoinedComponentTableMixin, Component): @property def external_document_erasure(self): - """Returns the external ``DataStorage`` proof of erasure. - """ + """Returns the external ``DataStorage`` proof of erasure.""" from ereuse_devicehub.resources.action.models import DataWipe + try: ev = self.last_action_of(DataWipe) return ev.document.url.to_text() @@ -985,6 +1115,7 @@ class NetworkAdapter(JoinedComponentTableMixin, NetworkMixin, Component): class Processor(JoinedComponentTableMixin, Component): """The CPU.""" + speed = Column(Float, check_range('speed', 0.1, 15)) speed.comment = """The regular CPU speed.""" cores = Column(SmallInteger, check_range('cores', 1, 10)) @@ -999,6 +1130,7 @@ class Processor(JoinedComponentTableMixin, Component): class RamModule(JoinedComponentTableMixin, Component): """A stick of RAM.""" + size = Column(SmallInteger, check_range('size', min=128, max=17000)) size.comment = """The capacity of the RAM stick.""" speed = Column(SmallInteger, check_range('speed', min=100, max=10000)) @@ -1016,6 +1148,7 @@ class Display(JoinedComponentTableMixin, DisplayMixin, Component): mobiles, smart-watches, and so on; excluding ``ComputerMonitor`` and ``TelevisionSet``. """ + pass @@ -1031,14 +1164,16 @@ class Battery(JoinedComponentTableMixin, Component): @property def capacity(self) -> float: - """The quantity of """ + """The quantity of""" from ereuse_devicehub.resources.action.models import MeasureBattery + real_size = self.last_action_of(MeasureBattery).size return real_size / self.size if real_size and self.size else None class Camera(Component): """The camera of a device.""" + focal_length = db.Column(db.SmallInteger) video_height = db.Column(db.SmallInteger) video_width = db.Column(db.Integer) @@ -1051,6 +1186,7 @@ class Camera(Component): class ComputerAccessory(Device): """Computer peripherals and similar accessories.""" + id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) pass @@ -1073,6 +1209,7 @@ class MemoryCardReader(ComputerAccessory): class Networking(NetworkMixin, Device): """Routers, switches, hubs...""" + id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) @@ -1118,6 +1255,7 @@ class Microphone(Sound): class Video(Device): """Devices related to video treatment.""" + pass @@ -1131,6 +1269,7 @@ class Videoconference(Video): class Cooking(Device): """Cooking devices.""" + pass @@ -1182,6 +1321,7 @@ class Manufacturer(db.Model): Ideally users should use the names from this list when submitting devices. """ + name = db.Column(CIText(), primary_key=True) name.comment = """The normalized name of the manufacturer.""" url = db.Column(URL(), unique=True) @@ -1192,7 +1332,7 @@ class Manufacturer(db.Model): __table_args__ = ( # from https://niallburkley.com/blog/index-columns-for-like-in-postgres/ db.Index('name_index', text('name gin_trgm_ops'), postgresql_using='gin'), - {'schema': 'common'} + {'schema': 'common'}, ) @classmethod @@ -1202,10 +1342,7 @@ class Manufacturer(db.Model): #: Dialect used to write the CSV with pathlib.Path(__file__).parent.joinpath('manufacturers.csv').open() as f: - cursor.copy_expert( - 'COPY common.manufacturer FROM STDIN (FORMAT csv)', - f - ) + cursor.copy_expert('COPY common.manufacturer FROM STDIN (FORMAT csv)', f) listener_reset_field_updated_in_actual_time(Device) @@ -1217,6 +1354,7 @@ def create_code_tag(mapper, connection, device): this tag is the same of devicehub_id. """ from ereuse_devicehub.resources.tag.model import Tag + if isinstance(device, Computer): tag = Tag(device_id=device.id, id=device.devicehub_id) db.session.add(tag) diff --git a/ereuse_devicehub/resources/user/models.py b/ereuse_devicehub/resources/user/models.py index 70f14e00..5eadb21d 100644 --- a/ereuse_devicehub/resources/user/models.py +++ b/ereuse_devicehub/resources/user/models.py @@ -2,37 +2,44 @@ from uuid import uuid4 from flask import current_app as app from flask_login import UserMixin -from sqlalchemy import Column, Boolean, BigInteger, Sequence +from sqlalchemy import BigInteger, Boolean, Column, Sequence from sqlalchemy.dialects.postgresql import UUID from sqlalchemy_utils import EmailType, PasswordType from teal.db import IntEnum from ereuse_devicehub.db import db +from ereuse_devicehub.resources.enums import SessionType from ereuse_devicehub.resources.inventory.model import Inventory from ereuse_devicehub.resources.models import STR_SIZE, Thing -from ereuse_devicehub.resources.enums import SessionType class User(UserMixin, Thing): __table_args__ = {'schema': 'common'} id = Column(UUID(as_uuid=True), default=uuid4, primary_key=True) email = Column(EmailType, nullable=False, unique=True) - password = Column(PasswordType(max_length=STR_SIZE, - onload=lambda **kwargs: dict( - schemes=app.config['PASSWORD_SCHEMES'], - **kwargs - ))) + password = Column( + PasswordType( + max_length=STR_SIZE, + onload=lambda **kwargs: dict( + schemes=app.config['PASSWORD_SCHEMES'], **kwargs + ), + ) + ) token = Column(UUID(as_uuid=True), default=uuid4, unique=True, nullable=False) active = Column(Boolean, default=True, nullable=False) phantom = Column(Boolean, default=False, nullable=False) - inventories = db.relationship(Inventory, - backref=db.backref('users', lazy=True, collection_class=set), - secondary=lambda: UserInventory.__table__, - collection_class=set) + inventories = db.relationship( + Inventory, + backref=db.backref('users', lazy=True, collection_class=set), + secondary=lambda: UserInventory.__table__, + collection_class=set, + ) # todo set restriction that user has, at least, one active db - def __init__(self, email, password=None, inventories=None, active=True, phantom=False) -> None: + def __init__( + self, email, password=None, inventories=None, active=True, phantom=False + ) -> None: """Creates an user. :param email: :param password: @@ -44,8 +51,13 @@ class User(UserMixin, Thing): create during the trade actions """ inventories = inventories or {Inventory.current} - super().__init__(email=email, password=password, inventories=inventories, - active=active, phantom=phantom) + super().__init__( + email=email, + password=password, + inventories=inventories, + active=active, + phantom=phantom, + ) def __repr__(self) -> str: return ''.format(self) @@ -73,8 +85,8 @@ class User(UserMixin, Thing): @property def get_full_name(self): - # TODO(@slamora) create first_name & last_name fields and use - # them to generate user full name + # TODO(@slamora) create first_name & last_name fields??? + # needs to be discussed related to Agent <--> User concepts return self.email def check_password(self, password): @@ -84,9 +96,12 @@ class User(UserMixin, Thing): class UserInventory(db.Model): """Relationship between users and their inventories.""" + __table_args__ = {'schema': 'common'} user_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(User.id), primary_key=True) - inventory_id = db.Column(db.Unicode(), db.ForeignKey(Inventory.id), primary_key=True) + inventory_id = db.Column( + db.Unicode(), db.ForeignKey(Inventory.id), primary_key=True + ) class Session(Thing): @@ -96,9 +111,11 @@ class Session(Thing): token = Column(UUID(as_uuid=True), default=uuid4, unique=True, nullable=False) type = Column(IntEnum(SessionType), default=SessionType.Internal, nullable=False) user_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(User.id)) - user = db.relationship(User, - backref=db.backref('sessions', lazy=True, collection_class=set), - collection_class=set) + user = db.relationship( + User, + backref=db.backref('sessions', lazy=True, collection_class=set), + collection_class=set, + ) def __str__(self) -> str: return '{0.token}'.format(self) diff --git a/ereuse_devicehub/static/css/devicehub.css b/ereuse_devicehub/static/css/devicehub.css new file mode 100644 index 00000000..e6ae1893 --- /dev/null +++ b/ereuse_devicehub/static/css/devicehub.css @@ -0,0 +1,25 @@ +/** +* eReuse CSS +*/ + +/*-------------------------------------------------------------- +# LotsSelector +--------------------------------------------------------------*/ + +#dropDownLotsSelector { + max-height: 500px; +} + +#dropDownLotsSelector>ul#LotsSelector { + list-style-type: none; + margin: 0; + padding: 0; + min-width: max-content; + max-height: 380px; + overflow-y: auto; +} + +#dropDownLotsSelector #ApplyDeviceLots { + padding-top: 0px; + padding-bottom: 5px; +} diff --git a/ereuse_devicehub/static/css/style.css b/ereuse_devicehub/static/css/style.css index 8e263853..1fd44f19 100644 --- a/ereuse_devicehub/static/css/style.css +++ b/ereuse_devicehub/static/css/style.css @@ -1,10 +1,10 @@ -/** -* Template Name: NiceAdmin - v2.2.0 -* Template URL: https://bootstrapmade.com/nice-admin-bootstrap-admin-html-template/ -* Author: BootstrapMade.com -* License: https://bootstrapmade.com/license/ -*/ - +/** +* Template Name: NiceAdmin - v2.2.0 +* Template URL: https://bootstrapmade.com/nice-admin-bootstrap-admin-html-template/ +* Author: BootstrapMade.com +* License: https://bootstrapmade.com/license/ +*/ + /*-------------------------------------------------------------- # General --------------------------------------------------------------*/ @@ -1081,4 +1081,4 @@ h1, h2, h3, h4, h5, h6 { text-align: center; font-size: 13px; color: #012970; -} \ No newline at end of file +} diff --git a/ereuse_devicehub/static/js/api.js b/ereuse_devicehub/static/js/api.js index ee98a08f..190e5d42 100644 --- a/ereuse_devicehub/static/js/api.js +++ b/ereuse_devicehub/static/js/api.js @@ -4,7 +4,7 @@ const Api = { * @returns get lots */ async get_lots() { - var request = await this.doRequest(API_URLS.lots, "GET", null); + const request = await this.doRequest(API_URLS.lots, "GET", null); if (request != undefined) return request.items; throw request; }, @@ -15,7 +15,7 @@ const Api = { * @returns full detailed device list */ async get_devices(ids) { - var request = await this.doRequest(API_URLS.devices + '?filter={"id": [' + ids.toString() + ']}', "GET", null); + const request = await this.doRequest(`${API_URLS.devices }?filter={"id": [${ ids.toString() }]}`, "GET", null); if (request != undefined) return request.items; throw request; }, @@ -26,7 +26,7 @@ const Api = { * @returns full detailed device list */ async search_device(id) { - var request = await this.doRequest(API_URLS.devices + '?filter={"devicehub_id": ["' + id + '"]}', "GET", null) + const request = await this.doRequest(`${API_URLS.devices }?filter={"devicehub_id": ["${ id }"]}`, "GET", null) if (request != undefined) return request.items throw request }, @@ -37,8 +37,8 @@ const Api = { * @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); + const queryURL = `${API_URLS.devices_modify.replace("UUID", lotID) }?${ listDevices.map(deviceID => `id=${ deviceID}`).join("&")}`; + return Api.doRequest(queryURL, "POST", null); }, /** @@ -47,8 +47,8 @@ const Api = { * @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); + const queryURL = `${API_URLS.devices_modify.replace("UUID", lotID) }?${ listDevices.map(deviceID => `id=${ deviceID}`).join("&")}`; + return Api.doRequest(queryURL, "DELETE", null); }, /** @@ -59,13 +59,13 @@ const Api = { * @returns */ async doRequest(url, type, body) { - var result; + let result; try { result = await $.ajax({ - url: url, - type: type, + url, + type, headers: { "Authorization": API_URLS.Auth_Token }, - body: body + body }); return result; } catch (error) { diff --git a/ereuse_devicehub/static/js/create_device.js b/ereuse_devicehub/static/js/create_device.js index 1c9e0655..a1b609b0 100644 --- a/ereuse_devicehub/static/js/create_device.js +++ b/ereuse_devicehub/static/js/create_device.js @@ -1,15 +1,15 @@ -$(document).ready(function() { +$(document).ready(() => { $("#type").on("change", deviceInputs); deviceInputs(); }) function deviceInputs() { - if ($("#type").val() == 'Monitor') { + if ($("#type").val() == "Monitor") { $("#screen").show(); $("#resolution").show(); $("#imei").hide(); $("#meid").hide(); - } else if (['Smartphone', 'Cellphone', 'Tablet'].includes($("#type").val())) { + } else if (["Smartphone", "Cellphone", "Tablet"].includes($("#type").val())) { $("#screen").hide(); $("#resolution").hide(); $("#imei").show(); diff --git a/ereuse_devicehub/static/js/main.js b/ereuse_devicehub/static/js/main.js index 5eaec3ea..55a4951c 100644 --- a/ereuse_devicehub/static/js/main.js +++ b/ereuse_devicehub/static/js/main.js @@ -14,9 +14,9 @@ el = el.trim() if (all) { return [...document.querySelectorAll(el)] - } else { + } return document.querySelector(el) - } + } /** @@ -34,103 +34,101 @@ * Easy on scroll event listener */ const onscroll = (el, listener) => { - el.addEventListener('scroll', listener) + el.addEventListener("scroll", listener) } /** * Sidebar toggle */ - if (select('.toggle-sidebar-btn')) { - on('click', '.toggle-sidebar-btn', function (e) { - select('body').classList.toggle('toggle-sidebar') + if (select(".toggle-sidebar-btn")) { + on("click", ".toggle-sidebar-btn", (e) => { + select("body").classList.toggle("toggle-sidebar") }) } /** * Search bar toggle */ - if (select('.search-bar-toggle')) { - on('click', '.search-bar-toggle', function (e) { - select('.search-bar').classList.toggle('search-bar-show') + if (select(".search-bar-toggle")) { + on("click", ".search-bar-toggle", (e) => { + select(".search-bar").classList.toggle("search-bar-show") }) } /** * Navbar links active state on scroll */ - let navbarlinks = select('#navbar .scrollto', true) + const navbarlinks = select("#navbar .scrollto", true) const navbarlinksActive = () => { - let position = window.scrollY + 200 + const position = window.scrollY + 200 navbarlinks.forEach(navbarlink => { if (!navbarlink.hash) return - let section = select(navbarlink.hash) + const section = select(navbarlink.hash) if (!section) return if (position >= section.offsetTop && position <= (section.offsetTop + section.offsetHeight)) { - navbarlink.classList.add('active') + navbarlink.classList.add("active") } else { - navbarlink.classList.remove('active') + navbarlink.classList.remove("active") } }) } - window.addEventListener('load', navbarlinksActive) + window.addEventListener("load", navbarlinksActive) onscroll(document, navbarlinksActive) /** * Toggle .header-scrolled class to #header when page is scrolled */ - let selectHeader = select('#header') + const selectHeader = select("#header") if (selectHeader) { const headerScrolled = () => { if (window.scrollY > 100) { - selectHeader.classList.add('header-scrolled') + selectHeader.classList.add("header-scrolled") } else { - selectHeader.classList.remove('header-scrolled') + selectHeader.classList.remove("header-scrolled") } } - window.addEventListener('load', headerScrolled) + window.addEventListener("load", headerScrolled) onscroll(document, headerScrolled) } /** * Back to top button */ - let backtotop = select('.back-to-top') + const backtotop = select(".back-to-top") if (backtotop) { const toggleBacktotop = () => { if (window.scrollY > 100) { - backtotop.classList.add('active') + backtotop.classList.add("active") } else { - backtotop.classList.remove('active') + backtotop.classList.remove("active") } } - window.addEventListener('load', toggleBacktotop) + window.addEventListener("load", toggleBacktotop) onscroll(document, toggleBacktotop) } /** * Initiate tooltips */ - var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) - var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { - return new bootstrap.Tooltip(tooltipTriggerEl) - }) + const tooltipTriggerList = [].slice.call(document.querySelectorAll("[data-bs-toggle=\"tooltip\"]")) + const tooltipList = tooltipTriggerList.map((tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl)) /** * Initiate quill editors */ - if (select('.quill-editor-default')) { - new Quill('.quill-editor-default', { - theme: 'snow' + if (select(".quill-editor-default")) { + new Quill(".quill-editor-default", { + theme: "snow" }); } - if (select('.quill-editor-bubble')) { - new Quill('.quill-editor-bubble', { - theme: 'bubble' + if (select(".quill-editor-bubble")) { + new Quill(".quill-editor-bubble", { + theme: "bubble" }); } - if (select('.quill-editor-full')) { + if (select(".quill-editor-full")) { new Quill(".quill-editor-full", { modules: { toolbar: [ @@ -181,24 +179,24 @@ /** * Initiate Bootstrap validation check */ - var needsValidation = document.querySelectorAll('.needs-validation') + const needsValidation = document.querySelectorAll(".needs-validation") Array.prototype.slice.call(needsValidation) - .forEach(function (form) { - form.addEventListener('submit', function (event) { + .forEach((form) => { + form.addEventListener("submit", (event) => { if (!form.checkValidity()) { event.preventDefault() event.stopPropagation() } - form.classList.add('was-validated') + form.classList.add("was-validated") }, false) }) /** * Initiate Datatables */ - const datatables = select('.datatable', true) + const datatables = select(".datatable", true) datatables.forEach(datatable => { new simpleDatatables.DataTable(datatable); }) @@ -206,11 +204,11 @@ /** * Autoresize echart charts */ - const mainContainer = select('#main'); + const mainContainer = select("#main"); if (mainContainer) { setTimeout(() => { - new ResizeObserver(function () { - select('.echart', true).forEach(getEchart => { + new ResizeObserver(() => { + select(".echart", true).forEach(getEchart => { echarts.getInstanceByDom(getEchart).resize(); }) }).observe(mainContainer); @@ -220,11 +218,11 @@ /** * Select all functionality */ - var btnSelectAll = document.getElementById("SelectAllBTN"); - var tableListCheckboxes = document.querySelectorAll(".deviceSelect"); + const btnSelectAll = document.getElementById("SelectAllBTN"); + const tableListCheckboxes = document.querySelectorAll(".deviceSelect"); function itemListCheckChanged(event) { - let isAllChecked = Array.from(tableListCheckboxes).map(itm => itm.checked); + const isAllChecked = Array.from(tableListCheckboxes).map(itm => itm.checked); if (isAllChecked.every(bool => bool == true)) { btnSelectAll.checked = true; btnSelectAll.indeterminate = false; @@ -241,8 +239,8 @@ }) btnSelectAll.addEventListener("click", event => { - let checkedState = event.target.checked; - tableListCheckboxes.forEach(ckeckbox => ckeckbox.checked = checkedState); + const checkedState = event.target.checked; + tableListCheckboxes.forEach(ckeckbox => {ckeckbox.checked = checkedState}); }) /** @@ -256,23 +254,23 @@ * Search form functionality */ window.addEventListener("DOMContentLoaded", () => { - var searchForm = document.getElementById("SearchForm") - var inputSearch = document.querySelector("#SearchForm > input") - var doSearch = true + const searchForm = document.getElementById("SearchForm") + const inputSearch = document.querySelector("#SearchForm > input") + const 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 + const dropdownList = document.getElementById("dropdown-search-list") + const defaultEmptySearch = document.getElementById("dropdown-search-list").innerHTML inputSearch.addEventListener("input", (e) => { clearTimeout(timeoutHandler) - let searchText = e.target.value - if (searchText == '') { + const searchText = e.target.value + if (searchText == "") { document.getElementById("dropdown-search-list").innerHTML = defaultEmptySearch; return } @@ -315,7 +313,7 @@ const device = devices[i]; // See: ereuse_devicehub/resources/device/models.py - var verboseName = `${device.type} ${device.manufacturer} ${device.model}` + const verboseName = `${device.type} ${device.manufacturer} ${device.model}` const templateString = `
  • diff --git a/ereuse_devicehub/static/js/main_inventory.js b/ereuse_devicehub/static/js/main_inventory.js index bf2fdf43..c230363d 100644 --- a/ereuse_devicehub/static/js/main_inventory.js +++ b/ereuse_devicehub/static/js/main_inventory.js @@ -1,7 +1,7 @@ -$(document).ready(function() { - var show_allocate_form = $("#allocateModal").data('show-action-form'); - var show_datawipe_form = $("#datawipeModal").data('show-action-form'); - var show_trade_form = $("#tradeLotModal").data('show-action-form'); +$(document).ready(() => { + const show_allocate_form = $("#allocateModal").data("show-action-form"); + const show_datawipe_form = $("#datawipeModal").data("show-action-form"); + const show_trade_form = $("#tradeLotModal").data("show-action-form"); if (show_allocate_form != "None") { $("#allocateModal .btn-primary").show(); newAllocate(show_allocate_form); @@ -18,7 +18,7 @@ $(document).ready(function() { }) function deviceSelect() { - var devices_count = $(".deviceSelect").filter(':checked').length; + const devices_count = $(".deviceSelect").filter(":checked").length; get_device_list(); if (devices_count == 0) { $("#addingLotModal .pol").show(); @@ -60,7 +60,7 @@ function deviceSelect() { } function removeLot() { - var devices = $(".deviceSelect"); + const devices = $(".deviceSelect"); if (devices.length > 0) { $("#btnRemoveLots .text-danger").show(); } else { @@ -70,10 +70,10 @@ function removeLot() { } function removeTag() { - var devices = $(".deviceSelect").filter(':checked'); - var devices_id = $.map(devices, function(x) { return $(x).attr('data')}); + const devices = $(".deviceSelect").filter(":checked"); + const devices_id = $.map(devices, (x) => $(x).attr("data")); if (devices_id.length == 1) { - var url = "/inventory/tag/devices/"+devices_id[0]+"/del/"; + const url = `/inventory/tag/devices/${devices_id[0]}/del/`; window.location.href = url; } else { $("#unlinkTagAlertModal").click(); @@ -81,8 +81,8 @@ function removeTag() { } function addTag() { - var devices = $(".deviceSelect").filter(':checked'); - var devices_id = $.map(devices, function(x) { return $(x).attr('data')}); + const devices = $(".deviceSelect").filter(":checked"); + const devices_id = $.map(devices, (x) => $(x).attr("data")); if (devices_id.length == 1) { $("#addingTagModal .pol").hide(); $("#addingTagModal .btn-primary").show(); @@ -95,20 +95,20 @@ function addTag() { } function newTrade(action) { - var title = "Trade " - var user_to = $("#user_to").data("email"); - var user_from = $("#user_from").data("email"); - if (action == 'user_from') { - title = 'Trade Incoming'; - $("#user_to").attr('readonly', 'readonly'); - $("#user_from").prop('readonly', false); - $("#user_from").val(''); + let title = "Trade " + const user_to = $("#user_to").data("email"); + const user_from = $("#user_from").data("email"); + if (action == "user_from") { + title = "Trade Incoming"; + $("#user_to").attr("readonly", "readonly"); + $("#user_from").prop("readonly", false); + $("#user_from").val(""); $("#user_to").val(user_to); - } else if (action == 'user_to') { - title = 'Trade Outgoing'; - $("#user_from").attr('readonly', 'readonly'); - $("#user_to").prop('readonly', false); - $("#user_to").val(''); + } else if (action == "user_to") { + title = "Trade Outgoing"; + $("#user_from").attr("readonly", "readonly"); + $("#user_to").prop("readonly", false); + $("#user_to").val(""); $("#user_from").val(user_from); } $("#tradeLotModal #title-action").html(title); @@ -137,44 +137,44 @@ function newDataWipe(action) { } function get_device_list() { - var devices = $(".deviceSelect").filter(':checked'); + const devices = $(".deviceSelect").filter(":checked"); /* Insert the correct count of devices in actions form */ - var devices_count = devices.length; + const devices_count = devices.length; $("#datawipeModal .devices-count").html(devices_count); $("#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) { + const devices_id = $.map(devices, (x) => $(x).attr("data")).join(","); + $.map($(".devicesList"), (x) => { $(x).val(devices_id); }); /* Create a list of devices for human representation */ - var computer = { + const computer = { "Desktop": "", "Laptop": "", }; - 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"); + list_devices = devices.map((x) => { + let typ = $(devices[x]).data("device-type"); + const manuf = $(devices[x]).data("device-manufacturer"); + const dhid = $(devices[x]).data("device-dhid"); if (computer[typ]) { typ = computer[typ]; }; - return typ + " " + manuf + " " + dhid; + return `${typ } ${ manuf } ${ dhid}`; }); - description = $.map(list_devices, function(x) { return x }).join(", "); + description = $.map(list_devices, (x) => x).join(", "); $(".enumeration-devices").html(description); } function export_file(type_file) { - var devices = $(".deviceSelect").filter(':checked'); - var devices_id = $.map(devices, function(x) { return $(x).attr('data-device-dhid')}).join(","); + const devices = $(".deviceSelect").filter(":checked"); + const devices_id = $.map(devices, (x) => $(x).attr("data-device-dhid")).join(","); if (devices_id){ - var url = "/inventory/export/"+type_file+"/?ids="+devices_id; + const url = `/inventory/export/${type_file}/?ids=${devices_id}`; window.location.href = url; } else { $("#exportAlertModal").click(); @@ -200,11 +200,12 @@ async function processSelectedDevices() { */ manage(event, lotID, deviceListID) { event.preventDefault(); - const indeterminate = event.srcElement.indeterminate; - const checked = !event.srcElement.checked; + const srcElement = event.srcElement.parentElement.children[0] + const {indeterminate} = srcElement; + const checked = !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; + const found = this.list.filter(list => list.lotID == lotID)[0]; + const foundIndex = found != undefined ? this.list.findLastIndex(x => x.lotID == found.lotID) : -1; if (checked) { if (found != undefined && found.type == "Remove") { @@ -215,10 +216,9 @@ async function processSelectedDevices() { this.list = this.list.filter(list => list.lotID != lotID); } } else { - this.list.push({ type: "Add", lotID: lotID, devices: deviceListID, isFromIndeterminate: indeterminate }); + this.list.push({ type: "Add", lotID, devices: deviceListID, isFromIndeterminate: indeterminate }); } - } else { - if (found != undefined && found.type == "Add") { + } else if (found != undefined && found.type == "Add") { if (found.isFromIndeterminate == true) { found.type = "Remove"; this.list[foundIndex] = found; @@ -226,9 +226,8 @@ async function processSelectedDevices() { this.list = this.list.filter(list => list.lotID != lotID); } } else { - this.list.push({ type: "Remove", lotID: lotID, devices: deviceListID, isFromIndeterminate: indeterminate }); + this.list.push({ type: "Remove", lotID, devices: deviceListID, isFromIndeterminate: indeterminate }); } - } if (this.list.length > 0) { document.getElementById("ApplyDeviceLots").classList.remove("disabled"); @@ -244,10 +243,10 @@ async function processSelectedDevices() { * @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"); + const 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.attributes.role = "alert"; toast.style = "margin-left: auto; width: fit-content;"; toast.innerHTML = `${title}`; if (toastText && toastText.length > 0) { @@ -265,7 +264,7 @@ async function processSelectedDevices() { * 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 + let 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 { @@ -295,13 +294,13 @@ async function processSelectedDevices() { * Re-render list in table */ async reRenderTable() { - var newRequest = await Api.doRequest(window.location) + const newRequest = await Api.doRequest(window.location) - var tmpDiv = document.createElement("div") + const 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) + const oldTable = Array.from(document.querySelectorAll("table.table > tbody > tr .deviceSelect")).map(x => x.attributes["data-device-dhid"].value) + const 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])) { @@ -312,7 +311,7 @@ async function processSelectedDevices() { } } - var eventClickActions; + let eventClickActions; /** * Generates a list item with a correspondient checkbox state @@ -321,42 +320,47 @@ async function processSelectedDevices() { * @param {Array} selectedDevicesIDs * @param {HTMLElement} target */ - function templateLot(lotID, lot, selectedDevicesIDs, elementTarget, actions) { + function templateLot(lot, elementTarget, actions) { elementTarget.innerHTML = "" + const {id, name, state} = lot; - var htmlTemplate = ` - `; + const htmlTemplate = ` + `; - var existLotList = selectedDevicesIDs.map(selected => lot.devices.includes(selected)); - - var doc = document.createElement('li'); + const 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; + switch (state) { + case "true": + doc.children[0].checked = true; + break; + case "false": + doc.children[0].checked = false; + break; + case "indetermined": + doc.children[0].indeterminate = true; + break; + default: + console.warn("This shouldn't be happend: Lot without state: ", lot); + break; } - doc.children[0].addEventListener('mouseup', (ev) => actions.manage(ev, lotID, selectedDevicesIDs)); + doc.children[0].addEventListener("mouseup", (ev) => actions.manage(ev, id, selectedDevicesIDs)); + doc.children[1].addEventListener("mouseup", (ev) => actions.manage(ev, id, selectedDevicesIDs)); elementTarget.append(doc); } - var listHTML = $("#LotsSelector") + const listHTML = $("#LotsSelector") // Get selected devices - var selectedDevicesIDs = $.map($(".deviceSelect").filter(':checked'), function (x) { return parseInt($(x).attr('data')) }); + const selectedDevicesIDs = $.map($(".deviceSelect").filter(":checked"), (x) => parseInt($(x).attr("data"))); if (selectedDevicesIDs.length <= 0) { - listHTML.html('
  • No devices selected
  • '); + listHTML.html("
  • No devices selected
  • "); return; } // Initialize Actions list, and set checkbox triggers - var actions = new Actions(); + const actions = new Actions(); if (eventClickActions) { document.getElementById("ApplyDeviceLots").removeEventListener(eventClickActions); } @@ -364,21 +368,41 @@ async function processSelectedDevices() { document.getElementById("ApplyDeviceLots").classList.add("disabled"); try { - listHTML.html('
  • ') - var devices = await Api.get_devices(selectedDevicesIDs); - var lots = await Api.get_lots(); + listHTML.html("
  • ") + const devices = await Api.get_devices(selectedDevicesIDs); + let 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)); + + switch (lot.devices.length) { + case 0: + lot.state = "false"; + break; + case selectedDevicesIDs.length: + lot.state = "true"; + break; + default: + lot.state = "indetermined"; + break; + } + return lot; }) - listHTML.html(''); - lots.forEach(lot => templateLot(lot.id, lot, selectedDevicesIDs, listHTML, actions)); + + let lotsList = []; + lotsList.push(lots.filter(lot => lot.state == "true").sort((a,b) => a.name.localeCompare(b.name))); + lotsList.push(lots.filter(lot => lot.state == "indetermined").sort((a,b) => a.name.localeCompare(b.name))); + lotsList.push(lots.filter(lot => lot.state == "false").sort((a,b) => a.name.localeCompare(b.name))); + lotsList = lotsList.flat(); // flat array + + listHTML.html(""); + lotsList.forEach(lot => templateLot(lot, listHTML, actions)); } catch (error) { console.log(error); - listHTML.html('
  • Error feching devices and lots
    (see console for more details)
  • '); + listHTML.html("
  • Error feching devices and lots
    (see console for more details)
  • "); } } diff --git a/ereuse_devicehub/static/js/print.pdf.js b/ereuse_devicehub/static/js/print.pdf.js index 0d6fe6d5..f0b1817c 100644 --- a/ereuse_devicehub/static/js/print.pdf.js +++ b/ereuse_devicehub/static/js/print.pdf.js @@ -1,8 +1,10 @@ $(document).ready(function() { STORAGE_KEY = 'tag-spec-key'; $("#printerType").on("change", change_size); + $(".form-check-input").on("change", change_check); change_size(); - load_size(); + load_settings(); + change_check(); }) function qr_draw(url, id) { @@ -16,27 +18,43 @@ function qr_draw(url, id) { }); } -function save_size() { +function save_settings() { var height = $("#height-tag").val(); var width = $("#width-tag").val(); var sizePreset = $("#printerType").val(); var data = {"height": height, "width": width, "sizePreset": sizePreset}; + data['dhid'] = $("#dhidCheck").prop('checked'); + data['qr'] = $("#qrCheck").prop('checked'); + data['serial_number'] = $("#serialNumberCheck").prop('checked'); + data['manufacturer'] = $("#manufacturerCheck").prop('checked'); + data['model'] = $("#modelCheck").prop('checked'); localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } -function load_size() { +function load_settings() { var data = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (data){ $("#height-tag").val(data.height); $("#width-tag").val(data.width); $("#printerType").val(data.sizePreset); + $("#qrCheck").prop('checked', data.qr); + $("#dhidCheck").prop('checked', data.dhid); + $("#serialNumberCheck").prop('checked', data.serial_number); + $("#manufacturerCheck").prop('checked', data.manufacturer); + $("#modelCheck").prop('checked', data.model); }; } -function reset_size() { +function reset_settings() { localStorage.removeItem(STORAGE_KEY); $("#printerType").val('brotherSmall'); + $("#qrCheck").prop('checked', true); + $("#dhidCheck").prop('checked', true); + $("#serialNumberCheck").prop('checked', false); + $("#manufacturerCheck").prop('checked', false); + $("#modelCheck").prop('checked', false); change_size(); + change_check(); } function change_size() { @@ -50,29 +68,101 @@ function change_size() { } } +function change_check() { + if ($("#dhidCheck").prop('checked')) { + $(".dhid").show(); + } else { + $(".dhid").hide(); + } + if ($("#serialNumberCheck").prop('checked')) { + $(".serial_number").show(); + } else { + $(".serial_number").hide(); + } + if ($("#manufacturerCheck").prop('checked')) { + $(".manufacturer").show(); + } else { + $(".manufacturer").hide(); + } + if ($("#modelCheck").prop('checked')) { + $(".model").show(); + } else { + $(".model").hide(); + } + if ($("#qrCheck").prop('checked')) { + $(".qr").show(); + } else { + $(".qr").hide(); + } +} + function printpdf() { var border = 2; + var line = 5; var height = parseInt($("#height-tag").val()); var width = parseInt($("#width-tag").val()); - img_side = Math.min(height, width) - 2*border; + var img_side = Math.min(height, width) - 2*border; max_tag_side = (Math.max(height, width)/2) + border; if (max_tag_side < img_side) { - max_tag_side = img_side+ 2*border; + max_tag_side = img_side + 2*border; }; min_tag_side = (Math.min(height, width)/2) + border; var last_tag_code = ''; + if ($("#serialNumberCheck").prop('checked')) { + height += line; + }; + if ($("#manufacturerCheck").prop('checked')) { + height += line; + }; + if ($("#modelCheck").prop('checked')) { + height += line; + }; + var pdf = new jsPDF('l', 'mm', [width, height]); $(".tag").map(function(x, y) { if (x != 0){ pdf.addPage(); - console.log(x) }; + var space = line + border; + if ($("#qrCheck").prop('checked')) { + space += img_side; + } var tag = $(y).text(); last_tag_code = tag; - var imgData = $('#'+tag+' img').attr("src"); - pdf.addImage(imgData, 'PNG', border, border, img_side, img_side); - pdf.text(tag, max_tag_side, min_tag_side); + if ($("#qrCheck").prop('checked')) { + var imgData = $('#'+tag+' img').attr("src"); + pdf.addImage(imgData, 'PNG', border, border, img_side, img_side); + }; + + if ($("#dhidCheck").prop('checked')) { + if ($("#qrCheck").prop('checked')) { + pdf.setFontSize(15); + pdf.text(tag, max_tag_side, min_tag_side); + } else { + pdf.setFontSize(15); + pdf.text(tag, border, space); + space += line; + } + }; + if ($("#serialNumberCheck").prop('checked')) { + var sn = $(y).data('serial-number'); + pdf.setFontSize(12); + pdf.text(sn, border, space); + space += line; + }; + if ($("#manufacturerCheck").prop('checked')) { + var sn = $(y).data('manufacturer'); + pdf.setFontSize(12); + pdf.text(sn, border, space); + space += line; + }; + if ($("#modelCheck").prop('checked')) { + var sn = $(y).data('model'); + pdf.setFontSize(8); + pdf.text(sn, border, space); + space += line; + }; }); pdf.save('Tag_'+last_tag_code+'.pdf'); diff --git a/ereuse_devicehub/templates/ereuse_devicehub/base.html b/ereuse_devicehub/templates/ereuse_devicehub/base.html index 8e147828..3b7c94bd 100644 --- a/ereuse_devicehub/templates/ereuse_devicehub/base.html +++ b/ereuse_devicehub/templates/ereuse_devicehub/base.html @@ -29,6 +29,7 @@ + {% endblock main %} diff --git a/ereuse_devicehub/templates/inventory/tag_unlink_device.html b/ereuse_devicehub/templates/inventory/tag_unlink_device.html index 47b5ce8f..af6ed3a6 100644 --- a/ereuse_devicehub/templates/inventory/tag_unlink_device.html +++ b/ereuse_devicehub/templates/inventory/tag_unlink_device.html @@ -18,8 +18,8 @@
    -

    Unlink Tag from Device

    -

    Please enter a code for the tag.

    +

    Unlink Unique Identifier from Device

    +

    Please enter a code for the unique identifier.

    {% if form.form_errors %}

    {% for error in form.form_errors %} @@ -33,10 +33,10 @@ {{ form.csrf_token }}

    - +
    {{ form.tag(class_="form-control") }} -
    Please select tag.
    +
    Please select unique identifier.
    {% if form.tag.errors %}

    diff --git a/ereuse_devicehub/templates/labels/label_detail.html b/ereuse_devicehub/templates/labels/label_detail.html index 75ff3efb..c7e38b96 100644 --- a/ereuse_devicehub/templates/labels/label_detail.html +++ b/ereuse_devicehub/templates/labels/label_detail.html @@ -5,8 +5,8 @@

    Inventory

    @@ -26,7 +26,7 @@
    Type
    -
    {% if tag.provider %}UnNamed Tag{% else %}Named{% endif %}
    +
    {% if tag.provider %}UnNamed Unique Identifier{% else %}Named Unique Identifier{% endif %}
    @@ -43,16 +43,49 @@
    Print Label
    -
    +
    -
    +
    -
    -
    {{ tag.id }}
    +
    +
    + {% if tag.device %} + {{ tag.id }} + {% else %} + {{ tag.id }} + {% endif %} +
    + {% if tag.device %} + + + + {% endif %}
    @@ -84,20 +117,43 @@ mm
    + {% if tag.device %} +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + {% endif %} +
    -
    -
    - Print -
    -
    - Save -
    -
    - Reset -
    -
    -
    diff --git a/ereuse_devicehub/templates/labels/label_list.html b/ereuse_devicehub/templates/labels/label_list.html index 810db5cf..7d184477 100644 --- a/ereuse_devicehub/templates/labels/label_list.html +++ b/ereuse_devicehub/templates/labels/label_list.html @@ -19,18 +19,18 @@
    -