diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e190313f02f94d31bb3a0e9f0310eed7b729ea4..c4cbb2125a03d4c29547703d6a8627fecea37959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +* Entity State support (experimental, no StateModel support yet). See the `caosdb.State` class for + more information. +* `etag` property for the `caosdb.Query` class. The etag allows to debug the + caching and to decide whether the server has changed between queries. + ### Changed ### ### Deprecated ### @@ -75,7 +80,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `get_something` functions from all error object in `exceptions.py` * `AmbiguityException` - ### Fixed ### ### Security ### diff --git a/src/caosdb/__init__.py b/src/caosdb/__init__.py index b86fcd638c7321c2e0464b603fb736e3adfbafe3..b320b8295e2981f99788f141c1a5e8c8952aabce 100644 --- a/src/caosdb/__init__.py +++ b/src/caosdb/__init__.py @@ -38,6 +38,7 @@ import caosdb.apiutils from caosdb.common import administration from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, REFERENCE, TEXT, LIST) +from caosdb.common.state import State, Transition # Import of the basic API classes: from caosdb.common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, SUGGESTED, Container, DropOffBox, Entity, diff --git a/src/caosdb/common/administration.py b/src/caosdb/common/administration.py index 7f9336c7473ac759aab4461c84b708a3b9049d8b..5b7a098a58463489eec9b827c8baa1c90a580e13 100644 --- a/src/caosdb/common/administration.py +++ b/src/caosdb/common/administration.py @@ -53,9 +53,11 @@ def set_server_property(key, value): None """ con = get_connection() - - con._form_data_request(method="POST", path="_server_properties", - params={key: value}).read() + try: + con._form_data_request(method="POST", path="_server_properties", + params={key: value}).read() + except EntityDoesNotExistError: + raise ServerConfigurationException("Debug mode in server is probably disabled.") from None def get_server_properties(): @@ -69,7 +71,11 @@ def get_server_properties(): The server properties. """ con = get_connection() - body = con._http_request(method="GET", path="_server_properties").response + try: + body = con._http_request(method="GET", path="_server_properties").response + except EntityDoesNotExistError: + raise ServerConfigurationException("Debug mode in server is probably disabled.") from None + xml = etree.parse(body) props = dict() diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index d8f42a3f1eb4f8b8f5e4c1a5e648a01381622ec7..39d9e38d5c9d0d943af83e075a3923de2b0d7bd7 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -42,6 +42,7 @@ from warnings import warn from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT) from caosdb.common.versioning import Version +from caosdb.common.state import State from caosdb.common.utils import uuid, xml2str from caosdb.configuration import get_config from caosdb.connection.connection import get_connection @@ -112,6 +113,7 @@ class Entity(object): self.name = name self.description = description self.id = id + self.state = None @property def version(self): @@ -907,6 +909,9 @@ class Entity(object): if self.acl is not None: xml.append(self.acl.to_xml()) + if self.state is not None: + xml.append(self.state.to_xml()) + return xml @staticmethod @@ -951,6 +956,8 @@ class Entity(object): entity.add_message(child) elif isinstance(child, Version): entity.version = child + elif isinstance(child, State): + entity.state = child elif child is None or hasattr(child, "encode"): vals.append(child) elif isinstance(child, Entity): @@ -1078,7 +1085,7 @@ class Entity(object): flags=flags)[0] def update(self, strict=False, raise_exception_on_error=True, - unique=True, flags=None): + unique=True, flags=None, sync=True): """Update this entity. There are two possible work-flows to perform this update: @@ -1112,6 +1119,7 @@ class Entity(object): return Container().append(self).update( strict=strict, + sync=sync, raise_exception_on_error=raise_exception_on_error, unique=unique, flags=flags)[0] @@ -1247,6 +1255,7 @@ class QueryTemplate(): self.is_valid = lambda: False self.is_deleted = lambda: False self.version = None + self.state = None def retrieve(self, raise_exception_on_error=True, unique=True, sync=True, flags=None): @@ -2223,6 +2232,7 @@ def _basic_sync(e_local, e_remote): e_local.is_valid = e_remote.is_valid e_local.is_deleted = e_remote.is_deleted e_local.version = e_remote.version + e_local.state = e_remote.state if hasattr(e_remote, "query"): e_local.query = e_remote.query @@ -2840,7 +2850,7 @@ class Container(list): entity_url_segments = [_ENTITY_URI_SEGMENT, "&".join(id_str)] _log_request("DELETE: " + str(entity_url_segments) + - ("?" + flags if flags is not None else '')) + ("?" + str(flags) if flags is not None else '')) http_response = c.delete(entity_url_segments, query_dict=flags) cresp = Container._response_to_entities(http_response) @@ -3419,6 +3429,22 @@ class ACL(): self.deny(username=username, realm=realm, role=role, permission=permission, priority=priority) + def combine(self, other): + """ Combine and return new instance.""" + result = ACL() + result._grants.update(other._grants) + result._grants.update(self._grants) + result._denials.update(other._denials) + result._denials.update(self._denials) + result._priority_grants.update(other._priority_grants) + result._priority_grants.update(self._priority_grants) + result._priority_denials.update(other._priority_denials) + result._priority_denials.update(self._priority_denials) + return result + + def __eq__(self, other): + return isinstance(other, ACL) and other._grants == self._grants and self._denials == other._denials and self._priority_grants == other._priority_grants and self._priority_denials == other._priority_denials + def is_empty(self): return len(self._grants) + len(self._priority_grants) + \ len(self._priority_denials) + len(self._denials) == 0 @@ -3945,6 +3971,8 @@ def _parse_single_xml_element(elem): return entity elif elem.tag.lower() == "version": return Version.from_xml(elem) + elif elem.tag.lower() == "state": + return State.from_xml(elem) elif elem.tag.lower() == "emptystring": return "" elif elem.tag.lower() == "value": diff --git a/src/caosdb/common/state.py b/src/caosdb/common/state.py new file mode 100644 index 0000000000000000000000000000000000000000..cb74022bef57a77c8270b2033c904eecabaadf83 --- /dev/null +++ b/src/caosdb/common/state.py @@ -0,0 +1,198 @@ +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header + +import copy +from lxml import etree + + +def _translate_to_state_acis(acis): + result = set() + for aci in acis: + aci = copy.copy(aci) + if aci.role: + aci.role = "?STATE?" + aci.role + "?" + result.add(aci) + return result + + +class Transition: + """Transition + + Represents allowed transitions from one state to another. + + Properties + ---------- + name : str + The name of the transition + description: str + The description of the transition + from_state : str + A state name + to_state : str + A state name + """ + + def __init__(self, name, from_state, to_state, description=None): + self._name = name + self._from_state = from_state + self._to_state = to_state + self._description = description + + @property + def name(self): + return self._name + + @property + def description(self): + return self._description + + @property + def from_state(self): + return self._from_state + + @property + def to_state(self): + return self._to_state + + def __repr__(self): + return f'Transition(name="{self.name}", from_state="{self.from_state}", to_state="{self.to_state}", description="{self.description}")' + + def __eq__(self, other): + return (isinstance(other, Transition) + and other.name == self.name + and other.to_state == self.to_state + and other.from_state == self.from_state) + + def __hash__(self): + return 23472 + hash(self.name) + hash(self.from_state) + hash(self.to_state) + + @staticmethod + def from_xml(xml): + to_state = [to.get("name") for to in xml + if to.tag.lower() == "tostate"] + from_state = [from_.get("name") for from_ in xml + if from_.tag.lower() == "fromstate"] + result = Transition(name=xml.get("name"), + description=xml.get("description"), + from_state=from_state[0] if from_state else None, + to_state=to_state[0] if to_state else None) + return result + + +class State: + """State + + Represents the state of an entity and take care of the serialization and + deserialization of xml for the entity state. + + An entity state is always a State of a StateModel. + + Properties + ---------- + name : str + Name of the State + model : str + Name of the StateModel + description : str + Description of the State (read-only) + id : str + Id of the undelying State record (read-only) + transitions : set of Transition + All transitions which are available from this state (read-only) + """ + + def __init__(self, model, name): + self.name = name + self.model = model + self._id = None + self._description = None + self._transitions = None + + @property + def id(self): + return self._id + + @property + def description(self): + return self._description + + @property + def transitions(self): + return self._transitions + + def __eq__(self, other): + return (isinstance(other, State) + and self.name == other.name + and self.model == other.model) + + def __hash__(self): + return hash(self.name) + hash(self.model) + + def __repr__(self): + return f"State('{self.model}', '{self.name}')" + + def to_xml(self): + """Serialize this State to xml. + + Returns + ------- + xml : etree.Element + """ + xml = etree.Element("State") + if self.name is not None: + xml.set("name", self.name) + if self.model is not None: + xml.set("model", self.model) + return xml + + @staticmethod + def from_xml(xml): + """Create a new State instance from an xml Element. + + Parameters + ---------- + xml : etree.Element + + Returns + ------- + state : State + """ + name = xml.get("name") + model = xml.get("model") + result = State(name=name, model=model) + result._id = xml.get("id") + result._description = xml.get("description") + transitions = [Transition.from_xml(t) for t in xml if t.tag.lower() == + "transition"] + if transitions: + result._transitions = set(transitions) + + return result + + @staticmethod + def create_state_acl(acl): + from .models import ACL + state_acl = ACL() + state_acl._grants = _translate_to_state_acis(acl._grants) + state_acl._denials = _translate_to_state_acis(acl._denials) + state_acl._priority_grants = _translate_to_state_acis(acl._priority_grants) + state_acl._priority_denials = _translate_to_state_acis(acl._priority_denials) + return state_acl diff --git a/src/caosdb/exceptions.py b/src/caosdb/exceptions.py index c9da22ce59b4eee37ea35e58d24ef436a6e88255..f02a4630356726f99d8439fd821b6dd327ab22c7 100644 --- a/src/caosdb/exceptions.py +++ b/src/caosdb/exceptions.py @@ -63,6 +63,13 @@ class ConfigurationError(CaosDBException): ".pycaosdb.ini. Does at least one of them exist and are they correct?") +class ServerConfigurationException(CaosDBException): + """The server is configured in a different way than expected. + + This can be for example unexpected flags or settings or missing extensions. + """ + + class HTTPClientError(CaosDBException): """HTTPClientError represents 4xx HTTP client errors.""" diff --git a/src/caosdb/utils/caosdb_admin.py b/src/caosdb/utils/caosdb_admin.py index ff06624e3edc1379eff6dcbdfb4e8563433886d0..392d8bea2ce3d9a868c32854800ca6cb78f021ba 100755 --- a/src/caosdb/utils/caosdb_admin.py +++ b/src/caosdb/utils/caosdb_admin.py @@ -73,7 +73,7 @@ def do_retrieve(args): c.append(db.Entity(id=eid)) except ValueError: c.append(db.Entity(name=i)) - c.retrieve() + c.retrieve(flags=eval(args.flags)) print(c) diff --git a/unittests/test_state.py b/unittests/test_state.py new file mode 100644 index 0000000000000000000000000000000000000000..202c7a02af3db28434406626e5164def46febed7 --- /dev/null +++ b/unittests/test_state.py @@ -0,0 +1,77 @@ +import pytest +import caosdb as db +from caosdb import State, Transition +from caosdb.common.models import parse_xml, ACL +from lxml import etree + + +def test_state_xml(): + state = State(model="model1", name="state1") + xml = etree.tostring(state.to_xml()) + + assert xml == b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.name == "state1" + assert state.model == "model1" + + assert xml == etree.tostring(state.to_xml()) + + +def test_entity_xml(): + r = db.Record() + assert r.state is None + r.state = State(model="model1", name="state1") + + xml = etree.tostring(r.to_xml()) + assert xml == b'<Record><State name="state1" model="model1"/></Record>' + + r = parse_xml(xml) + assert r.state == State(model="model1", name="state1") + + +def test_description(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.description is None + + with pytest.raises(AttributeError): + state.description = "test" + + xml = b'<State name="state1" model="model1" description="test2"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.description == "test2" + + +def test_id(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.id is None + + with pytest.raises(AttributeError): + state.id = "2345" + + xml = b'<State name="state1" model="model1" id="1234"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.id == "1234" + + +def test_create_state_acl(): + acl = ACL() + acl.grant(role="role1", permission="DO:IT") + acl.grant(role="?OWNER?", permission="DO:THAT") + state_acl = State.create_state_acl(acl) + assert state_acl.get_permissions_for_role("?STATE?role1?") == {"DO:IT"} + assert state_acl.get_permissions_for_role("?STATE??OWNER??") == {"DO:THAT"} + + +def test_transitions(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.transitions is None + + with pytest.raises(AttributeError): + state.transitions = [] + + xml = b'<State name="state1" model="model1" id="1234"><Transition name="t1"><FromState name="state1"/><ToState name="state2"/></Transition></State>' + state = State.from_xml(etree.fromstring(xml)) + assert state.transitions == set([Transition(name="t1", from_state="state1", to_state="state2")])