diff --git a/ereuse_devicehub/resources/device/__init__.py b/ereuse_devicehub/resources/device/__init__.py index b8dda2ce..e888c988 100644 --- a/ereuse_devicehub/resources/device/__init__.py +++ b/ereuse_devicehub/resources/device/__init__.py @@ -1,3 +1,5 @@ +from typing import Callable, Iterable, Tuple + from teal.resource import Converters, Resource from ereuse_devicehub.resources.device import schemas @@ -9,7 +11,19 @@ class DeviceDef(Resource): SCHEMA = schemas.Device VIEW = DeviceView ID_CONVERTER = Converters.int - AUTH = True + AUTH = False # We manage this at each view + + def __init__(self, app, + import_name=__name__, static_folder=None, + static_url_path=None, + template_folder='templates', + url_prefix=None, + subdomain=None, + url_defaults=None, + root_path=None, + cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()): + super().__init__(app, import_name, static_folder, static_url_path, template_folder, + url_prefix, subdomain, url_defaults, root_path, cli_commands) class ComputerDef(DeviceDef): diff --git a/ereuse_devicehub/resources/device/models.py b/ereuse_devicehub/resources/device/models.py index 6292c908..c8fd186b 100644 --- a/ereuse_devicehub/resources/device/models.py +++ b/ereuse_devicehub/resources/device/models.py @@ -3,7 +3,7 @@ import pathlib from contextlib import suppress from itertools import chain from operator import attrgetter -from typing import Dict, Set +from typing import Dict, List, Set from boltons import urlutils from citext import CIText @@ -111,8 +111,18 @@ class Device(Thing): def __lt__(self, other): return self.id < other.id - def __repr__(self) -> str: - return '<{0.t} {0.id!r} model={0.model!r} S/N={0.serial_number!r}>'.format(self) + def __str__(self) -> str: + return '{0.t} {0.id}: model {0.model}, S/N {0.serial_number}'.format(self) + + def __format__(self, format_spec): + if not format_spec: + return super().__format__(format_spec) + v = '' + if 't' in format_spec: + v += '{0.t} {0.model}'.format(self) + if 's' in format_spec: + v += '({0.manufacturer}) S/N {0.serial_number}'.format(self) + return v class DisplayMixin: @@ -135,6 +145,14 @@ class DisplayMixin: in pixels. """ + def __format__(self, format_spec: str) -> str: + v = '' + if 't' in format_spec: + v += '{0.t} {0.model}'.format(self) + if 's' in format_spec: + v += '({0.manufacturer}) S/N {0.serial_number} – {0.size}in {0.technology}' + return v + class Computer(Device): id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) @@ -144,6 +162,46 @@ class Computer(Device): def events(self) -> list: return sorted(chain(super().events, self.events_parent), key=attrgetter('created')) + @property + def ram_size(self) -> int: + """The total of RAM memory the computer has.""" + return sum(ram.size 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 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)) + + @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)) + + @property + def network_speeds(self) -> List[int]: + """Returns two speeds: the first for the eth and the + second for the wifi networks, or 0 respectively if not found. + """ + speeds = [0, 0] + for net in (c for c in self.components if isinstance(c, NetworkAdapter)): + speeds[net.wireless] = max(net.speed or 0, speeds[net.wireless]) + return speeds + + def __format__(self, format_spec): + if not format_spec: + return super().__format__(format_spec) + v = '' + if 't' in format_spec: + v += '{0.chassis} {0.model}'.format(self) + elif 's' in format_spec: + v += '({0.manufacturer}) S/N {0.serial_number}'.format(self) + return v + class Desktop(Computer): pass @@ -260,6 +318,12 @@ class DataStorage(JoinedComponentTableMixin, Component): """ interface = Column(DBEnum(DataStorageInterface)) + def __format__(self, format_spec): + v = super().__format__(format_spec) + if 's' in format_spec: + v += ' – {} GB'.format(self.size // 1000) + return v + class HardDrive(DataStorage): pass @@ -290,6 +354,12 @@ class NetworkMixin: Whether it is a wireless interface. """ + def __format__(self, format_spec): + v = super().__format__(format_spec) + if 's' in format_spec: + v += ' – {} Mbps'.format(self.speed) + return v + class NetworkAdapter(JoinedComponentTableMixin, NetworkMixin, Component): pass diff --git a/ereuse_devicehub/resources/device/models.pyi b/ereuse_devicehub/resources/device/models.pyi index 5dbf9782..a5bcb688 100644 --- a/ereuse_devicehub/resources/device/models.pyi +++ b/ereuse_devicehub/resources/device/models.pyi @@ -76,6 +76,29 @@ class Computer(DisplayMixin, Device): self.events_parent = ... # type: Set[Event] self.chassis = ... # type: ComputerChassis + @property + def events(self) -> List: + pass + + @property + def ram_size(self) -> int: + pass + + @property + def data_storage_size(self) -> int: + pass + + @property + def processor_model(self) -> str: + pass + + @property + def graphic_card_model(self) -> str: + pass + + @property + def network_speeds(self) -> List[int]: + pass class Desktop(Computer): pass diff --git a/ereuse_devicehub/resources/device/templates/devices/layout.html b/ereuse_devicehub/resources/device/templates/devices/layout.html new file mode 100644 index 00000000..ef1fa6fb --- /dev/null +++ b/ereuse_devicehub/resources/device/templates/devices/layout.html @@ -0,0 +1,97 @@ +{% import 'devices/macros.html' as macros %} + + + + + + + Devicehub | {{ device.__format__('t') }} + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Range
+
+ CPU – {{ device.processor_model }} + {{ macros.component_type(device.components, 'Processor') }} +
+
+
+ RAM – {{ device.ram_size // 1000 }} GB + {{ macros.component_type(device.components, 'RamModule') }} +
+
//range//
+
+ Data Storage – {{ device.data_storage_size // 1000 }} GB + {{ macros.component_type(device.components, 'SolidStateDrive') }} + {{ macros.component_type(device.components, 'HardDrive') }} +
+
//range//
+
+ Graphics – {{ device.graphic_card_model }} + {{ macros.component_type(device.components, 'GraphicCard') }} +
+
//range//
+
+ Network – + {% if device.network_speeds[0] %} + Ethernet of {{ device.network_speeds[0] }} Mbps + {% endif %} + {% if device.network_speeds[0] and device.network_speeds[1] %} + + + {% endif %} + {% if device.network_speeds[1] %} + WiFi of {{ device.network_speeds[1] }} Mbps + {% endif %} + + {{ macros.component_type(device.components, 'NetworkAdapter') }} +
+
+
+ +
+ + + diff --git a/ereuse_devicehub/resources/device/views.py b/ereuse_devicehub/resources/device/views.py index 2226cdc6..e6ae99ab 100644 --- a/ereuse_devicehub/resources/device/views.py +++ b/ereuse_devicehub/resources/device/views.py @@ -1,9 +1,13 @@ +import datetime + import marshmallow -from flask import current_app as app +from flask import current_app as app, render_template, request from flask.json import jsonify from flask_sqlalchemy import Pagination +from teal.cache import cache from teal.resource import View +from ereuse_devicehub import auth from ereuse_devicehub.resources.device.models import Device, Manufacturer @@ -27,9 +31,21 @@ class DeviceView(View): def one(self, id: int): """Gets one device.""" + if not request.authorization: + return self.one_public(id) + else: + return self.one_private(id) + + def one_public(self, id: int): + device = Device.query.filter_by(id=id).one() + return render_template('devices/layout.html', device=device) + + @auth.Auth.requires_auth + def one_private(self, id: int): device = Device.query.filter_by(id=id).one() return self.schema.jsonify(device) + @auth.Auth.requires_auth def find(self, args: dict): """Gets many devices.""" return self.schema.jsonify(Device.query, many=True) @@ -41,6 +57,7 @@ class ManufacturerView(View): # Disallow like operators validate=lambda x: '%' not in x and '_' not in x) + @cache(datetime.timedelta(days=1)) def find(self, args: dict): name = args['name'] manufacturers = Manufacturer.query \ diff --git a/ereuse_devicehub/resources/enums.py b/ereuse_devicehub/resources/enums.py index 6340a6dc..3aed8e6e 100644 --- a/ereuse_devicehub/resources/enums.py +++ b/ereuse_devicehub/resources/enums.py @@ -166,12 +166,18 @@ class RamInterface(Enum): DDR5 = 'DDR5 SDRAM' DDR6 = 'DDR6 SDRAM' + def __str__(self): + return self.value + @unique class RamFormat(Enum): DIMM = 'DIMM' SODIMM = 'SODIMM' + def __str__(self): + return self.value + @unique class DataStorageInterface(Enum): @@ -179,6 +185,9 @@ class DataStorageInterface(Enum): USB = 'USB' PCI = 'PCI' + def __str__(self): + return self.value + @unique class DisplayTech(Enum): @@ -190,15 +199,18 @@ class DisplayTech(Enum): OLED = 'Organic light-emitting diode (OLED)' AMOLED = 'Organic light-emitting diode (AMOLED)' + def __str__(self): + return self.name + @unique class ComputerChassis(Enum): """The chassis of a computer.""" Tower = 'Tower' Docking = 'Docking' - AllInOne = 'AllInOne' + AllInOne = 'All in one' Microtower = 'Microtower' - PizzaBox = 'PizzaBox' + PizzaBox = 'Pizza box' Lunchbox = 'Lunchbox' Stick = 'Stick' Netbook = 'Netbook' @@ -207,7 +219,10 @@ class ComputerChassis(Enum): Convertible = 'Convertible' Detachable = 'Detachable' Tablet = 'Tablet' - Virtual = 'Virtual: A device with no chassis, probably non-physical.' + Virtual = 'Non-physical device' + + def __format__(self, format_spec): + return self.value.lower() class ReceiverRole(Enum): diff --git a/requirements.txt b/requirements.txt index b612d4e8..6ecbef7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ requests==2.19.1 requests-mock==1.5.2 SQLAlchemy==1.2.11 SQLAlchemy-Utils==0.33.3 -teal==0.2.0a20 +teal==0.2.0a21 webargs==4.0.0 Werkzeug==0.14.1 sqlalchemy-citext==1.3.post0 diff --git a/setup.py b/setup.py index 83ae609c..e39e665d 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ setup( long_description=long_description, long_description_content_type='text/markdown', install_requires=[ - 'teal>=0.2.0a20', # teal always first + 'teal>=0.2.0a21', # teal always first 'click', 'click-spinner', 'ereuse-rate==0.0.2', diff --git a/tests/test_device.py b/tests/test_device.py index 693456a5..2ce2d6dd 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -1,20 +1,22 @@ +import datetime from datetime import timedelta from uuid import UUID import pytest from colour import Color from ereuse_utils.naming import Naming +from ereuse_utils.test import ANY from pytest import raises from sqlalchemy.util import OrderedSet from teal.db import ResourceNotFound -from ereuse_devicehub.client import UserClient +from ereuse_devicehub.client import Client, UserClient from ereuse_devicehub.db import db from ereuse_devicehub.devicehub import Devicehub from ereuse_devicehub.resources.agent.models import Person from ereuse_devicehub.resources.device.exceptions import NeedsId -from ereuse_devicehub.resources.device.models import Component, ComputerMonitor, Desktop, Device, \ - GraphicCard, Laptop, Motherboard, NetworkAdapter +from ereuse_devicehub.resources.device.models import Component, ComputerMonitor, DataStorage, \ + Desktop, Device, GraphicCard, Laptop, Motherboard, NetworkAdapter from ereuse_devicehub.resources.device.schemas import Device as DeviceS from ereuse_devicehub.resources.device.search import DeviceSearch from ereuse_devicehub.resources.device.sync import MismatchBetweenTags, MismatchBetweenTagsAndHid, \ @@ -470,11 +472,42 @@ def test_device_search_all_devices_token_if_empty(app: Devicehub, user: UserClie def test_manufacturer(user: UserClient): - m, _ = user.get(res='Manufacturer', query=[('name', 'asus')]) + m, r = user.get(res='Manufacturer', query=[('name', 'asus')]) assert m == {'items': [{'name': 'Asus', 'url': 'https://en.wikipedia.org/wiki/Asus'}]} + assert r.cache_control.public + assert r.expires > datetime.datetime.now() @pytest.mark.xfail(reason='Develop functionality') def test_manufacturer_enforced(): """Ensures that non-computer devices can submit only manufacturers from the Manufacturer table.""" + + +def test_device_properties_format(app: Devicehub, user: UserClient): + user.post(file('asus-eee-1000h.snapshot.11'), res=m.Snapshot) + with app.app_context(): + pc = Laptop.query.one() # type: Laptop + assert format(pc) == 'Laptop 1: model 1000h, S/N 94oaaq021116' + assert format(pc, 't') == 'netbook 1000h' + assert format(pc, 's') == '(asustek computer inc.) S/N 94oaaq021116' + assert pc.ram_size == 1024 + assert pc.data_storage_size == 152627 + assert pc.graphic_card_model == 'mobile 945gse express integrated graphics controller' + assert pc.processor_model == 'intel atom cpu n270 @ 1.60ghz' + net = next(c for c in pc.components if isinstance(c, NetworkAdapter)) + assert format(net) == 'NetworkAdapter 2: model ar8121/ar8113/ar8114 ' \ + 'gigabit or fast ethernet, S/N 00:24:8c:7f:cf:2d' + assert format(net, 't') == 'NetworkAdapter ar8121/ar8113/ar8114 gigabit or fast ethernet' + assert format(net, 's') == '(qualcomm atheros) S/N 00:24:8c:7f:cf:2d – 100 Mbps' + hdd = next(c for c in pc.components if isinstance(c, DataStorage)) + assert format(hdd) == 'HardDrive 7: model st9160310as, S/N 5sv4tqa6' + assert format(hdd, 't') == 'HardDrive st9160310as' + assert format(hdd, 's') == '(seagate) S/N 5sv4tqa6 – 152 GB' + + +def test_device_public(user: UserClient, client: Client): + s, _ = user.post(file('asus-eee-1000h.snapshot.11'), res=m.Snapshot) + html, _ = client.get(res=Device, item=s['device']['id'], accept=ANY) + assert 'intel atom cpu n270 @ 1.60ghz' in html + assert 'S/N 00:24:8c:7f:cf:2d – 100 Mbps' in html