diff --git a/ereuse_devicehub/__init__.py b/ereuse_devicehub/__init__.py index a346d164..944f81a7 100644 --- a/ereuse_devicehub/__init__.py +++ b/ereuse_devicehub/__init__.py @@ -1 +1 @@ -__version__ = "1.0b" +__version__ = "1.0.1-beta" diff --git a/ereuse_devicehub/resources/CHANGELOG.md b/ereuse_devicehub/resources/CHANGELOG.md new file mode 100644 index 00000000..3f423a22 --- /dev/null +++ b/ereuse_devicehub/resources/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## [1.0.1-beta] - 2020-11-16 +- [fixed] #80 manual merged from website diff --git a/ereuse_devicehub/resources/device/definitions.py b/ereuse_devicehub/resources/device/definitions.py index 47f276e2..7960efad 100644 --- a/ereuse_devicehub/resources/device/definitions.py +++ b/ereuse_devicehub/resources/device/definitions.py @@ -27,11 +27,13 @@ class DeviceDef(Resource): url_prefix, subdomain, url_defaults, root_path, cli_commands) device_merge = DeviceMergeView.as_view('merge-devices', definition=self, auth=app.auth) + if self.AUTH: device_merge = app.auth.requires_auth(device_merge) - self.add_url_rule('/<{}:{}>/merge/'.format(self.ID_CONVERTER.value, self.ID_NAME), - view_func=device_merge, - methods={'POST'}) + + path = '/<{value}:dev1_id>/merge/<{value}:dev2_id>'.format(value=self.ID_CONVERTER.value) + + self.add_url_rule(path, view_func=device_merge, methods={'POST'}) class ComputerDef(DeviceDef): diff --git a/ereuse_devicehub/resources/device/views.py b/ereuse_devicehub/resources/device/views.py index 8f16b12d..1542c548 100644 --- a/ereuse_devicehub/resources/device/views.py +++ b/ereuse_devicehub/resources/device/views.py @@ -6,10 +6,13 @@ import marshmallow from flask import g, current_app as app, render_template, request, Response from flask.json import jsonify from flask_sqlalchemy import Pagination +from sqlalchemy.util import OrderedSet from marshmallow import fields, fields as f, validate as v, Schema as MarshmallowSchema from teal import query +from teal.db import ResourceNotFound from teal.cache import cache from teal.resource import View +from teal.marshmallow import ValidationError from ereuse_devicehub import auth from ereuse_devicehub.db import db @@ -170,66 +173,78 @@ class DeviceView(View): class DeviceMergeView(View): """View for merging two devices - Ex. ``device//merge/id=X``. + Ex. ``device//merge/``. """ - class FindArgs(MarshmallowSchema): - id = fields.Integer() + def post(self, dev1_id: int, dev2_id: int): + device = self.merge_devices(dev1_id, dev2_id) - def get_merge_id(self) -> uuid.UUID: - args = self.QUERY_PARSER.parse(self.find_args, request, locations=('querystring',)) - return args['id'] - - def post(self, id: uuid.UUID): - device = Device.query.filter_by(id=id).one() - with_device = Device.query.filter_by(id=self.get_merge_id()).one() - self.merge_devices(device, with_device) - - db.session().final_flush() ret = self.schema.jsonify(device) ret.status_code = 201 db.session.commit() return ret - def merge_devices(self, base_device, with_device): - """Merge the current device with `with_device` by - adding all `with_device` actions under the current device. + @auth.Auth.requires_auth + def merge_devices(self, dev1_id: int, dev2_id: int) -> Device: + """Merge the current device with `with_device` (dev2_id) by + adding all `with_device` actions under the current device, (dev1_id). This operation is highly costly as it forces refreshing many models in session. """ - snapshots = sorted( - filterfalse(lambda x: not isinstance(x, actions.Snapshot), (base_device.actions + with_device.actions))) - workbench_snapshots = [s for s in snapshots if - s.software == (SnapshotSoftware.Workbench or SnapshotSoftware.WorkbenchAndroid)] - latest_snapshot_device = [d for d in (base_device, with_device) if d.id == snapshots[-1].device.id][0] - latest_snapshotworkbench_device = \ - [d for d in (base_device, with_device) if d.id == workbench_snapshots[-1].device.id][0] - # Adding actions of with_device - with_actions_one = [a for a in with_device.actions if isinstance(a, actions.ActionWithOneDevice)] - with_actions_multiple = [a for a in with_device.actions if isinstance(a, actions.ActionWithMultipleDevices)] + # base_device = Device.query.filter_by(id=dev1_id, owner_id=g.user.id).one() + self.base_device = Device.query.filter_by(id=dev1_id).one() + self.with_device = Device.query.filter_by(id=dev2_id).one() + if not self.base_device.type == self.with_device.type: + # Validation than we are speaking of the same kind of devices + raise ValidationError('The devices is not the same type.') + + # Adding actions of self.with_device + with_actions_one = [a for a in self.with_device.actions + if isinstance(a, actions.ActionWithOneDevice)] + with_actions_multiple = [a for a in self.with_device.actions + if isinstance(a, actions.ActionWithMultipleDevices)] + + # Moving the tags from `with_device` to `base_device` + # Union of tags the device had plus the (potentially) new ones + self.base_device.tags.update([x for x in self.with_device.tags]) + self.with_device.tags.clear() # We don't want to add the transient dummy tags + db.session.add(self.with_device) + + # Moving the actions from `with_device` to `base_device` for action in with_actions_one: if action.parent: - action.parent = base_device + action.parent = self.base_device else: - base_device.actions_one.add(action) + self.base_device.actions_one.add(action) for action in with_actions_multiple: if action.parent: - action.parent = base_device + action.parent = self.base_device else: - base_device.actions_multiple.add(action) + self.base_device.actions_multiple.add(action) - # Keeping the components of latest SnapshotWorkbench - base_device.components = latest_snapshotworkbench_device.components + # Keeping the components of with_device + components = OrderedSet(c for c in self.with_device.components) + self.base_device.components = components - # Properties from latest Snapshot - base_device.type = latest_snapshot_device.type - base_device.hid = latest_snapshot_device.hid - base_device.manufacturer = latest_snapshot_device.manufacturer - base_device.model = latest_snapshot_device.model - base_device.chassis = latest_snapshot_device.chassis + # Properties from with_device + self.merge() + + db.session().add(self.base_device) + db.session().final_flush() + return self.base_device + + def merge(self): + """Copies the physical properties of the base_device to the with_device. + This method mutates base_device. + """ + for field_name, value in self.with_device.physical_properties.items(): + if value is not None: + setattr(self.base_device, field_name, value) + + self.base_device.hid = self.with_device.hid class ManufacturerView(View): diff --git a/tests/test_basic.py b/tests/test_basic.py index fe3dd1da..dc60cc19 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -30,76 +30,76 @@ def test_api_docs(client: Client): assert set(docs['paths'].keys()) == { '/actions/', '/apidocs', - '/batteries/{id}/merge/', - '/bikes/{id}/merge/', - '/cameras/{id}/merge/', - '/cellphones/{id}/merge/', - '/components/{id}/merge/', - '/computer-accessories/{id}/merge/', - '/computer-monitors/{id}/merge/', - '/computers/{id}/merge/', - '/cookings/{id}/merge/', - '/data-storages/{id}/merge/', - '/dehumidifiers/{id}/merge/', + '/batteries/{dev1_id}/merge/{dev2_id}', + '/bikes/{dev1_id}/merge/{dev2_id}', + '/cameras/{dev1_id}/merge/{dev2_id}', + '/cellphones/{dev1_id}/merge/{dev2_id}', + '/components/{dev1_id}/merge/{dev2_id}', + '/computer-accessories/{dev1_id}/merge/{dev2_id}', + '/computer-monitors/{dev1_id}/merge/{dev2_id}', + '/computers/{dev1_id}/merge/{dev2_id}', + '/cookings/{dev1_id}/merge/{dev2_id}', + '/data-storages/{dev1_id}/merge/{dev2_id}', + '/dehumidifiers/{dev1_id}/merge/{dev2_id}', '/deliverynotes/', - '/desktops/{id}/merge/', + '/desktops/{dev1_id}/merge/{dev2_id}', '/devices/', '/devices/static/{filename}', - '/devices/{id}/merge/', - '/displays/{id}/merge/', - '/diy-and-gardenings/{id}/merge/', + '/devices/{dev1_id}/merge/{dev2_id}', + '/displays/{dev1_id}/merge/{dev2_id}', + '/diy-and-gardenings/{dev1_id}/merge/{dev2_id}', '/documents/devices/', '/documents/erasures/', '/documents/lots/', '/documents/static/{filename}', '/documents/stock/', - '/drills/{id}/merge/', - '/graphic-cards/{id}/merge/', - '/hard-drives/{id}/merge/', - '/homes/{id}/merge/', - '/hubs/{id}/merge/', - '/keyboards/{id}/merge/', - '/label-printers/{id}/merge/', - '/laptops/{id}/merge/', + '/drills/{dev1_id}/merge/{dev2_id}', + '/graphic-cards/{dev1_id}/merge/{dev2_id}', + '/hard-drives/{dev1_id}/merge/{dev2_id}', + '/homes/{dev1_id}/merge/{dev2_id}', + '/hubs/{dev1_id}/merge/{dev2_id}', + '/keyboards/{dev1_id}/merge/{dev2_id}', + '/label-printers/{dev1_id}/merge/{dev2_id}', + '/laptops/{dev1_id}/merge/{dev2_id}', '/lots/', '/lots/{id}/children', '/lots/{id}/devices', '/manufacturers/', - '/memory-card-readers/{id}/merge/', - '/mice/{id}/merge/', - '/microphones/{id}/merge/', - '/mixers/{id}/merge/', - '/mobiles/{id}/merge/', - '/monitors/{id}/merge/', - '/motherboards/{id}/merge/', - '/network-adapters/{id}/merge/', - '/networkings/{id}/merge/', - '/pack-of-screwdrivers/{id}/merge/', - '/printers/{id}/merge/', - '/processors/{id}/merge/', + '/memory-card-readers/{dev1_id}/merge/{dev2_id}', + '/mice/{dev1_id}/merge/{dev2_id}', + '/microphones/{dev1_id}/merge/{dev2_id}', + '/mixers/{dev1_id}/merge/{dev2_id}', + '/mobiles/{dev1_id}/merge/{dev2_id}', + '/monitors/{dev1_id}/merge/{dev2_id}', + '/motherboards/{dev1_id}/merge/{dev2_id}', + '/network-adapters/{dev1_id}/merge/{dev2_id}', + '/networkings/{dev1_id}/merge/{dev2_id}', + '/pack-of-screwdrivers/{dev1_id}/merge/{dev2_id}', + '/printers/{dev1_id}/merge/{dev2_id}', + '/processors/{dev1_id}/merge/{dev2_id}', '/proofs/', - '/rackets/{id}/merge/', - '/ram-modules/{id}/merge/', - '/recreations/{id}/merge/', - '/routers/{id}/merge/', - '/sais/{id}/merge/', - '/servers/{id}/merge/', - '/smartphones/{id}/merge/', - '/solid-state-drives/{id}/merge/', - '/sound-cards/{id}/merge/', - '/sounds/{id}/merge/', - '/stairs/{id}/merge/', - '/switches/{id}/merge/', - '/tablets/{id}/merge/', + '/rackets/{dev1_id}/merge/{dev2_id}', + '/ram-modules/{dev1_id}/merge/{dev2_id}', + '/recreations/{dev1_id}/merge/{dev2_id}', + '/routers/{dev1_id}/merge/{dev2_id}', + '/sais/{dev1_id}/merge/{dev2_id}', + '/servers/{dev1_id}/merge/{dev2_id}', + '/smartphones/{dev1_id}/merge/{dev2_id}', + '/solid-state-drives/{dev1_id}/merge/{dev2_id}', + '/sound-cards/{dev1_id}/merge/{dev2_id}', + '/sounds/{dev1_id}/merge/{dev2_id}', + '/stairs/{dev1_id}/merge/{dev2_id}', + '/switches/{dev1_id}/merge/{dev2_id}', + '/tablets/{dev1_id}/merge/{dev2_id}', '/tags/', '/tags/{tag_id}/device/{device_id}', - '/television-sets/{id}/merge/', + '/television-sets/{dev1_id}/merge/{dev2_id}', '/users/', '/users/login/', - '/video-scalers/{id}/merge/', - '/videoconferences/{id}/merge/', - '/videos/{id}/merge/', - '/wireless-access-points/{id}/merge/', + '/video-scalers/{dev1_id}/merge/{dev2_id}', + '/videoconferences/{dev1_id}/merge/{dev2_id}', + '/videos/{dev1_id}/merge/{dev2_id}', + '/wireless-access-points/{dev1_id}/merge/{dev2_id}', '/versions/' } assert docs['info'] == {'title': 'Devicehub', 'version': '0.2'} diff --git a/tests/test_merge.py b/tests/test_merge.py new file mode 100644 index 00000000..52d3436d --- /dev/null +++ b/tests/test_merge.py @@ -0,0 +1,87 @@ +import datetime +from uuid import UUID +from flask import g + +import pytest +from ereuse_devicehub.client import Client, UserClient +from ereuse_devicehub.db import db +from ereuse_devicehub.devicehub import Devicehub +from ereuse_devicehub.resources.action import models as m +from ereuse_devicehub.resources.device import models as d +from ereuse_devicehub.resources.tag import Tag +from tests import conftest +from tests.conftest import file as import_snap + + +@pytest.mark.mvp +def test_simple_merge(app: Devicehub, user: UserClient): + """ Check if is correct to do a manual merge """ + snapshot1, _ = user.post(import_snap('real-custom.snapshot.11'), res=m.Snapshot) + snapshot2, _ = user.post(import_snap('real-hp.snapshot.11'), res=m.Snapshot) + pc1_id = snapshot1['device']['id'] + pc2_id = snapshot2['device']['id'] + + with app.app_context(): + pc1 = d.Device.query.filter_by(id=pc1_id).one() + pc2 = d.Device.query.filter_by(id=pc2_id).one() + n_actions1 = len(pc1.actions) + n_actions2 = len(pc2.actions) + action1 = pc1.actions[0] + action2 = pc2.actions[0] + assert not action2 in pc1.actions + + tag = Tag(id='foo-bar', owner_id=user.user['id']) + pc2.tags.add(tag) + db.session.add(pc2) + db.session.commit() + + components1 = [com for com in pc1.components] + components2 = [com for com in pc2.components] + components1_excluded = [com for com in pc1.components if not com in components2] + assert pc1.hid != pc2.hid + assert not tag in pc1.tags + + uri = '/devices/%d/merge/%d' % (pc1_id, pc2_id) + result, _ = user.post({'id': 1}, uri=uri, status=201) + + assert pc1.hid == pc2.hid + assert action1 in pc1.actions + assert action2 in pc1.actions + assert len(pc1.actions) == n_actions1 + n_actions2 + assert set(pc2.components) == set() + assert tag in pc1.tags + assert not tag in pc2.tags + + for com in components2: + assert com in pc1.components + + for com in components1_excluded: + assert not com in pc1.components + +@pytest.mark.mvp +def test_merge_two_device_with_differents_tags(app: Devicehub, user: UserClient): + """ Check if is correct to do a manual merge of 2 diferents devices with diferents tags """ + snapshot1, _ = user.post(import_snap('real-custom.snapshot.11'), res=m.Snapshot) + snapshot2, _ = user.post(import_snap('real-hp.snapshot.11'), res=m.Snapshot) + pc1_id = snapshot1['device']['id'] + pc2_id = snapshot2['device']['id'] + + with app.app_context(): + pc1 = d.Device.query.filter_by(id=pc1_id).one() + pc2 = d.Device.query.filter_by(id=pc2_id).one() + + tag1 = Tag(id='fii-bor', owner_id=user.user['id']) + tag2 = Tag(id='foo-bar', owner_id=user.user['id']) + pc1.tags.add(tag1) + pc2.tags.add(tag2) + db.session.add(pc1) + db.session.add(pc2) + db.session.commit() + + uri = '/devices/%d/merge/%d' % (pc1_id, pc2_id) + result, _ = user.post({'id': 1}, uri=uri, status=201) + + assert pc1.hid == pc2.hid + assert tag1 in pc1.tags + assert tag2 in pc1.tags +