diff --git a/README.md b/README.md index ff281f8b..3386b45d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ The requirements are: - PostgreSQL 9.6 or higher with pgcrypto and ltree. In debian 9 is `# apt install postgresql-contrib` - passlib. In debian 9 is `# apt install python3-passlib`. +- Weasyprint requires some system packages. + [Their docs explain which ones and how to install them](http://weasyprint.readthedocs.io/en/stable/install.html). Install Devicehub with *pip*: `pip3 install ereuse-devicehub -U --pre`. diff --git a/ereuse_devicehub/config.py b/ereuse_devicehub/config.py index 59879dbe..a77fc614 100644 --- a/ereuse_devicehub/config.py +++ b/ereuse_devicehub/config.py @@ -9,6 +9,7 @@ from teal.utils import import_resource from ereuse_devicehub.resources import agent, event, lot, tag, user from ereuse_devicehub.resources.device import definitions +from ereuse_devicehub.resources.documents import documents from ereuse_devicehub.resources.enums import PriceSoftware, RatingSoftware @@ -18,7 +19,9 @@ class DevicehubConfig(Config): import_resource(user), import_resource(tag), import_resource(agent), - import_resource(lot))) + import_resource(lot), + import_resource(documents)) + ) PASSWORD_SCHEMES = {'pbkdf2_sha256'} # type: Set[str] SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/devicehub' # type: str SCHEMA = 'dhub' diff --git a/ereuse_devicehub/resources/device/models.py b/ereuse_devicehub/resources/device/models.py index 39ab0cf2..062acfd2 100644 --- a/ereuse_devicehub/resources/device/models.py +++ b/ereuse_devicehub/resources/device/models.py @@ -343,7 +343,7 @@ class Computer(Device): @property def privacy(self): """Returns the privacy of all DataStorage components when - it is None. + it is not None. """ return set( privacy for privacy in @@ -500,7 +500,7 @@ class DataStorage(JoinedComponentTableMixin, Component): def __format__(self, format_spec): v = super().__format__(format_spec) if 's' in format_spec: - v += ' – {} GB'.format(self.size // 1000) + v += ' – {} GB'.format(self.size // 1000 if self.size else '?') return v diff --git a/ereuse_devicehub/resources/device/models.pyi b/ereuse_devicehub/resources/device/models.pyi index 7a893ad2..a3097889 100644 --- a/ereuse_devicehub/resources/device/models.pyi +++ b/ereuse_devicehub/resources/device/models.pyi @@ -19,6 +19,7 @@ from ereuse_devicehub.resources.image.models import ImageList from ereuse_devicehub.resources.lot.models import Lot from ereuse_devicehub.resources.models import Thing from ereuse_devicehub.resources.tag import Tag +from ereuse_devicehub.resources.tag.model import Tags class Device(Thing): @@ -55,7 +56,7 @@ class Device(Thing): self.events_multiple = ... # type: Set[e.EventWithMultipleDevices] self.events_one = ... # type: Set[e.EventWithOneDevice] self.images = ... # type: ImageList - self.tags = ... # type: Set[Tag] + self.tags = ... # type: Tags[Tag] self.lots = ... # type: Set[Lot] self.production_date = ... # type: datetime diff --git a/ereuse_devicehub/resources/device/templates/devices/layout.html b/ereuse_devicehub/resources/device/templates/devices/layout.html index 0f7d0957..ad127bc8 100644 --- a/ereuse_devicehub/resources/device/templates/devices/layout.html +++ b/ereuse_devicehub/resources/device/templates/devices/layout.html @@ -2,220 +2,222 @@ - - - - Devicehub | {{ device.__format__('t') }} + + + + Devicehub | {{ device.__format__('t') }}
- +
- +
-

- This is your {{ device.t }}. -

+

+ This is your {{ device.t }}. +

-

- {% if device.trading %} - {{ device.trading }} - {% endif %} - {% if device.trading and device.physical %} - and - {% endif %} - {% if device.physical %} - {{ device.physical }} - {% endif %} -

-
-
-

You can verify the originality of your device.

-

- If your device comes with the following tag - - it means it has been refurbished by an eReuse.org - certified organization. -

-

- The tag is special –illuminate it with the torch of - your phone for 6 seconds and it will react like in - the following image: - - This is proof that this device is genuine. -

-
-
-

These are the specifications

-
- - - - - - - - - {% if device.processor_model %} - - - - - {% endif %} - {% if device.ram_size %} - - - - - {% endif %} - {% if device.data_storage_size %} - - - - - {% endif %} - {% if device.graphic_card_model %} - - - - - {% endif %} - {% if device.network_speeds %} - - - - - {% endif %} - {% if device.rate %} - - - - - {% endif %} - {% if device.rate and device.rate.price %} - - - - - {% endif %} - {% if device.price %} - - - - - {% endif %} - -
Range
- CPU – {{ device.processor_model }} - - {% if device.rate %} - {{ device.rate.processor_range }} - ({{ device.rate.processor }}) - {% endif %} -
- RAM – {{ device.ram_size // 1000 }} GB - {{ macros.component_type(device.components, 'RamModule') }} - - {% if device.rate %} - {{ device.rate.ram_range }} - ({{ device.rate.ram }}) - {% endif %} -
- Data Storage – {{ device.data_storage_size // 1000 }} GB - {{ macros.component_type(device.components, 'SolidStateDrive') }} - {{ macros.component_type(device.components, 'HardDrive') }} - - {% if device.rate %} - {{ device.rate.data_storage_range }} - ({{ device.rate.data_storage }}) - {% endif %} -
- Graphics – {{ device.graphic_card_model }} - {{ macros.component_type(device.components, 'GraphicCard') }} -
- Network – - {% if device.network_speeds[0] %} - Ethernet - {% if device.network_speeds[0] != None %} - max. {{ device.network_speeds[0] }} Mbps - {% endif %} - {% endif %} - {% if device.network_speeds[0] and device.network_speeds[1] %} - + - {% endif %} - {% if device.network_speeds[1] %} - WiFi - {% if device.network_speeds[1] != None %} - max. {{ device.network_speeds[1] }} Mbps - {% endif %} - {% endif %} - {{ macros.component_type(device.components, 'NetworkAdapter') }} -
- Total rate - - {{ device.rate.rating_range }} - ({{ device.rate.rating }}) -
- Algorithm price - - {{ device.rate.price }} -
- Actual price - - {{ device.price }} -
+

+ {% if device.trading %} + {{ device.trading }} + {% endif %} + {% if device.trading and device.physical %} + and + {% endif %} + {% if device.physical %} + {{ device.physical }} + {% endif %} +

+
+
+

You can verify the originality of your device.

+

+ If your device comes with the following tag + + it means it has been refurbished by an eReuse.org + certified organization. +

+

+ The tag is special –illuminate it with the torch of + your phone for 6 seconds and it will react like in + the following image: + + This is proof that this device is genuine. +

+
+
+

These are the specifications

+
+ + + + + + + + + {% if device.processor_model %} + + + + + {% endif %} + {% if device.ram_size %} + + + + + {% endif %} + {% if device.data_storage_size %} + + + + + {% endif %} + {% if device.graphic_card_model %} + + + + + {% endif %} + {% if device.network_speeds %} + + + + + {% endif %} + {% if device.rate %} + + + + + {% endif %} + {% if device.rate and device.rate.price %} + + + + + {% endif %} + {% if device.price %} + + + + + {% endif %} + +
Range
+ CPU – {{ device.processor_model }} + + {% if device.rate %} + {{ device.rate.processor_range }} + ({{ device.rate.processor }}) + {% endif %} +
+ RAM – {{ device.ram_size // 1000 }} GB + {{ macros.component_type(device.components, 'RamModule') }} + + {% if device.rate %} + {{ device.rate.ram_range }} + ({{ device.rate.ram }}) + {% endif %} +
+ Data Storage – {{ device.data_storage_size // 1000 }} GB + {{ macros.component_type(device.components, 'SolidStateDrive') }} + {{ macros.component_type(device.components, 'HardDrive') }} + + {% if device.rate %} + {{ device.rate.data_storage_range }} + ({{ device.rate.data_storage }}) + {% endif %} +
+ Graphics – {{ device.graphic_card_model }} + {{ macros.component_type(device.components, 'GraphicCard') }} +
+ Network – + {% if device.network_speeds[0] %} + Ethernet + {% if device.network_speeds[0] != None %} + max. {{ device.network_speeds[0] }} Mbps + {% endif %} + {% endif %} + {% if device.network_speeds[0] and device.network_speeds[1] %} + + + {% endif %} + {% if device.network_speeds[1] %} + WiFi + {% if device.network_speeds[1] != None %} + max. {{ device.network_speeds[1] }} Mbps + {% endif %} + {% endif %} + {{ macros.component_type(device.components, 'NetworkAdapter') }} +
+ Total rate + + {{ device.rate.rating_range }} + ({{ device.rate.rating }}) +
+ Algorithm price + + {{ device.rate.price }} +
+ Actual price + + {{ device.price }} +
+
+

This is the traceability log of your device

+
+ Latest one. +
+
    + {% for event in device.events|reverse %} +
  1. + + {{ event.type }} + + — + {{ event }} +
    +
    + + {{ event._date_str }} +
    -

    This is the traceability log of your device

    -
    - Latest one. -
    -
      - {% for event in device.events|reverse %} -
    1. - - {{ event.type }} - - — - {{ event }} -
      -
      - - {{ event._date_str }} - -
      - -
    2. - {% endfor %} -
    -
    - Oldest one. -
    -
-
+ {% if event.certificate %} + See the certificate + {% endif %} + + {% endfor %} + +
+ Oldest one. +
+
+
diff --git a/ereuse_devicehub/resources/device/views.py b/ereuse_devicehub/resources/device/views.py index c13ae11a..0eead26c 100644 --- a/ereuse_devicehub/resources/device/views.py +++ b/ereuse_devicehub/resources/device/views.py @@ -15,7 +15,7 @@ from ereuse_devicehub.query import SearchQueryParser from ereuse_devicehub.resources import search from ereuse_devicehub.resources.device.models import Device, Manufacturer from ereuse_devicehub.resources.device.search import DeviceSearch -from ereuse_devicehub.resources.event.models import Rate +from ereuse_devicehub.resources.event import models as events from ereuse_devicehub.resources.lot.models import LotDeviceDescendants from ereuse_devicehub.resources.tag.model import Tag @@ -31,9 +31,9 @@ class OfType(f.Str): class RateQ(query.Query): - rating = query.Between(Rate.rating, f.Float()) - appearance = query.Between(Rate.appearance, f.Float()) - functionality = query.Between(Rate.functionality, f.Float()) + rating = query.Between(events.Rate.rating, f.Float()) + appearance = query.Between(events.Rate.appearance, f.Float()) + functionality = query.Between(events.Rate.functionality, f.Float()) class TagQ(query.Query): @@ -46,11 +46,12 @@ class LotQ(query.Query): class Filters(query.Query): + id = query.Or(query.Equal(Device.id, fields.Integer())) type = query.Or(OfType(Device.type)) model = query.ILike(Device.model) manufacturer = query.ILike(Device.manufacturer) serialNumber = query.ILike(Device.serial_number) - rating = query.Join(Device.id == Rate.device_id, RateQ) + rating = query.Join(Device.id == events.Rate.device_id, RateQ) tag = query.Join(Device.id == Tag.device_id, TagQ) # todo This part of the query is really slow # And forces usage of distinct, as it returns many rows @@ -80,21 +81,13 @@ class DeviceView(View): parameters: - name: id type: integer - in: path + in: path} description: The identifier of the device. responses: 200: description: The device or devices. """ - # Majority of code is from teal - if id: - response = self.one(id) - else: - args = self.QUERY_PARSER.parse(self.find_args, - request, - locations=('querystring',)) - response = self.find(args) - return response + return super().get(id) def one(self, id: int): """Gets one device.""" @@ -115,17 +108,8 @@ class DeviceView(View): @auth.Auth.requires_auth def find(self, args: dict): """Gets many devices.""" - search_p = args.get('search', None) - query = Device.query.distinct() # todo we should not force to do this if the query is ok - if search_p: - properties = DeviceSearch.properties - tags = DeviceSearch.tags - query = query.join(DeviceSearch).filter( - search.Search.match(properties, search_p) | search.Search.match(tags, search_p) - ).order_by( - search.Search.rank(properties, search_p) + search.Search.rank(tags, search_p) - ) - query = query.filter(*args['filter']).order_by(*args['sort']) + # Compute query + query = self.query(args) devices = query.paginate(page=args['page'], per_page=30) # type: Pagination ret = { 'items': self.schema.dump(devices.items, many=True, nested=1), @@ -142,6 +126,19 @@ class DeviceView(View): } return jsonify(ret) + def query(self, args): + query = Device.query.distinct() # todo we should not force to do this if the query is ok + search_p = args.get('search', None) + if search_p: + properties = DeviceSearch.properties + tags = DeviceSearch.tags + query = query.join(DeviceSearch).filter( + search.Search.match(properties, search_p) | search.Search.match(tags, search_p) + ).order_by( + search.Search.rank(properties, search_p) + search.Search.rank(tags, search_p) + ) + return query.filter(*args['filter']).order_by(*args['sort']) + class ManufacturerView(View): class FindArgs(marshmallow.Schema): diff --git a/ereuse_devicehub/resources/documents/__init__.py b/ereuse_devicehub/resources/documents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ereuse_devicehub/resources/documents/documents.py b/ereuse_devicehub/resources/documents/documents.py new file mode 100644 index 00000000..34121d8c --- /dev/null +++ b/ereuse_devicehub/resources/documents/documents.py @@ -0,0 +1,126 @@ +import enum +import uuid +from typing import Callable, Iterable, Tuple + +import boltons +import flask +import flask_weasyprint +import teal.marshmallow +from boltons import urlutils +from teal.resource import Resource + +from ereuse_devicehub.db import db +from ereuse_devicehub.resources.device import models as devs +from ereuse_devicehub.resources.device.views import DeviceView +from ereuse_devicehub.resources.event import models as evs + + +class Format(enum.Enum): + HTML = 'HTML' + PDF = 'PDF' + + +class DocumentView(DeviceView): + class FindArgs(DeviceView.FindArgs): + format = teal.marshmallow.EnumField(Format, missing=None) + + def get(self, id): + """Get a collection of resources or a specific one. + --- + parameters: + - name: id + in: path + description: The identifier of the resource. + type: string + required: false + responses: + 200: + description: Return the collection or the specific one. + """ + args = self.QUERY_PARSER.parse(self.find_args, + flask.request, + locations=('querystring',)) + if id: + # todo we assume we can pass both device id and event id + # for certificates... how is it going to end up being? + try: + id = uuid.UUID(id) + except ValueError: + try: + id = int(id) + except ValueError: + raise teal.marshmallow.ValidationError('Document must be an ID or UUID.') + else: + query = devs.Device.query.filter_by(id=id) + else: + query = evs.Event.query.filter_by(id=id) + else: + flask.current_app.auth.requires_auth(lambda: None)() # todo not nice + query = self.query(args) + + type = urlutils.URL(flask.request.url).path_parts[-2] + if type == 'erasures': + template = self.erasure(query) + if args.get('format') == Format.PDF: + res = flask_weasyprint.render_pdf( + flask_weasyprint.HTML(string=template), download_filename='{}.pdf'.format(type) + ) + else: + res = flask.make_response(template) + return res + + @staticmethod + def erasure(query: db.Query): + def erasures(): + for model in query: + if isinstance(model, devs.Computer): + for erasure in model.privacy: + yield erasure + elif isinstance(model, devs.DataStorage): + erasure = model.privacy + if erasure: + yield erasure + else: + assert isinstance(model, evs.EraseBasic) + yield model + + url_pdf = boltons.urlutils.URL(flask.request.url) + url_pdf.query_params['format'] = 'PDF' + url_web = boltons.urlutils.URL(flask.request.url) + url_web.query_params['format'] = 'HTML' + params = { + 'title': 'Erasure Certificate', + 'erasures': tuple(erasures()), + 'url_pdf': url_pdf.to_text(), + 'url_web': url_web.to_text() + } + return flask.render_template('documents/erasure.html', **params) + + +class DocumentDef(Resource): + __type__ = 'Document' + SCHEMA = None + VIEW = None # We do not want to create default / documents endpoint + AUTH = False + + def __init__(self, app, + import_name=__name__, + static_folder='static', + 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) + d = {'id': None} + get = {'GET'} + + view = DocumentView.as_view('main', definition=self, auth=app.auth) + if self.AUTH: + view = app.auth.requires_auth(view) + self.add_url_rule('/erasures/', defaults=d, view_func=view, methods=get) + self.add_url_rule('/erasures/<{}:{}>'.format(self.ID_CONVERTER.value, self.ID_NAME), + view_func=view, methods=get) diff --git a/ereuse_devicehub/resources/documents/static/print.css b/ereuse_devicehub/resources/documents/static/print.css new file mode 100644 index 00000000..042b1014 --- /dev/null +++ b/ereuse_devicehub/resources/documents/static/print.css @@ -0,0 +1,48 @@ +/** +Devicehub uses Weasyprint to generate the PDF. + +This print.css provides helpful markup to generate the PDF (pages, margins, etc). + +The most important things to remember are: +- DOM elements with a class `page-break` create a new page. +- DOM elements with a class `no-page-break` do not break between pages. +- Pages are in A4 by default an 12px. + */ +body { + background-color: transparent !important; + font-size: 12px !important +} + +@page { + size: A4; + @bottom-right { + font-family: "Source Sans Pro", Calibri, Candra, Sans serif; + margin-right: 3em; + content: counter(page) " / " counter(pages) !important + } +} + +/* Sections produce a new page*/ +.page-break:not(section:first-of-type) { + page-break-before: always +} + +/* Do not break divs with not-break between pages*/ +.no-page-break { + page-break-inside: avoid +} + +.print-only, .print-only * { + display: none +} + +/* Do not print divs with no-print in them */ +@media print { + .no-print, .no-print * { + display: none !important; + } + + .print-only, .print-only * { + display: initial; + } +} diff --git a/ereuse_devicehub/resources/documents/templates/documents/erasure.html b/ereuse_devicehub/resources/documents/templates/documents/erasure.html new file mode 100644 index 00000000..908bdbe8 --- /dev/null +++ b/ereuse_devicehub/resources/documents/templates/documents/erasure.html @@ -0,0 +1,89 @@ +{% extends "documents/layout.html" %} +{% block body %} +
+

Resumé

+ + + + + + + + + + + + + {% for erasure in erasures %} + + + + + + + + + {% endfor %} + +
S/NTagsS/N Data StorageType of erasureResultDate
+ {{ erasure.parent.serial_number.upper() }} + + {{ erasure.parent.tags }} + + {{ erasure.device.serial_number.upper() }} + + {{ erasure.type }} + + {{ erasure.severity }} + + {{ erasure.date_str }} +
+
+
+

Details

+ {% for erasure in erasures %} +
+

{{ erasure.device.__format__('t') }}

+
+
Data storage:
+
{{ erasure.device.__format__('ts') }}
+
Computer:
+
{{ erasure.parent.__format__('ts') }}
+
Tags:
+
{{ erasure.parent.tags }}
+
Erasure:
+
{{ erasure.__format__('ts') }}
+
Erasure steps:
+
+
    + {% for step in erasure.steps %} +
  1. {{ step.__format__('') }}
  2. + {% endfor %} +
+
+
+
+ {% endfor %} +
+
+

Glossary

+
+
Erase Basic
+
+ A software-based fast non-100%-secured way of erasing data storage, + using shred. +
+
Erase Sectors
+
+ A secured-way of erasing data storages, checking sector-by-sector + the erasure, using badblocks. +
+
+
+
+ Click here to download the PDF. +
+ +{% endblock %} diff --git a/ereuse_devicehub/resources/documents/templates/documents/layout.html b/ereuse_devicehub/resources/documents/templates/documents/layout.html new file mode 100644 index 00000000..5bf2aa56 --- /dev/null +++ b/ereuse_devicehub/resources/documents/templates/documents/layout.html @@ -0,0 +1,26 @@ +{% import 'devices/macros.html' as macros %} + + + + + + + + Devicehub | {{ title }} + + +
+
+ +
+ {% block body %}{% endblock %} +
+ + diff --git a/ereuse_devicehub/resources/enums.py b/ereuse_devicehub/resources/enums.py index 6d2f344a..8788f00b 100644 --- a/ereuse_devicehub/resources/enums.py +++ b/ereuse_devicehub/resources/enums.py @@ -310,6 +310,9 @@ class Severity(IntEnum): m = '❌' return m + def __format__(self, format_spec): + return str(self) + class PhysicalErasureMethod(Enum): """Methods of physically erasing the data-storage, usually diff --git a/ereuse_devicehub/resources/event/models.py b/ereuse_devicehub/resources/event/models.py index 2caa7995..14949664 100644 --- a/ereuse_devicehub/resources/event/models.py +++ b/ereuse_devicehub/resources/event/models.py @@ -2,7 +2,7 @@ from collections import Iterable from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_EVEN, ROUND_UP from distutils.version import StrictVersion -from typing import Set, Union +from typing import Optional, Set, Union from uuid import uuid4 import inflection @@ -157,11 +157,21 @@ class Event(Thing): would point to the computer that contained this data storage, if any. """ + @property + def elapsed(self): + """Returns the elapsed time with seconds precision.""" + t = self.end_time - self.start_time + return timedelta(seconds=t.seconds) + @property def url(self) -> urlutils.URL: """The URL where to GET this event.""" return urlutils.URL(url_for_resource(Event, item_id=self.id)) + @property + def certificate(self) -> Optional[urlutils.URL]: + return None + # noinspection PyMethodParameters @declared_attr def __mapper_args__(cls): @@ -193,7 +203,7 @@ class Event(Thing): return start_time @property - def _date_str(self): + def date_str(self): return '{:%c}'.format(self.end_time or self.created) def __str__(self) -> str: @@ -311,20 +321,44 @@ class EraseBasic(JoinedWithOneDeviceMixin, EventWithOneDevice): Devicehub automatically shows the standards that each erasure follows. """ + method = 'Shred' + """The method or software used to destroy the data.""" @property def standards(self): """A set of standards that this erasure follows.""" return ErasureStandards.from_data_storage(self) + @property + def certificate(self): + """The URL of this erasure certificate.""" + # todo will this url_for_resoure work for other resources? + return urlutils.URL(url_for_resource('Document', item_id=self.id)) + def __str__(self) -> str: - return '{} on {}.'.format(self.severity, self.end_time) + return '{} on {}.'.format(self.severity, self.date_str) + + def __format__(self, format_spec: str) -> str: + v = '' + if 't' in format_spec: + v += '{} {}'.format(self.type, self.severity) + if 't' in format_spec and 's' in format_spec: + v += '. ' + if 's' in format_spec: + if self.standards: + std = 'with standards {}'.format(self.standards) + else: + std = 'no standard' + v += 'Method used: {}, {}. '.format(self.method, std) + v += '{} elapsed, on {}'.format(self.elapsed, self.date_str) + return v class EraseSectors(EraseBasic): """A secured-way of erasing data storages, checking sector-by-sector the erasure, using `badblocks `_. """ + method = 'Badblocks' class ErasePhysical(EraseBasic): @@ -348,6 +382,12 @@ class Step(db.Model): order_by=num, collection_class=ordering_list('num'))) + @property + def elapsed(self): + """Returns the elapsed time with seconds precision.""" + t = self.end_time - self.start_time + return timedelta(seconds=t.seconds) + # noinspection PyMethodParameters @declared_attr def __mapper_args__(cls): @@ -363,6 +403,9 @@ class Step(db.Model): args[POLYMORPHIC_ON] = cls.type return args + def __format__(self, format_spec: str) -> str: + return '{} – {} {}'.format(self.severity, self.type, self.elapsed) + class StepZero(Step): pass diff --git a/ereuse_devicehub/resources/event/models.pyi b/ereuse_devicehub/resources/event/models.pyi index 9c331a47..b5d69a89 100644 --- a/ereuse_devicehub/resources/event/models.pyi +++ b/ereuse_devicehub/resources/event/models.pyi @@ -2,7 +2,7 @@ import ipaddress from datetime import datetime, timedelta from decimal import Decimal from distutils.version import StrictVersion -from typing import Dict, List, Set, Union +from typing import Dict, List, Optional, Set, Union from uuid import UUID from boltons import urlutils @@ -358,6 +358,10 @@ class EraseBasic(EventWithOneDevice): def standards(self) -> Set[ErasureStandards]: pass + @property + def certificate(self) -> urlutils.URL: + pass + class EraseSectors(EraseBasic): def __init__(self, **kwargs) -> None: diff --git a/ereuse_devicehub/resources/tag/model.py b/ereuse_devicehub/resources/tag/model.py index 1c6fc0f0..a4692f6e 100644 --- a/ereuse_devicehub/resources/tag/model.py +++ b/ereuse_devicehub/resources/tag/model.py @@ -1,4 +1,5 @@ from contextlib import suppress +from typing import Set from sqlalchemy import BigInteger, Column, ForeignKey, Unicode, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID @@ -11,6 +12,14 @@ from ereuse_devicehub.resources.device.models import Device from ereuse_devicehub.resources.models import Thing +class Tags(Set['Tag']): + def __str__(self) -> str: + return ', '.join(str(tag) for tag in self).strip() + + def __format__(self, format_spec): + return ', '.join(format(tag, format_spec) for tag in self).strip() + + class Tag(Thing): id = Column(Unicode(), check_lower('id'), primary_key=True) id.comment = """The ID of the tag.""" @@ -35,7 +44,7 @@ class Tag(Thing): ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL), index=True) device = relationship(Device, - backref=backref('tags', lazy=True, collection_class=set), + backref=backref('tags', lazy=True, collection_class=Tags), primaryjoin=Device.id == device_id) """The device linked to this tag.""" secondary = Column(Unicode(), check_lower('secondary'), index=True) @@ -82,3 +91,9 @@ class Tag(Thing): def __repr__(self) -> str: return ''.format(self) + + def __str__(self) -> str: + return '{0.id} org: {0.org.name} device: {0.device}'.format(self) + + def __format__(self, format_spec: str) -> str: + return '{0.org.name} {0.id}' diff --git a/requirements.txt b/requirements.txt index c3fcc22a..37802b01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,3 +29,5 @@ teal==0.2.0a30 webargs==4.0.0 Werkzeug==0.14.1 sqlalchemy-citext==1.3.post0 +flask-weasyprint==0.5 +weasyprint==43 diff --git a/setup.py b/setup.py index a1cdbfcc..1d0d2491 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ setup( 'requests-toolbelt', 'sqlalchemy-citext', 'sqlalchemy-utils[password, color, phone]', + 'Flask-WeasyPrint' ], extras_require={ 'docs': [ diff --git a/tests/test_basic.py b/tests/test_basic.py index af50e867..debb4c64 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -28,6 +28,8 @@ def test_api_docs(client: Client): '/manufacturers/', '/lots/{id}/children', '/lots/{id}/devices', + '/documents/erasures/', + '/documents/static/{filename}', '/tags/{tag_id}/device/{device_id}', '/devices/static/{filename}' } diff --git a/tests/test_device_find.py b/tests/test_device_find.py index 008e5bfe..e0765d2e 100644 --- a/tests/test_device_find.py +++ b/tests/test_device_find.py @@ -184,11 +184,6 @@ def test_device_query(user: UserClient): assert not pc['tags'] -@pytest.mark.xfail(reason='Functionality not yet developed.') -def test_device_lots_query(user: UserClient): - pass - - def test_device_search_all_devices_token_if_empty(app: Devicehub, user: UserClient): """Ensures DeviceSearch can regenerate itself when the table is empty.""" user.post(file('basic.snapshot'), res=Snapshot) diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 00000000..09ce9bd2 --- /dev/null +++ b/tests/test_documents.py @@ -0,0 +1,65 @@ +import teal.marshmallow +from ereuse_utils.test import ANY + +from ereuse_devicehub.client import Client, UserClient +from ereuse_devicehub.resources.documents import documents as docs +from ereuse_devicehub.resources.event import models as e +from tests.conftest import file + + +def test_erasure_certificate_public_one(user: UserClient, client: Client): + """Public user can get certificate from one device as HTML or PDF.""" + s = file('erase-sectors.snapshot') + snapshot, _ = user.post(s, res=e.Snapshot) + + doc, response = client.get(res=docs.DocumentDef.t, + item='erasures/{}'.format(snapshot['device']['id']), + accept=ANY) + assert 'html' in response.content_type + assert '