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 @@
-
-
- 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
-
-
-
-
-
- Range
-
-
-
- {% if device.processor_model %}
-
-
- CPU – {{ device.processor_model }}
-
-
- {% if device.rate %}
- {{ device.rate.processor_range }}
- ({{ device.rate.processor }})
- {% endif %}
-
-
- {% endif %}
- {% if device.ram_size %}
-
-
- RAM – {{ device.ram_size // 1000 }} GB
- {{ macros.component_type(device.components, 'RamModule') }}
-
-
- {% if device.rate %}
- {{ device.rate.ram_range }}
- ({{ device.rate.ram }})
- {% endif %}
-
-
- {% endif %}
- {% if device.data_storage_size %}
-
-
- 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 %}
-
-
- {% endif %}
- {% if device.graphic_card_model %}
-
-
- Graphics – {{ device.graphic_card_model }}
- {{ macros.component_type(device.components, 'GraphicCard') }}
-
-
-
- {% endif %}
- {% if device.network_speeds %}
-
-
- 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') }}
-
-
-
- {% endif %}
- {% if device.rate %}
-
-
- Total rate
-
-
- {{ device.rate.rating_range }}
- ({{ device.rate.rating }})
-
-
- {% endif %}
- {% if device.rate and device.rate.price %}
-
-
- Algorithm price
-
-
- {{ device.rate.price }}
-
-
- {% endif %}
- {% if device.price %}
-
-
- Actual price
-
-
- {{ device.price }}
-
-
- {% endif %}
-
-
+
+ {% 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
+
+
+
+
+
+ Range
+
+
+
+ {% if device.processor_model %}
+
+
+ CPU – {{ device.processor_model }}
+
+
+ {% if device.rate %}
+ {{ device.rate.processor_range }}
+ ({{ device.rate.processor }})
+ {% endif %}
+
+
+ {% endif %}
+ {% if device.ram_size %}
+
+
+ RAM – {{ device.ram_size // 1000 }} GB
+ {{ macros.component_type(device.components, 'RamModule') }}
+
+
+ {% if device.rate %}
+ {{ device.rate.ram_range }}
+ ({{ device.rate.ram }})
+ {% endif %}
+
+
+ {% endif %}
+ {% if device.data_storage_size %}
+
+
+ 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 %}
+
+
+ {% endif %}
+ {% if device.graphic_card_model %}
+
+
+ Graphics – {{ device.graphic_card_model }}
+ {{ macros.component_type(device.components, 'GraphicCard') }}
+
+
+
+ {% endif %}
+ {% if device.network_speeds %}
+
+
+ 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') }}
+
+
+
+ {% endif %}
+ {% if device.rate %}
+
+
+ Total rate
+
+
+ {{ device.rate.rating_range }}
+ ({{ device.rate.rating }})
+
+
+ {% endif %}
+ {% if device.rate and device.rate.price %}
+
+
+ Algorithm price
+
+
+ {{ device.rate.price }}
+
+
+ {% endif %}
+ {% if device.price %}
+
+
+ Actual price
+
+
+ {{ device.price }}
+
+
+ {% endif %}
+
+
+
+ This is the traceability log of your device
+
+ Latest one.
+
+
+ {% for event in device.events|reverse %}
+
+
+ {{ event.type }}
+
+ —
+ {{ event }}
+
+
+
+ {{ event._date_str }}
+
- This is the traceability log of your device
-
- Latest one.
-
-
- {% for event in device.events|reverse %}
-
-
- {{ event.type }}
-
- —
- {{ event }}
-
-
-
- {{ event._date_str }}
-
-
-
-
- {% 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é
+
+
+
+ S/N
+ Tags
+ S/N Data Storage
+ Type of erasure
+ Result
+ Date
+
+
+
+ {% for erasure in erasures %}
+
+
+ {{ erasure.parent.serial_number.upper() }}
+
+
+ {{ erasure.parent.tags }}
+
+
+ {{ erasure.device.serial_number.upper() }}
+
+
+ {{ erasure.type }}
+
+
+ {{ erasure.severity }}
+
+
+ {{ erasure.date_str }}
+
+
+ {% endfor %}
+
+
+
+
+
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 %}
+ {{ step.__format__('') }}
+ {% 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 .
+
+
+
+
+
+{% 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 '