Add document resource and erase certificate
This commit is contained in:
parent
b59721707d
commit
d5a71a7678
|
@ -26,6 +26,8 @@ The requirements are:
|
||||||
- PostgreSQL 9.6 or higher with pgcrypto and ltree.
|
- PostgreSQL 9.6 or higher with pgcrypto and ltree.
|
||||||
In debian 9 is `# apt install postgresql-contrib`
|
In debian 9 is `# apt install postgresql-contrib`
|
||||||
- passlib. In debian 9 is `# apt install python3-passlib`.
|
- 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`.
|
Install Devicehub with *pip*: `pip3 install ereuse-devicehub -U --pre`.
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ from teal.utils import import_resource
|
||||||
|
|
||||||
from ereuse_devicehub.resources import agent, event, lot, tag, user
|
from ereuse_devicehub.resources import agent, event, lot, tag, user
|
||||||
from ereuse_devicehub.resources.device import definitions
|
from ereuse_devicehub.resources.device import definitions
|
||||||
|
from ereuse_devicehub.resources.documents import documents
|
||||||
from ereuse_devicehub.resources.enums import PriceSoftware, RatingSoftware
|
from ereuse_devicehub.resources.enums import PriceSoftware, RatingSoftware
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +19,9 @@ class DevicehubConfig(Config):
|
||||||
import_resource(user),
|
import_resource(user),
|
||||||
import_resource(tag),
|
import_resource(tag),
|
||||||
import_resource(agent),
|
import_resource(agent),
|
||||||
import_resource(lot)))
|
import_resource(lot),
|
||||||
|
import_resource(documents))
|
||||||
|
)
|
||||||
PASSWORD_SCHEMES = {'pbkdf2_sha256'} # type: Set[str]
|
PASSWORD_SCHEMES = {'pbkdf2_sha256'} # type: Set[str]
|
||||||
SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/devicehub' # type: str
|
SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/devicehub' # type: str
|
||||||
SCHEMA = 'dhub'
|
SCHEMA = 'dhub'
|
||||||
|
|
|
@ -343,7 +343,7 @@ class Computer(Device):
|
||||||
@property
|
@property
|
||||||
def privacy(self):
|
def privacy(self):
|
||||||
"""Returns the privacy of all DataStorage components when
|
"""Returns the privacy of all DataStorage components when
|
||||||
it is None.
|
it is not None.
|
||||||
"""
|
"""
|
||||||
return set(
|
return set(
|
||||||
privacy for privacy in
|
privacy for privacy in
|
||||||
|
@ -500,7 +500,7 @@ class DataStorage(JoinedComponentTableMixin, Component):
|
||||||
def __format__(self, format_spec):
|
def __format__(self, format_spec):
|
||||||
v = super().__format__(format_spec)
|
v = super().__format__(format_spec)
|
||||||
if 's' in 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
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ from ereuse_devicehub.resources.image.models import ImageList
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.tag import Tag
|
from ereuse_devicehub.resources.tag import Tag
|
||||||
|
from ereuse_devicehub.resources.tag.model import Tags
|
||||||
|
|
||||||
|
|
||||||
class Device(Thing):
|
class Device(Thing):
|
||||||
|
@ -55,7 +56,7 @@ class Device(Thing):
|
||||||
self.events_multiple = ... # type: Set[e.EventWithMultipleDevices]
|
self.events_multiple = ... # type: Set[e.EventWithMultipleDevices]
|
||||||
self.events_one = ... # type: Set[e.EventWithOneDevice]
|
self.events_one = ... # type: Set[e.EventWithOneDevice]
|
||||||
self.images = ... # type: ImageList
|
self.images = ... # type: ImageList
|
||||||
self.tags = ... # type: Set[Tag]
|
self.tags = ... # type: Tags[Tag]
|
||||||
self.lots = ... # type: Set[Lot]
|
self.lots = ... # type: Set[Lot]
|
||||||
self.production_date = ... # type: datetime
|
self.production_date = ... # type: datetime
|
||||||
|
|
||||||
|
|
|
@ -207,7 +207,9 @@
|
||||||
{{ event._date_str }}
|
{{ event._date_str }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
{% if event.certificate %}
|
||||||
|
<a href="{{ event.certificate.to_text() }}">See the certificate</a>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
@ -15,7 +15,7 @@ from ereuse_devicehub.query import SearchQueryParser
|
||||||
from ereuse_devicehub.resources import search
|
from ereuse_devicehub.resources import search
|
||||||
from ereuse_devicehub.resources.device.models import Device, Manufacturer
|
from ereuse_devicehub.resources.device.models import Device, Manufacturer
|
||||||
from ereuse_devicehub.resources.device.search import DeviceSearch
|
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.lot.models import LotDeviceDescendants
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
|
|
||||||
|
@ -31,9 +31,9 @@ class OfType(f.Str):
|
||||||
|
|
||||||
|
|
||||||
class RateQ(query.Query):
|
class RateQ(query.Query):
|
||||||
rating = query.Between(Rate.rating, f.Float())
|
rating = query.Between(events.Rate.rating, f.Float())
|
||||||
appearance = query.Between(Rate.appearance, f.Float())
|
appearance = query.Between(events.Rate.appearance, f.Float())
|
||||||
functionality = query.Between(Rate.functionality, f.Float())
|
functionality = query.Between(events.Rate.functionality, f.Float())
|
||||||
|
|
||||||
|
|
||||||
class TagQ(query.Query):
|
class TagQ(query.Query):
|
||||||
|
@ -46,11 +46,12 @@ class LotQ(query.Query):
|
||||||
|
|
||||||
|
|
||||||
class Filters(query.Query):
|
class Filters(query.Query):
|
||||||
|
id = query.Or(query.Equal(Device.id, fields.Integer()))
|
||||||
type = query.Or(OfType(Device.type))
|
type = query.Or(OfType(Device.type))
|
||||||
model = query.ILike(Device.model)
|
model = query.ILike(Device.model)
|
||||||
manufacturer = query.ILike(Device.manufacturer)
|
manufacturer = query.ILike(Device.manufacturer)
|
||||||
serialNumber = query.ILike(Device.serial_number)
|
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)
|
tag = query.Join(Device.id == Tag.device_id, TagQ)
|
||||||
# todo This part of the query is really slow
|
# todo This part of the query is really slow
|
||||||
# And forces usage of distinct, as it returns many rows
|
# And forces usage of distinct, as it returns many rows
|
||||||
|
@ -80,21 +81,13 @@ class DeviceView(View):
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
type: integer
|
type: integer
|
||||||
in: path
|
in: path}
|
||||||
description: The identifier of the device.
|
description: The identifier of the device.
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: The device or devices.
|
description: The device or devices.
|
||||||
"""
|
"""
|
||||||
# Majority of code is from teal
|
return super().get(id)
|
||||||
if id:
|
|
||||||
response = self.one(id)
|
|
||||||
else:
|
|
||||||
args = self.QUERY_PARSER.parse(self.find_args,
|
|
||||||
request,
|
|
||||||
locations=('querystring',))
|
|
||||||
response = self.find(args)
|
|
||||||
return response
|
|
||||||
|
|
||||||
def one(self, id: int):
|
def one(self, id: int):
|
||||||
"""Gets one device."""
|
"""Gets one device."""
|
||||||
|
@ -115,17 +108,8 @@ class DeviceView(View):
|
||||||
@auth.Auth.requires_auth
|
@auth.Auth.requires_auth
|
||||||
def find(self, args: dict):
|
def find(self, args: dict):
|
||||||
"""Gets many devices."""
|
"""Gets many devices."""
|
||||||
search_p = args.get('search', None)
|
# Compute query
|
||||||
query = Device.query.distinct() # todo we should not force to do this if the query is ok
|
query = self.query(args)
|
||||||
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'])
|
|
||||||
devices = query.paginate(page=args['page'], per_page=30) # type: Pagination
|
devices = query.paginate(page=args['page'], per_page=30) # type: Pagination
|
||||||
ret = {
|
ret = {
|
||||||
'items': self.schema.dump(devices.items, many=True, nested=1),
|
'items': self.schema.dump(devices.items, many=True, nested=1),
|
||||||
|
@ -142,6 +126,19 @@ class DeviceView(View):
|
||||||
}
|
}
|
||||||
return jsonify(ret)
|
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 ManufacturerView(View):
|
||||||
class FindArgs(marshmallow.Schema):
|
class FindArgs(marshmallow.Schema):
|
||||||
|
|
|
@ -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)
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,89 @@
|
||||||
|
{% extends "documents/layout.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<div>
|
||||||
|
<h2>Resumé</h2>
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>S/N</th>
|
||||||
|
<th>Tags</th>
|
||||||
|
<th>S/N Data Storage</th>
|
||||||
|
<th>Type of erasure</th>
|
||||||
|
<th>Result</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for erasure in erasures %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ erasure.parent.serial_number.upper() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ erasure.parent.tags }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ erasure.device.serial_number.upper() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ erasure.type }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ erasure.severity }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ erasure.date_str }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="page-break row">
|
||||||
|
<h2>Details</h2>
|
||||||
|
{% for erasure in erasures %}
|
||||||
|
<div class="col-md-6 no-page-break">
|
||||||
|
<h4>{{ erasure.device.__format__('t') }}</h4>
|
||||||
|
<dl>
|
||||||
|
<dt>Data storage:</dt>
|
||||||
|
<dd>{{ erasure.device.__format__('ts') }}</dd>
|
||||||
|
<dt>Computer:</dt>
|
||||||
|
<dd>{{ erasure.parent.__format__('ts') }}</dd>
|
||||||
|
<dt>Tags:</dt>
|
||||||
|
<dd>{{ erasure.parent.tags }}</dd>
|
||||||
|
<dt>Erasure:</dt>
|
||||||
|
<dd>{{ erasure.__format__('ts') }}</dd>
|
||||||
|
<dt>Erasure steps:</dt>
|
||||||
|
<dd>
|
||||||
|
<ol>
|
||||||
|
{% for step in erasure.steps %}
|
||||||
|
<li>{{ step.__format__('') }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="no-page-break">
|
||||||
|
<h2>Glossary</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Erase Basic</dt>
|
||||||
|
<dd>
|
||||||
|
A software-based fast non-100%-secured way of erasing data storage,
|
||||||
|
using <a href="https://en.wikipedia.org/wiki/Shred_(Unix)">shred</a>.
|
||||||
|
</dd>
|
||||||
|
<dt>Erase Sectors</dt>
|
||||||
|
<dd>
|
||||||
|
A secured-way of erasing data storages, checking sector-by-sector
|
||||||
|
the erasure, using <a href="https://en.wikipedia.org/wiki/Badblocks">badblocks</a>.
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="no-print">
|
||||||
|
<a href="{{ url_pdf }}">Click here to download the PDF.</a>
|
||||||
|
</div>
|
||||||
|
<div class="print-only">
|
||||||
|
<a href="{{ url_web }}">Verify on-line the integrity of this document</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,26 @@
|
||||||
|
{% import 'devices/macros.html' as macros %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<link href="https://stackpath.bootstrapcdn.com/bootswatch/3.3.7/flatly/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-+ENW/yibaokMnme+vBLnHMphUYxHs34h9lpdbSLuAwGkOKFRl4C34WkjazBtb7eT"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
href="{{ url_for('Document.static', filename='print.css') }}">
|
||||||
|
<title>Devicehub | {{ title }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1> {{ title }}</h1>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -310,6 +310,9 @@ class Severity(IntEnum):
|
||||||
m = '❌'
|
m = '❌'
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
def __format__(self, format_spec):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
|
||||||
class PhysicalErasureMethod(Enum):
|
class PhysicalErasureMethod(Enum):
|
||||||
"""Methods of physically erasing the data-storage, usually
|
"""Methods of physically erasing the data-storage, usually
|
||||||
|
|
|
@ -2,7 +2,7 @@ from collections import Iterable
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_UP
|
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_UP
|
||||||
from distutils.version import StrictVersion
|
from distutils.version import StrictVersion
|
||||||
from typing import Set, Union
|
from typing import Optional, Set, Union
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import inflection
|
import inflection
|
||||||
|
@ -157,11 +157,21 @@ class Event(Thing):
|
||||||
would point to the computer that contained this data storage, if any.
|
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
|
@property
|
||||||
def url(self) -> urlutils.URL:
|
def url(self) -> urlutils.URL:
|
||||||
"""The URL where to GET this event."""
|
"""The URL where to GET this event."""
|
||||||
return urlutils.URL(url_for_resource(Event, item_id=self.id))
|
return urlutils.URL(url_for_resource(Event, item_id=self.id))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def certificate(self) -> Optional[urlutils.URL]:
|
||||||
|
return None
|
||||||
|
|
||||||
# noinspection PyMethodParameters
|
# noinspection PyMethodParameters
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def __mapper_args__(cls):
|
def __mapper_args__(cls):
|
||||||
|
@ -193,7 +203,7 @@ class Event(Thing):
|
||||||
return start_time
|
return start_time
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _date_str(self):
|
def date_str(self):
|
||||||
return '{:%c}'.format(self.end_time or self.created)
|
return '{:%c}'.format(self.end_time or self.created)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
@ -311,20 +321,44 @@ class EraseBasic(JoinedWithOneDeviceMixin, EventWithOneDevice):
|
||||||
Devicehub automatically shows the standards that each erasure
|
Devicehub automatically shows the standards that each erasure
|
||||||
follows.
|
follows.
|
||||||
"""
|
"""
|
||||||
|
method = 'Shred'
|
||||||
|
"""The method or software used to destroy the data."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def standards(self):
|
def standards(self):
|
||||||
"""A set of standards that this erasure follows."""
|
"""A set of standards that this erasure follows."""
|
||||||
return ErasureStandards.from_data_storage(self)
|
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:
|
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):
|
class EraseSectors(EraseBasic):
|
||||||
"""A secured-way of erasing data storages, checking sector-by-sector
|
"""A secured-way of erasing data storages, checking sector-by-sector
|
||||||
the erasure, using `badblocks <https://en.wikipedia.org/wiki/Badblocks>`_.
|
the erasure, using `badblocks <https://en.wikipedia.org/wiki/Badblocks>`_.
|
||||||
"""
|
"""
|
||||||
|
method = 'Badblocks'
|
||||||
|
|
||||||
|
|
||||||
class ErasePhysical(EraseBasic):
|
class ErasePhysical(EraseBasic):
|
||||||
|
@ -348,6 +382,12 @@ class Step(db.Model):
|
||||||
order_by=num,
|
order_by=num,
|
||||||
collection_class=ordering_list('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
|
# noinspection PyMethodParameters
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def __mapper_args__(cls):
|
def __mapper_args__(cls):
|
||||||
|
@ -363,6 +403,9 @@ class Step(db.Model):
|
||||||
args[POLYMORPHIC_ON] = cls.type
|
args[POLYMORPHIC_ON] = cls.type
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
def __format__(self, format_spec: str) -> str:
|
||||||
|
return '{} – {} {}'.format(self.severity, self.type, self.elapsed)
|
||||||
|
|
||||||
|
|
||||||
class StepZero(Step):
|
class StepZero(Step):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -2,7 +2,7 @@ import ipaddress
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from distutils.version import StrictVersion
|
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 uuid import UUID
|
||||||
|
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
|
@ -358,6 +358,10 @@ class EraseBasic(EventWithOneDevice):
|
||||||
def standards(self) -> Set[ErasureStandards]:
|
def standards(self) -> Set[ErasureStandards]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def certificate(self) -> urlutils.URL:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class EraseSectors(EraseBasic):
|
class EraseSectors(EraseBasic):
|
||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Column, ForeignKey, Unicode, UniqueConstraint
|
from sqlalchemy import BigInteger, Column, ForeignKey, Unicode, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
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
|
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):
|
class Tag(Thing):
|
||||||
id = Column(Unicode(), check_lower('id'), primary_key=True)
|
id = Column(Unicode(), check_lower('id'), primary_key=True)
|
||||||
id.comment = """The ID of the tag."""
|
id.comment = """The ID of the tag."""
|
||||||
|
@ -35,7 +44,7 @@ class Tag(Thing):
|
||||||
ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL),
|
ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL),
|
||||||
index=True)
|
index=True)
|
||||||
device = relationship(Device,
|
device = relationship(Device,
|
||||||
backref=backref('tags', lazy=True, collection_class=set),
|
backref=backref('tags', lazy=True, collection_class=Tags),
|
||||||
primaryjoin=Device.id == device_id)
|
primaryjoin=Device.id == device_id)
|
||||||
"""The device linked to this tag."""
|
"""The device linked to this tag."""
|
||||||
secondary = Column(Unicode(), check_lower('secondary'), index=True)
|
secondary = Column(Unicode(), check_lower('secondary'), index=True)
|
||||||
|
@ -82,3 +91,9 @@ class Tag(Thing):
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return '<Tag {0.id} org:{0.org_id} device:{0.device_id}>'.format(self)
|
return '<Tag {0.id} org:{0.org_id} device:{0.device_id}>'.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}'
|
||||||
|
|
|
@ -29,3 +29,5 @@ teal==0.2.0a30
|
||||||
webargs==4.0.0
|
webargs==4.0.0
|
||||||
Werkzeug==0.14.1
|
Werkzeug==0.14.1
|
||||||
sqlalchemy-citext==1.3.post0
|
sqlalchemy-citext==1.3.post0
|
||||||
|
flask-weasyprint==0.5
|
||||||
|
weasyprint==43
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -42,6 +42,7 @@ setup(
|
||||||
'requests-toolbelt',
|
'requests-toolbelt',
|
||||||
'sqlalchemy-citext',
|
'sqlalchemy-citext',
|
||||||
'sqlalchemy-utils[password, color, phone]',
|
'sqlalchemy-utils[password, color, phone]',
|
||||||
|
'Flask-WeasyPrint'
|
||||||
],
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
'docs': [
|
'docs': [
|
||||||
|
|
|
@ -28,6 +28,8 @@ def test_api_docs(client: Client):
|
||||||
'/manufacturers/',
|
'/manufacturers/',
|
||||||
'/lots/{id}/children',
|
'/lots/{id}/children',
|
||||||
'/lots/{id}/devices',
|
'/lots/{id}/devices',
|
||||||
|
'/documents/erasures/',
|
||||||
|
'/documents/static/{filename}',
|
||||||
'/tags/{tag_id}/device/{device_id}',
|
'/tags/{tag_id}/device/{device_id}',
|
||||||
'/devices/static/{filename}'
|
'/devices/static/{filename}'
|
||||||
}
|
}
|
||||||
|
|
|
@ -184,11 +184,6 @@ def test_device_query(user: UserClient):
|
||||||
assert not pc['tags']
|
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):
|
def test_device_search_all_devices_token_if_empty(app: Devicehub, user: UserClient):
|
||||||
"""Ensures DeviceSearch can regenerate itself when the table is empty."""
|
"""Ensures DeviceSearch can regenerate itself when the table is empty."""
|
||||||
user.post(file('basic.snapshot'), res=Snapshot)
|
user.post(file('basic.snapshot'), res=Snapshot)
|
||||||
|
|
|
@ -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 '<html' in doc
|
||||||
|
assert '2018' in doc
|
||||||
|
|
||||||
|
doc, response = client.get(res=docs.DocumentDef.t,
|
||||||
|
item='erasures/{}'.format(snapshot['device']['id']),
|
||||||
|
query=[('format', 'PDF')],
|
||||||
|
accept='application/pdf')
|
||||||
|
assert 'application/pdf' == response.content_type
|
||||||
|
|
||||||
|
erasure = next(e for e in snapshot['events'] if e['type'] == 'EraseSectors')
|
||||||
|
|
||||||
|
doc, response = client.get(res=docs.DocumentDef.t,
|
||||||
|
item='erasures/{}'.format(erasure['id']),
|
||||||
|
accept=ANY)
|
||||||
|
assert 'html' in response.content_type
|
||||||
|
assert '<html' in doc
|
||||||
|
assert '2018' in doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_erasure_certificate_private_query(user: UserClient):
|
||||||
|
"""Logged-in user can get certificates using queries as HTML and
|
||||||
|
PDF.
|
||||||
|
"""
|
||||||
|
s = file('erase-sectors.snapshot')
|
||||||
|
snapshot, response = user.post(s, res=e.Snapshot)
|
||||||
|
|
||||||
|
doc, response = user.get(res=docs.DocumentDef.t,
|
||||||
|
item='erasures/',
|
||||||
|
query=[('filter', {'id': [snapshot['device']['id']]})],
|
||||||
|
accept=ANY)
|
||||||
|
assert 'html' in response.content_type
|
||||||
|
assert '<html' in doc
|
||||||
|
assert '2018' in doc
|
||||||
|
|
||||||
|
doc, response = user.get(res=docs.DocumentDef.t,
|
||||||
|
item='erasures/',
|
||||||
|
query=[
|
||||||
|
('filter', {'id': [snapshot['device']['id']]}),
|
||||||
|
('format', 'PDF')
|
||||||
|
],
|
||||||
|
accept='application/pdf')
|
||||||
|
assert 'application/pdf' == response.content_type
|
||||||
|
|
||||||
|
|
||||||
|
def test_erasure_certificate_wrong_id(client: Client):
|
||||||
|
client.get(res=docs.DocumentDef.t, item='erasures/this-is-not-an-id',
|
||||||
|
status=teal.marshmallow.ValidationError)
|
|
@ -351,27 +351,6 @@ def test_erase_physical():
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reson='validate use-case')
|
|
||||||
def test_view_public_erasure_certificate():
|
|
||||||
"""User can see html erasure certificate even if not logged-in,
|
|
||||||
from the public link.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reson='Validate use-case')
|
|
||||||
def test_not_download_erasure_certificate_if_public():
|
|
||||||
"""User cannot download an erasure certificate as PDF if
|
|
||||||
not logged-in.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reson='talk to Jordi about variables in certificate erasure.')
|
|
||||||
def test_download_erasure_certificate():
|
|
||||||
"""User can download erasure certificates. We test erasure
|
|
||||||
certificates with: ... todo
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reson='Adapt rate algorithm to re-compute by passing a manual rate.')
|
@pytest.mark.xfail(reson='Adapt rate algorithm to re-compute by passing a manual rate.')
|
||||||
def test_manual_rate_after_workbench_rate(user: UserClient):
|
def test_manual_rate_after_workbench_rate(user: UserClient):
|
||||||
"""Perform a WorkbenchRate and then update the device with a ManualRate.
|
"""Perform a WorkbenchRate and then update the device with a ManualRate.
|
||||||
|
|
Reference in New Issue