Improve api generation; remove unneeded views
This commit is contained in:
parent
ac6c94ebe4
commit
10f3aa7d35
|
@ -13,6 +13,7 @@ from ereuse_devicehub.resources.event import AddDef, AggregateRateDef, AppRateDe
|
|||
from ereuse_devicehub.resources.inventory import InventoryDef
|
||||
from ereuse_devicehub.resources.tag import TagDef
|
||||
from ereuse_devicehub.resources.user import OrganizationDef, UserDef
|
||||
from teal.auth import TokenAuth
|
||||
from teal.config import Config
|
||||
|
||||
|
||||
|
@ -20,7 +21,7 @@ class DevicehubConfig(Config):
|
|||
RESOURCE_DEFINITIONS = {
|
||||
DeviceDef, ComputerDef, DesktopDef, LaptopDef, NetbookDef, ServerDef,
|
||||
MicrotowerDef, ComputerMonitorDef, ComponentDef, GraphicCardDef, DataStorageDef,
|
||||
SolidStateDriveDef,
|
||||
SolidStateDriveDef,
|
||||
HardDriveDef, MotherboardDef, NetworkAdapterDef, RamModuleDef, ProcessorDef, UserDef,
|
||||
OrganizationDef, TagDef, EventDef, AddDef, RemoveDef, EraseBasicDef, EraseSectorsDef,
|
||||
StepDef, StepZeroDef, StepRandomDef, RateDef, AggregateRateDef, WorkbenchRateDef,
|
||||
|
@ -43,6 +44,14 @@ class DevicehubConfig(Config):
|
|||
|
||||
It is used by default, for example, when creating tags.
|
||||
"""
|
||||
API_DOC_CONFIG_TITLE = 'Devicehub'
|
||||
API_DOC_CONFIG_VERSION = '0.2'
|
||||
API_DOC_CONFIG_COMPONENTS = {
|
||||
'securitySchemes': {
|
||||
'bearerAuth': TokenAuth.API_DOCS
|
||||
}
|
||||
}
|
||||
API_DOC_CLASS_DISCRIMINATOR = 'type'
|
||||
|
||||
def __init__(self, db: str = None) -> None:
|
||||
if not self.ORGANIZATION_NAME or not self.ORGANIZATION_TAX_ID:
|
||||
|
|
|
@ -13,64 +13,80 @@ class DeviceDef(Resource):
|
|||
|
||||
|
||||
class ComputerDef(DeviceDef):
|
||||
VIEW = None
|
||||
SCHEMA = Computer
|
||||
|
||||
|
||||
class DesktopDef(ComputerDef):
|
||||
VIEW = None
|
||||
SCHEMA = Desktop
|
||||
|
||||
|
||||
class LaptopDef(ComputerDef):
|
||||
VIEW = None
|
||||
SCHEMA = Laptop
|
||||
|
||||
|
||||
class NetbookDef(ComputerDef):
|
||||
VIEW = None
|
||||
SCHEMA = Netbook
|
||||
|
||||
|
||||
class ServerDef(ComputerDef):
|
||||
VIEW = None
|
||||
SCHEMA = Server
|
||||
|
||||
|
||||
class MicrotowerDef(ComputerDef):
|
||||
VIEW = None
|
||||
SCHEMA = Microtower
|
||||
|
||||
|
||||
class ComputerMonitorDef(DeviceDef):
|
||||
VIEW = None
|
||||
SCHEMA = ComputerMonitor
|
||||
|
||||
|
||||
class ComponentDef(DeviceDef):
|
||||
VIEW = None
|
||||
SCHEMA = Component
|
||||
|
||||
|
||||
class GraphicCardDef(ComponentDef):
|
||||
VIEW = None
|
||||
SCHEMA = GraphicCard
|
||||
|
||||
|
||||
class DataStorageDef(ComponentDef):
|
||||
VIEW = None
|
||||
SCHEMA = DataStorage
|
||||
|
||||
|
||||
class HardDriveDef(DataStorageDef):
|
||||
VIEW = None
|
||||
SCHEMA = HardDrive
|
||||
|
||||
|
||||
class SolidStateDriveDef(DataStorageDef):
|
||||
VIEW = None
|
||||
SCHEMA = SolidStateDrive
|
||||
|
||||
|
||||
class MotherboardDef(ComponentDef):
|
||||
VIEW = None
|
||||
SCHEMA = Motherboard
|
||||
|
||||
|
||||
class NetworkAdapterDef(ComponentDef):
|
||||
VIEW = None
|
||||
SCHEMA = NetworkAdapter
|
||||
|
||||
|
||||
class RamModuleDef(ComponentDef):
|
||||
VIEW = None
|
||||
SCHEMA = RamModule
|
||||
|
||||
|
||||
class ProcessorDef(ComponentDef):
|
||||
VIEW = None
|
||||
SCHEMA = Processor
|
||||
|
|
|
@ -18,6 +18,10 @@ from teal.db import CASCADE, POLYMORPHIC_ID, POLYMORPHIC_ON, ResourceNotFound, c
|
|||
|
||||
|
||||
class Device(Thing):
|
||||
"""
|
||||
Base class for any type of physical object that can be identified.
|
||||
"""
|
||||
|
||||
id = Column(BigInteger, Sequence('device_seq'), primary_key=True)
|
||||
id.comment = """
|
||||
The identifier of the device for this database.
|
||||
|
@ -45,12 +49,18 @@ class Device(Thing):
|
|||
"""
|
||||
depth = Column(Float(decimal_return_scale=3), check_range('depth', 0.1, 3))
|
||||
color = Column(ColorType)
|
||||
color.comment = """
|
||||
|
||||
"""
|
||||
|
||||
@property
|
||||
def events(self) -> list:
|
||||
"""
|
||||
All the events performed to the device,
|
||||
ordered by ascending creation time.
|
||||
All the events where the device participated, including
|
||||
1) events performed directly to the device, 2) events performed
|
||||
to a component, and 3) events performed to a parent device.
|
||||
|
||||
Events are returned by ascending creation time.
|
||||
"""
|
||||
return sorted(chain(self.events_multiple, self.events_one), key=attrgetter('created'))
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from marshmallow import post_load, pre_load
|
||||
from marshmallow.fields import Float, Integer, Str
|
||||
from marshmallow.validate import Length, OneOf, Range
|
||||
from marshmallow_enum import EnumField
|
||||
from sqlalchemy.util import OrderedSet
|
||||
|
||||
from ereuse_devicehub.marshmallow import NestedOn
|
||||
|
@ -9,21 +8,29 @@ from ereuse_devicehub.resources.device import models as m
|
|||
from ereuse_devicehub.resources.enums import ComputerMonitorTechnologies, RamFormat, RamInterface
|
||||
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
|
||||
from ereuse_devicehub.resources.schemas import Thing, UnitCodes
|
||||
from teal.marshmallow import ValidationError
|
||||
from teal.marshmallow import EnumField, ValidationError
|
||||
|
||||
|
||||
class Device(Thing):
|
||||
id = Integer(description=m.Device.id, dump_only=True)
|
||||
hid = Str(dump_only=True, description=m.Device.hid)
|
||||
tags = NestedOn('Tag', many=True, collection_class=OrderedSet)
|
||||
id = Integer(description=m.Device.id.comment.strip(), dump_only=True)
|
||||
hid = Str(dump_only=True, description=m.Device.hid.comment.strip())
|
||||
tags = NestedOn('Tag',
|
||||
many=True,
|
||||
collection_class=OrderedSet,
|
||||
description='The set of tags that identify the device.')
|
||||
model = Str(validate=Length(max=STR_BIG_SIZE))
|
||||
manufacturer = Str(validate=Length(max=STR_SIZE))
|
||||
serial_number = Str(data_key='serialNumber')
|
||||
product_id = Str(data_key='productId')
|
||||
weight = Float(validate=Range(0.1, 3), unit=UnitCodes.kgm, description=m.Device.weight)
|
||||
width = Float(validate=Range(0.1, 3), unit=UnitCodes.m, description=m.Device.width)
|
||||
height = Float(validate=Range(0.1, 3), unit=UnitCodes.m, description=m.Device.height)
|
||||
events = NestedOn('Event', many=True, dump_only=True)
|
||||
weight = Float(validate=Range(0.1, 3),
|
||||
unit=UnitCodes.kgm,
|
||||
description=m.Device.weight.comment.strip())
|
||||
width = Float(validate=Range(0.1, 3),
|
||||
unit=UnitCodes.m,
|
||||
description=m.Device.width.comment.strip())
|
||||
height = Float(validate=Range(0.1, 3),
|
||||
unit=UnitCodes.m,
|
||||
description=m.Device.height.comment.strip())
|
||||
events = NestedOn('Event', many=True, dump_only=True, description=m.Device.events.__doc__)
|
||||
events_one = NestedOn('Event', many=True, load_only=True, collection_class=OrderedSet)
|
||||
|
||||
@pre_load
|
||||
|
@ -76,15 +83,15 @@ class Microtower(Computer):
|
|||
|
||||
|
||||
class ComputerMonitor(Device):
|
||||
size = Float(description=m.ComputerMonitor.size.comment, validate=Range(2, 150))
|
||||
size = Float(description=m.ComputerMonitor.size.comment.strip(), validate=Range(2, 150))
|
||||
technology = EnumField(ComputerMonitorTechnologies,
|
||||
description=m.ComputerMonitor.technology.comment)
|
||||
description=m.ComputerMonitor.technology.comment.strip())
|
||||
resolution_width = Integer(data_key='resolutionWidth',
|
||||
validate=Range(10, 20000),
|
||||
description=m.ComputerMonitor.resolution_width.comment)
|
||||
description=m.ComputerMonitor.resolution_width.comment.strip())
|
||||
resolution_height = Integer(data_key='resolutionHeight',
|
||||
validate=Range(10, 20000),
|
||||
description=m.ComputerMonitor.resolution_height.comment)
|
||||
description=m.ComputerMonitor.resolution_height.comment.strip())
|
||||
|
||||
|
||||
class Component(Device):
|
||||
|
@ -101,9 +108,6 @@ class DataStorage(Component):
|
|||
size = Integer(validate=Range(0, 10 ** 8),
|
||||
unit=UnitCodes.mbyte,
|
||||
description='The size of the hard-drive in MB.')
|
||||
erasure = NestedOn('EraseBasic', load_only=True)
|
||||
tests = NestedOn('TestHardDrive', many=True, load_only=True)
|
||||
benchmarks = NestedOn('BenchmarkHardDrive', load_only=True, many=True)
|
||||
|
||||
|
||||
class HardDrive(DataStorage):
|
||||
|
|
|
@ -3,12 +3,29 @@ from teal.resource import View
|
|||
|
||||
|
||||
class DeviceView(View):
|
||||
|
||||
def get(self, id):
|
||||
"""
|
||||
Devices view
|
||||
---
|
||||
description: Gets a device or multiple devices.
|
||||
parameters:
|
||||
- name: id
|
||||
type: integer
|
||||
in: path
|
||||
description: The identifier of the device.
|
||||
responses:
|
||||
200:
|
||||
description: The device or devices.
|
||||
"""
|
||||
return super().get(id)
|
||||
|
||||
def one(self, id: int):
|
||||
"""Gets one device."""
|
||||
device = Device.query.filter_by(id=id).one()
|
||||
return self.schema.jsonify(device)
|
||||
|
||||
def find(self, args: dict):
|
||||
"""Gets many devices"""
|
||||
"""Gets many devices."""
|
||||
devices = Device.query.all()
|
||||
return self.schema.jsonify(devices, many=True)
|
||||
|
|
|
@ -18,62 +18,77 @@ class EventDef(Resource):
|
|||
|
||||
|
||||
class AddDef(EventDef):
|
||||
VIEW = None
|
||||
SCHEMA = Add
|
||||
|
||||
|
||||
class RemoveDef(EventDef):
|
||||
VIEW = None
|
||||
SCHEMA = Remove
|
||||
|
||||
|
||||
class EraseBasicDef(EventDef):
|
||||
VIEW = None
|
||||
SCHEMA = EraseBasic
|
||||
|
||||
|
||||
class EraseSectorsDef(EraseBasicDef):
|
||||
VIEW = None
|
||||
SCHEMA = EraseSectors
|
||||
|
||||
|
||||
class StepDef(Resource):
|
||||
VIEW = None
|
||||
SCHEMA = Step
|
||||
|
||||
|
||||
class StepZeroDef(StepDef):
|
||||
VIEW = None
|
||||
SCHEMA = StepZero
|
||||
|
||||
|
||||
class StepRandomDef(StepDef):
|
||||
VIEW = None
|
||||
SCHEMA = StepRandom
|
||||
|
||||
|
||||
class RateDef(EventDef):
|
||||
VIEW = None
|
||||
SCHEMA = Rate
|
||||
|
||||
|
||||
class AggregateRateDef(RateDef):
|
||||
VIEW = None
|
||||
SCHEMA = AggregateRate
|
||||
|
||||
|
||||
class WorkbenchRateDef(RateDef):
|
||||
VIEW = None
|
||||
SCHEMA = WorkbenchRate
|
||||
|
||||
|
||||
class PhotoboxUserDef(RateDef):
|
||||
VIEW = None
|
||||
SCHEMA = PhotoboxUserRate
|
||||
|
||||
|
||||
class PhotoboxSystemRateDef(RateDef):
|
||||
VIEW = None
|
||||
SCHEMA = PhotoboxSystemRate
|
||||
|
||||
|
||||
class AppRateDef(RateDef):
|
||||
VIEW = None
|
||||
SCHEMA = AppRate
|
||||
|
||||
|
||||
class InstallDef(EventDef):
|
||||
VIEW = None
|
||||
SCHEMA = Install
|
||||
|
||||
|
||||
class SnapshotDef(EventDef):
|
||||
VIEW = None
|
||||
SCHEMA = Snapshot
|
||||
VIEW = SnapshotView
|
||||
|
||||
|
@ -86,36 +101,45 @@ class SnapshotDef(EventDef):
|
|||
|
||||
|
||||
class TestDef(EventDef):
|
||||
VIEW = None
|
||||
SCHEMA = Test
|
||||
|
||||
|
||||
class TestDataStorageDef(TestDef):
|
||||
VIEW = None
|
||||
SCHEMA = TestDataStorage
|
||||
|
||||
|
||||
class StressTestDef(TestDef):
|
||||
VIEW = None
|
||||
SCHEMA = StressTest
|
||||
|
||||
|
||||
class BenchmarkDef(EventDef):
|
||||
VIEW = None
|
||||
SCHEMA = Benchmark
|
||||
|
||||
|
||||
class BenchmarkDataStorageDef(BenchmarkDef):
|
||||
VIEW = None
|
||||
SCHEMA = BenchmarkDataStorage
|
||||
|
||||
|
||||
class BenchmarkWithRateDef(BenchmarkDef):
|
||||
VIEW = None
|
||||
SCHEMA = BenchmarkWithRate
|
||||
|
||||
|
||||
class BenchmarkProcessorDef(BenchmarkWithRateDef):
|
||||
VIEW = None
|
||||
SCHEMA = BenchmarkProcessor
|
||||
|
||||
|
||||
class BenchmarkProcessorSysbenchDef(BenchmarkProcessorDef):
|
||||
VIEW = None
|
||||
SCHEMA = BenchmarkProcessorSysbench
|
||||
|
||||
|
||||
class BenchmarkRamSysbenchDef(BenchmarkWithRateDef):
|
||||
VIEW = None
|
||||
SCHEMA = BenchmarkRamSysbench
|
||||
|
|
|
@ -46,8 +46,7 @@ class Event(Thing):
|
|||
closed.comment = """
|
||||
Whether the author has finished the event.
|
||||
After this is set to True, no modifications are allowed.
|
||||
|
||||
By default are events are closed when performed.
|
||||
By default events are closed when performed.
|
||||
"""
|
||||
error = Column(Boolean, default=False, nullable=False)
|
||||
error.comment = """
|
||||
|
|
|
@ -3,7 +3,6 @@ from marshmallow import ValidationError, validates_schema
|
|||
from marshmallow.fields import Boolean, DateTime, Float, Integer, List, Nested, String, TimeDelta, \
|
||||
UUID
|
||||
from marshmallow.validate import Length, Range
|
||||
from marshmallow_enum import EnumField
|
||||
|
||||
from ereuse_devicehub.marshmallow import NestedOn
|
||||
from ereuse_devicehub.resources.device.schemas import Component, Device
|
||||
|
@ -13,21 +12,23 @@ from ereuse_devicehub.resources.event import models as m
|
|||
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
|
||||
from ereuse_devicehub.resources.schemas import Thing
|
||||
from ereuse_devicehub.resources.user.schemas import User
|
||||
from teal.marshmallow import Version
|
||||
from teal.marshmallow import EnumField, Version
|
||||
from teal.resource import Schema
|
||||
|
||||
|
||||
class Event(Thing):
|
||||
id = UUID(dump_only=True)
|
||||
name = String(default='', validate=Length(STR_BIG_SIZE), description=m.Event.name.comment)
|
||||
date = DateTime('iso', description=m.Event.date.comment)
|
||||
error = Boolean(default=False, description=m.Event.error.comment)
|
||||
incidence = Boolean(default=False, description=m.Event.incidence.comment)
|
||||
name = String(default='',
|
||||
validate=Length(STR_BIG_SIZE),
|
||||
description=m.Event.name.comment.strip())
|
||||
date = DateTime('iso', description=m.Event.date.comment.strip())
|
||||
error = Boolean(default=False, description=m.Event.error.comment.strip())
|
||||
incidence = Boolean(default=False, description=m.Event.incidence.comment.strip())
|
||||
snapshot = NestedOn('Snapshot', dump_only=True)
|
||||
components = NestedOn(Component, dump_only=True, many=True)
|
||||
description = String(default='', description=m.Event.description.comment)
|
||||
description = String(default='', description=m.Event.description.comment.strip())
|
||||
author = NestedOn(User, dump_only=True, exclude=('token',))
|
||||
closed = Boolean(missing=True, description=m.Event.closed.comment)
|
||||
closed = Boolean(missing=True, description=m.Event.closed.comment.strip())
|
||||
|
||||
|
||||
class EventWithOneDevice(Event):
|
||||
|
|
|
@ -8,8 +8,7 @@ from sqlalchemy.util import OrderedSet
|
|||
from ereuse_devicehub.db import db
|
||||
from ereuse_devicehub.resources.device.models import Component, Computer
|
||||
from ereuse_devicehub.resources.enums import RatingSoftware, SnapshotSoftware
|
||||
from ereuse_devicehub.resources.event.models import Event, ManualRate, Snapshot, TestDataStorage, \
|
||||
WorkbenchRate
|
||||
from ereuse_devicehub.resources.event.models import Event, ManualRate, Snapshot, WorkbenchRate
|
||||
from teal.resource import View
|
||||
|
||||
|
||||
|
@ -85,16 +84,3 @@ class SnapshotView(View):
|
|||
ret = self.schema.jsonify(snapshot) # transform it back
|
||||
ret.status_code = 201
|
||||
return ret
|
||||
|
||||
|
||||
class TestHardDriveView(View):
|
||||
def post(self):
|
||||
t = request.get_json() # type: dict
|
||||
# noinspection PyArgumentList
|
||||
test = TestDataStorage(snapshot_id=t.pop('snapshot'), device_id=t.pop('device'), **t)
|
||||
return test
|
||||
|
||||
|
||||
class StressTestView(View):
|
||||
def post(self):
|
||||
t = request.get_json() # type: dict
|
||||
|
|
|
@ -58,13 +58,39 @@ class InventoryView(View):
|
|||
sort = Nested(Sorting, missing=[Device.created.desc()])
|
||||
page = Integer(validate=Range(min=1), missing=1)
|
||||
|
||||
def find(self, args: dict):
|
||||
def get(self, id):
|
||||
"""Inventory view
|
||||
---
|
||||
description: Supports the inventory view of ``devicehub-client``; returns
|
||||
all the devices, groups and widgets of this Devicehub instance.
|
||||
responses:
|
||||
200:
|
||||
description: The inventory.
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
devices:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Device'
|
||||
pagination:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
minimum: 0
|
||||
perPage:
|
||||
type: integer
|
||||
minimum: 0
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
"""
|
||||
Supports the inventory view of ``devicehub-client``; returns
|
||||
all the devices, groups and widgets of this Devicehub instance.
|
||||
# todo .format(yaml.load(schema2parameters(self.FindArgs, default_in='path', name='path')))
|
||||
return super().get(id)
|
||||
|
||||
The result can be filtered, sorted, and paginated.
|
||||
"""
|
||||
def find(self, args: dict):
|
||||
"""See :meth:`.get` above."""
|
||||
devices = Device.query \
|
||||
.filter(*args['filter']) \
|
||||
.order_by(*args['sort']) \
|
||||
|
|
|
@ -22,8 +22,8 @@ class Thing(Schema):
|
|||
type = String(description='Only required when it is nested.')
|
||||
url = URL(dump_only=True, description='The URL of the resource.')
|
||||
same_as = List(URL(dump_only=True), dump_only=True, data_key='sameAs')
|
||||
updated = DateTime('iso', dump_only=True, description=m.Thing.updated)
|
||||
created = DateTime('iso', dump_only=True, description=m.Thing.created)
|
||||
updated = DateTime('iso', dump_only=True, description=m.Thing.updated.comment.strip())
|
||||
created = DateTime('iso', dump_only=True, description=m.Thing.created.comment.strip())
|
||||
|
||||
@post_load
|
||||
def remove_type(self, data: dict):
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import pytest
|
||||
|
||||
from ereuse_devicehub.devicehub import Devicehub
|
||||
from ereuse_devicehub.client import Client
|
||||
|
||||
|
||||
def test_dependencies():
|
||||
|
@ -12,6 +12,27 @@ def test_dependencies():
|
|||
|
||||
|
||||
# noinspection PyArgumentList
|
||||
def test_init(app: Devicehub):
|
||||
"""Tests app initialization."""
|
||||
pass
|
||||
def test_api_docs(client: Client):
|
||||
"""Tests /apidocs correct initialization."""
|
||||
docs, _ = client.get('/apidocs')
|
||||
assert set(docs['paths'].keys()) == {
|
||||
'/tags/{id}/device',
|
||||
'/inventories/',
|
||||
'/apidocs',
|
||||
'/users/',
|
||||
'/devices/',
|
||||
'/tags/',
|
||||
'/snapshots/',
|
||||
'/users/login',
|
||||
'/events/'
|
||||
}
|
||||
assert docs['info'] == {'title': 'Devicehub', 'version': '0.2'}
|
||||
assert docs['components']['securitySchemes']['bearerAuth'] == {
|
||||
'description': 'Basic scheme with token.',
|
||||
'in': 'header',
|
||||
'description:': 'HTTP Basic scheme',
|
||||
'type': 'http',
|
||||
'scheme': 'basic',
|
||||
'name': 'Authorization'
|
||||
}
|
||||
assert len(docs['definitions']) == 46
|
||||
|
|
|
@ -313,13 +313,13 @@ def test_erase(user: UserClient):
|
|||
snapshot = snapshot_and_check(user, s, ('EraseSectors',), perform_second_snapshot=True)
|
||||
storage, *_ = snapshot['components']
|
||||
assert storage['type'] == 'SolidStateDrive', 'Components must be ordered by input order'
|
||||
storage, _ = user.get(res=SolidStateDrive, item=storage['id']) # Let's get storage events too
|
||||
storage, _ = user.get(res=Device, item=storage['id']) # Let's get storage events too
|
||||
# order: creation time descending
|
||||
_snapshot1, erasure1, _snapshot2, erasure2 = storage['events']
|
||||
assert erasure1['type'] == erasure2['type'] == 'EraseSectors'
|
||||
assert _snapshot1['type'] == _snapshot2['type'] == 'Snapshot'
|
||||
assert snapshot == user.get(res=Event, item=_snapshot2['id'])[0]
|
||||
erasure, _ = user.get(res=EraseBasic, item=erasure1['id'])
|
||||
erasure, _ = user.get(res=Event, item=erasure1['id'])
|
||||
assert len(erasure['steps']) == 2
|
||||
assert erasure['steps'][0]['startTime'] == '2018-06-01T08:15:00+00:00'
|
||||
assert erasure['steps'][0]['endTime'] == '2018-06-01T09:16:00+00:00'
|
||||
|
|
Reference in a new issue