class User(ModelBase): """A User model.""" fields = dict( groups=HasMany("Group", "User"), user_budget_associations=HasMany("UserBudgetAssociation", "User"), budgets=HasManyThrough("Budget", "UserBudgetAssociation"), additional=("name", "id", "login"), ignore=("password_digest", "remember_token", "key"), ) def __init__(self): pass
class Job(ModelBase): """A Job model.""" fields = dict( job_associations=HasMany("JobAssociation", "Job"), operations=HasManyThrough("Operation", "JobAssociation"), state=JSON(), ) methods = ["status"] @property def is_complete(self): return self.pc == -2 @property def uploads(self): http = self.session._aqhttp return http.get("krill/uploads?job={}".format(self.id))["uploads"] @property def start_time(self): return self.state[0]["time"] @property def end_time(self): return self.state[-2]["time"]
class ObjectType(SaveMixin, ModelBase): """A ObjectType model that represents the type of container an item is.""" fields = dict(items=HasMany("Item", "ObjectType"), sample_type=HasOne("SampleType")) def __str__(self): return self._to_str("id", "name") def new_item(self, sample: Union[int, Sample]): """Create a new item. .. versionadded:: 0.1.5a13 :param sample: the sample id :return: the new item """ if isinstance(sample, int): sample_id = sample sample = self.session.Sample.find(sample_id) elif isinstance(sample, Sample): sample_id = sample.id else: raise TypeError("Sample must be either a sample_id (int) or a" " Sample instance.") return self.session.Item.new(sample=sample, sample_id=sample_id, object_type=self, object_type_id=self.id)
class Author(ModelBase): fields = dict(books=HasMany("Book", ref="book_id", callback="foo")) def __init__(self): super().__init__(books=None) def foo(self, *args): return [Book.load_from({"id": 10}, fake_session)]
class Author(ModelBase): fields = dict(books=HasMany("Book", ref="book_id", callback="foo")) def __init__(self): super().__init__(books=None) def foo(self, *args): return []
class SampleType(FieldTypeInterface, JSONSaveMixin, ModelBase): """A SampleType model.""" fields = dict( samples=HasMany("Sample", "SampleType"), field_types=HasMany("FieldType", ref="parent_id", additional_args={"parent_class": "SampleType"}), # TODO: operation_type_afts # TODO: property_afts # TODO: add relationships description ) def field_type(self, name, role=None): return super().field_type(name, role) @property def properties(self): props = {} for ft in self.field_types: if ft.ftype == "sample": props[ft.name] = [str(aft) for aft in ft.allowable_field_types] else: props[ft.name] = ft.ftype return props def new_sample(self, name, description, project, properties=None): if properties is None: properties = dict() sample = self.session.Sample.new( name=name, sample_type=self, description=description, project=project, properties=properties, ) return sample def _get_update_json(self): return self.dump(include=("field_types", )) def _get_create_json(self): return self.dump(include=("field_types", )) def __str__(self): return self._to_str("id", "name")
def test_has_many(): """ Tests the HasMany relationship. Its expected with a MyModel and RefModelName, that the params should return a lambda callable equiavalent to: .. code-block:: python params = lambda: x: {"ref_model_id": x.id} """ class RefModel: id = 4 name = "myname" # has_many tries to return lambda x: {"ref_model_id": x.id} hasmany = HasMany("ModelName", RefModel.__name__) assert hasmany.nested == "ModelName" assert hasmany.callback_args[1](RefModel) == {"ref_model_id": 4} # has_many tries to return lambda x: {"ref_model_name": x.name} hasmany = HasMany("ModelName", RefModel.__name__, attr="name") assert hasmany.callback_args[1](RefModel) == {"ref_model_name": "myname"}
class Plan(DataAssociatorMixin, SaveMixin, DeleteMixin, ModelBase): """A Plan model.""" fields = dict( data_associations=HasManyGeneric( "DataAssociation", additional_args={"parent_class": "Plan"}), plan_associations=HasMany("PlanAssociation", "Plan"), operations=HasManyThrough("Operation", "PlanAssociation"), wires=Many("Wire", callback="_get_wires_from_server"), layout=JSON(), status=Raw(default="planning"), user=HasOne("User"), ) query_hook = {"include": ["plan_associations", "operations"]} def __init__(self, name="MyPlan", status="planning"): super().__init__( name=name, status=status, data_associations=None, plan_associations=None, layout={ "id": 0, "children": [], "documentation": "No documentation of this module", "height": 60, "input": [], "output": [], "name": "no_name", "parent_id": -1, "width": 160, "wires": [], }, ) def add_operation(self, operation): """Adds an operation to the Plan. :param operation: Operation to add :type operation: Operation :return: None :rtype: None """ self.append_to_many("operations", operation) def add_operations(self, operations): """Adds multiple operations to the Plan. :param operations: list of Operations :type operations: list :return: None :rtype: None """ for operation in operations: self.add_operation(operation) # TODO: this is not functional or not needed # def has_operation(self, op): # if op is None: # return False # return self.operations and op.rid in [_op.rid for _op in self.operations] def find_wires(self, src, dest): """Retrieves the wire between a source and destination FieldValues. :param src: source FieldValue :type src: FieldValue :param dest: destination FieldValue :type dest: FieldValue :return: array of wires between src and dest FieldValues (determined by rid) :rtype: array of wires """ found = [] for wire in self.wires: if src.rid == wire.source.rid and dest.rid == wire.destination.rid: found.append(wire) return found def wire(self, src, dest): """Creates a new wire between src and dest FieldValues. Returns the new wire if it does not exist in the plan. If the wire already exists and error_if_exists is True, then the existing wire is returned. Else an exception is raised. :param src: source field value (the input of the wire) :type src: FieldValue :param dest: destination field value (the output of the wire) :type dest: FieldValue :param error_if_exists: Raise an error if the Wire already exists in the plan. :type error_if_exists: boolean :return: Newly created wire or existing wire (if exists and error_if_exists == False) :rtype: Wire """ # TODO: these checks are unnecessary? # if not self.has_operation(src.operation): # raise AquariumModelError( # "Cannot wire because the wire's source FieldValue {} does " # "not exist in the Plan because its Operation '{}' is not in the plan".format( # src, src.operation # ) # ) # if not self.has_operation(dest.operation): # raise AquariumModelError( # "Cannot wire because the wire's destination FieldValue {} does not " # "exist in the Plan because its Operation '{}' is not in the plan.".format( # dest, dest.operation # ) # ) wire = Wire(source=src, destination=dest) self.append_to_many("wires", wire) return wire def _collect_wires(self): incoming_wires = [] outgoing_wires = [] if self.operations: for operation in self.operations: if operation.field_values: for field_value in operation.field_values: if field_value.outgoing_wires: outgoing_wires += field_value.outgoing_wires if field_value.incoming_wires: incoming_wires += field_value.incoming_wires return incoming_wires, outgoing_wires def _get_wire_dict(self, wires): """Return all wires in the plan grouped by the wire identifier.""" wire_dict = {} for w in wires: wire_dict.setdefault(w.identifier, list()) if w not in wire_dict[w.identifier]: wire_dict[w.identifier].append(w) return wire_dict def _get_wires_from_server(self, *args): fvs = [] if self.operations: for op in self.operations: fvs += op.field_values fv_ids = [fv.id for fv in fvs if fv.id is not None] wires_from_server = [] if fv_ids: wires_from_server = self.session.Wire.where({ "from_id": fv_ids, "to_id": fv_ids }) return wires_from_server def submit(self, user, budget): """Submits the Plan to the Aquarium server. :param user: User to submit the Plan :type user: User :param budget: Budget to use for the Plan :type budget: Budget :return: JSON :rtype: dict """ result = self.session.utils.submit_plan(self, user, budget) return result def all_data_associations(self): das = self.data_associations for operation in self.operations: das += operation.data_associations for field_value in operation.field_values: if field_value.item: das += field_value.item.data_associations return das @classmethod def interface(cls, session): # get model interface from Base class model_interface = super().interface(session) # make a special find method for plans, as generic method is too minimal. def new_find(model_id): return model_interface.get("plans/{}.json".format(model_id)) # override the old find method model_interface.find = new_find return model_interface def validate(self, raise_error=True): """Validates the plan. :param raise_error: If True, raises an AquariumModelException. If false, returns the error messages. :type raise_error: boolean :return: list of error messages :rtype: array """ errors = [] field_values = [] for op in self.operations: for fv in op.field_values: field_values.append(fv) fv_keys = [fv._primary_key for fv in field_values] for wire in self.wires: for _fvtype in ["source", "destination"]: field_value = getattr(wire, _fvtype) if field_value._primary_key not in fv_keys: msg = ( "The FieldValue of a wire Wire(rid={}).{} is missing from the " "list of FieldValues in the plan. Did you forget to add an " "operation to the plan?".format( wire._primary_key, _fvtype)) errors.append(msg) if raise_error and errors: msg = "\n".join( ["(ErrNo {}) - {}".format(i, e) for i, e in enumerate(errors)]) raise AquariumModelError( "Plan {} had the following errors:\n{}".format(self, msg)) return errors def to_save_json(self): """Returns the json representation of the plan for saving and creating Plans on the Aquarium server. :return: JSON :rtype: dict """ if not self.operations: self.operations = [] if not self.wires: self.wires = [] self.validate(raise_error=True) for op in self.operations: op.field_values json_data = self.dump( include={"operations": { "field_values": ["sample", "item"] }}) # remove redundant wires wire_dict = {} for wire in self.wires: wire_data = wire.to_save_json() attributes = [ wire_data["from_id"], wire_data["from"]["rid"], wire_data["to_id"], wire_data["to"]["rid"], ] wire_hash = "*&".join([str(a) for a in attributes]) wire_dict[wire_hash] = wire_data json_data["wires"] = list(wire_dict.values()) # validate fv_rids = [] fv_id_to_rids = {} for op in json_data["operations"]: for fv in op["field_values"]: if fv["id"]: fv_id_to_rids[fv["id"]] = fv["rid"] fv_rids.append(fv["rid"]) # fix json rids and ids warnings = [] for wire_data in json_data["wires"]: if wire_data["from_id"] in fv_id_to_rids: wire_data["from"]["rid"] = fv_id_to_rids[wire_data["from_id"]] if wire_data["to_id"] in fv_id_to_rids: wire_data["to"]["rid"] = fv_id_to_rids[wire_data["to_id"]] if not wire_data["from"]["rid"] in fv_rids: warnings.append("FieldValue rid={} is missing.".format( wire_data["from"]["rid"])) if not wire_data["to"]["rid"] in fv_rids: warnings.append("FieldValue rid={} is missing.".format( wire_data["to"]["rid"])) if warnings: print(fv_rids) warn(",".join(warnings)) if json_data["layout"] is not None: json_data["layout"] = json.loads(json_data["layout"]) else: del json_data["layout"] return json_data def _get_create_json(self): return self.to_save_json() def _get_update_json(self): return self.to_save_json() def _get_create_params(self): return {"user_id": self.session.current_user.id} def _get_update_params(self): return {"user_id": self.session.current_user.id} def estimate_cost(self): """Estimates the cost of the plan on the Aquarium server. This is necessary before plan submission. :return: cost :rtype: dict """ return self.session.utils.estimate_plan_cost(self) def field_values(self): raise NotImplementedError() def step(self): """Steps a plan.""" return self.session.utils.step_plan(self.id) def show(self): """Print the plan nicely.""" print(self.name + " id: " + str(self.id)) for operation in self.operations: operation.show(pre=" ") for wire in self.wires: wire.show(pre=" ") def replan(self): """Copies or replans the plan. Returns a plan copy """ return self.session.utils.replan(self.id) def download_files(self, outdir=None, overwrite=True): """Downloads all uploads associated with the plan. Downloads happen ansynchrounously. :param outdir: output directory for downloaded files :param overwrite: whether to overwrite files if they exist :return: None """ uploads = [] for da in self.data_associations: if da.upload is not None: uploads.append(da.upload) return Upload.async_download(uploads, outdir, overwrite)
class Collection(ItemLocationMixin, DataAssociatorMixin, SaveMixin, ControllerMixin, ModelBase): """A Collection model, such as a 96-well plate, which contains many `parts`, each of which can be associated with a different sample.""" fields = dict( object_type=HasOne("ObjectType"), data_associations=HasManyGeneric( "DataAssociation", additional_args={"parent_class": "Collection"}), part_associations=HasMany("PartAssociation", "Collection"), parts=HasManyThrough("Item", "PartAssociation", ref="part_id"), ) query_hook = {"methods": ["dimensions"]} # TODO: validate dimensions is returning the same dimensions as that in the object_type # TODO: init should establish dimensions def __init__( self, object_type: ObjectType = None, location: str = None, data_associations: List = None, parts: List = None, part_associations: List = None, **kwargs, ): """Initialize a new Collection. .. versionchanged:: 0.1.5a10 Advanced indexing added for setting and getting samples and data associations **Setting samples using new advanced indexing** .. code-block:: python object_type = session.ObjectType.one(query='rows > 2 AND columns > 2') collection = session.Collection.new(object_type=object_type) # assign sample '1' to (0, 0) row=0, column=0 collection[0, 0] = 1 # assign sample '2' to (1, 2) row=1, column=2 collection[1, 2] = 2 # assign sample '3234' to row 3 collection[3] = 3234 # assign sample '444' to column 1 collection[:, 1] = 444 # assign sample '6' to the whole collection collection[:, :] = 6 # assign sample using Sample instance collection[2, 2] = session.Sample.one() **Getting samples using new advanced indexing** .. code-block:: python # get 2d matrix of sample ids print(collection.matrix) # or collection.sample_id_matrix # get 2d matrix of Samples assigned at each location print(collection.sample_matrix) # get 2d matrix of Parts assigned at each location print(collection.part_matrix) # get 2d matrix of PartAssociations assigned at each location collection.part_associations_matrix # get 2d matrix of values of DataAssociations at each location collection.data_matrix # get 2d matrix of DataAssociations at each location collection.data_association_matrix **Assigning data to locations** To assign data, you can use the advanced indexing on the `data_matrix` .. code-block:: python collection.data_matrix[0, 0] = {'key': 'value'} collection.data_matrix[1] = {'key': 'value2'} collection.associate_to('key', 'value3', 3, 3) You can delete associations using the following: .. code-block:: python # delete 3, 3 collection.delete_association_at('key', 3, 3) # delete first three rows at column 3 collection.delete_association_at('key', slice(None, 3, None), 3) # delete all of the 'key' associations collection.delete_association_at('key', slice(None, None, None), slice(None, None, None)) :param object_type: :param location: :param data_associations: :param parts: :param part_associations: :param kwargs: """ if isinstance(object_type, ObjectType): object_type = object_type object_type_id = object_type.id dims = object_type.rows, object_type.columns else: object_type_id = None dims = None super().__init__( object_type=object_type, object_type_id=object_type_id, location=location, dimensions=dims, data_associations=data_associations, part_associations=part_associations, parts=parts, **kwargs, ) def _empty(self): nrows, ncols = self.dimensions data = [] for r in range(nrows): data.append([None] * ncols) return data def __part_association_matrix(self): data = self._empty() if self.part_associations: for assoc in self.part_associations: data[assoc.row][assoc.column] = assoc return data @staticmethod def _get_part(assoc): if assoc is not None: return assoc.part @staticmethod def _get_sample_id(assoc): if assoc is not None: if assoc.part: if assoc.part.sample_id: return assoc.part.sample_id elif assoc.part.sample: return assoc.part.sample.id return None @staticmethod def _get_sample(assoc): if assoc is not None: if assoc.part: return assoc.part.sample @staticmethod def _get_data_association(assoc): if assoc is not None: if assoc.part: if assoc.data_associations: return {a.key: a for a in assoc.part.data_associations} else: return {} @staticmethod def _get_data_value(assoc): if assoc is not None: if assoc.part: if assoc.part.data_associations: return { a.key: a.value for a in assoc.part.data_associations } else: return {} @staticmethod def _no_setter(x): raise ValueError("Setter is not implemented.") def _set_sample( self, data: List[List[PartAssociation]], r: int, c: int, sample: Union[int, Sample], ): if data[r][c]: part = data[r][c].part else: part = self.session.Item.new(object_type_id=self.session. ObjectType.find_by_name("__Part").id) association = self.session.PartAssociation.new( part_id=part.id, collection_id=self.id, row=r, column=c) association.part = part self.append_to_many("part_associations", association) if isinstance(sample, int): part.sample_id = sample part.reset_field("sample") elif isinstance(sample, Sample): part.sample = sample part.sample_id = sample.id elif sample is None: part.sample = None part.sample_id = None else: raise ValueError( "{} must be a Sample instance or an int".format(sample)) def _set_key_values( self, data: List[List[PartAssociation]], r: int, c: int, data_dict: Dict[str, Any], ): part = None if data[r][c]: part = data[r][c].part if not part: raise ValueError( "Cannot set data to ({r},{c}) because " "there is no Sample assigned to that location".format(r=r, c=c)) for k, v in data_dict.items(): part.associate(k, v) @property def _mapping(self): factory = MatrixMappingFactory(self.__part_association_matrix()) default_setter = self._no_setter factory.new("part_association", setter=default_setter, getter=None) factory.new("part", setter=default_setter, getter=self._get_part) factory.new("sample_id", setter=self._set_sample, getter=self._get_sample_id) factory.new("sample", setter=default_setter, getter=self._get_sample) factory.new("data", setter=self._set_key_values, getter=self._get_data_value) factory.new("data_association", setter=default_setter, getter=self._get_data_association) return factory @property def matrix(self): """Returns the matrix of Samples for this Collection. (Consider using samples of parts directly.) .. versionchanged:: 0.1.5a9 Refactored using MatrixMapping """ return self.sample_id_matrix @property def part_matrix(self) -> MatrixMapping[Item]: """Return a view of :class:`Item <pydent.models.Item>` .. versionadded:: 0.1.5a9 :return: collection as a view of Items (Parts) """ return self._mapping["part"] @property def part_association_matrix(self) -> MatrixMapping[PartAssociation]: """Return a view of part associations. .. versionadded:: 0.1.5a9 :return: collection as a view of PartAssociations """ return self._mapping["part_association"] @property def sample_id_matrix(self) -> MatrixMapping[int]: """Return a view of sample_ids :class:`Sample<pydent.models.Sample>` .. versionadded:: 0.1.5a9 :return: collection as a view of Sample.ids """ return self._mapping["sample_id"] @property def sample_matrix(self) -> MatrixMapping[Sample]: """Return a view of :class:`Sample<pydent.models.Sample>` .. versionadded:: 0.1.5a9 :return: collection as a view of Samples """ return self._mapping["sample"] @property def data_matrix(self) -> MatrixMapping[Any]: """Return a view of values from the. :class:`DataAssociation<pydent.models.DataAssociation>` .. versionadded:: 0.1.5a9 :return: collection as a view of DataAssociation values """ return self._mapping["data"] @property def data_association_matrix(self) -> MatrixMapping[DataAssociation]: """Return a view of. :class:`DataAssociation<pydent.models.DataAssociation>` .. versionadded:: 0.1.5a9 :return: collection as a view of DataAssociations """ return self._mapping["data_association"] def part(self, row, col) -> Item: """Returns the part Item at (row, col) of this Collection (zero- based).""" return self.part_matrix[row, col] def as_item(self): """Returns the Item object with the ID of this Collection.""" return self.session.Item.find(self.id) # TODO: implement save and create def create(self): """Create a new empty collection on the server.""" result = self.session.utils.model_update("collections", self.object_type_id, self.dump()) self.id = result["id"] self.created_at = result["created_at"] self.updated_at = result["updated_at"] self.update() return self def _validate_for_update(self): if not self.id: raise ValueError( "Cannot update part associations since the Collection " "is not saved.") for association in self.part_associations: if association.has_unsaved_sample(): raise ValueError( "Cannot update. Collection contains Samples ({r},{c})" " that have not yet been saved.".format( r=association.row, c=association.column)) def update(self): self._validate_for_update() self.move(self.location) for association in self.part_associations: if not association.collection_id: association.collection_id = self.id association.part.save() association.part_id = association.part.id association.save() self.refresh() def assign_sample(self, sample_id: int, pairs: List[Tuple[int, int]]): """Assign sample id to the (row, column) pairs for the collection. :param sample_id: the sample id to assign :param pairs: list of (row, column) tuples :return: self """ self.controller_method( "assign_sample", self.get_tableized_name(), self.id, data={ "sample_id": sample_id, "pairs": pairs }, ) self.refresh() return self def remove_sample(self, pairs: List[Tuple[int, int]]): """Clear the sample_id assigment in the (row, column) pairs for the collection. :param pairs: list of (row, column) tuples :return: self """ self.controller_method( "delete_selection", self.get_tableized_name(), self.id, data={"pairs": pairs}, ) self.refresh() return self def associate_to(self, key, value, r: int, c: int): self.data_matrix[r, c] = {key: value} def delete_association_at(self, key, r: int, c: int): part_matrix = self.part_matrix for r, c in part_matrix._iter_indices((r, c)): part = part_matrix[r, c] part.delete_data_associations(key) def __getitem__( self, index: IndexType) -> Union[int, List[int], List[List[int]]]: """Returns the sample_id of the part at the provided index. :param index: either an int (for rows), tuple of ints/slice objs, (row, column), or a slice object. :return: sample_ids """ return self.matrix[index] def __setitem__(self, index: IndexType, sample: Union[int, Sample]): """Sets the sample_ids of the collection, creating the PartAssociations and parts if needed. :param index: either an int (for rows), tuple of ints/slice objs, (row, column), or a slice object. :param sample: either a Sample with a valid id or sample_id :return: """ self.sample_id_matrix[index] = sample
class Item(DataAssociatorMixin, ItemLocationMixin, ModelBase): """A physical object in the lab, which a location and unique id.""" DID_ITEM_WARNING = False fields = dict( sample=HasOne("Sample"), object_type=HasOne("ObjectType"), data_associations=HasManyGeneric( "DataAssociation", additional_args={"parent_class": "Item"}), data=Raw(), ignore=("locator_id", ), part_associations=HasMany("PartAssociation", ref="part_id"), # TODO: add to change log collections=HasManyThrough( "Collection", "PartAssociation"), # TODO: add to change log ) query_hook = {"methods": ["is_part"]} def __init__( self=None, sample_id=None, sample=None, object_type=None, object_type_id=None, location=None, ): if sample_id is None: if sample and sample.id: sample_id = sample.id if object_type_id is None: if object_type and object_type.id: object_type_id = object_type.id super().__init__( object_type_id=object_type_id, object_type=object_type, sample_id=sample_id, sample=sample, location=location, ) def create(self): with DataAssociationSaveContext(self): result = self.session.utils.create_items([self]) self.reload(result[0]["item"]) return self @property def containing_collection(self): """Returns the collection of which this Item is a part. Returns the collection object if the Item is a part, otherwise returns None. """ if not self.is_part: return None assoc_list = self.session.PartAssociation.where({"part_id": self.id}) if not assoc_list: return if len(assoc_list) != 1: return None part_assoc = next(iter(assoc_list)) if not part_assoc: return None return self.session.Collection.find(part_assoc.collection_id) def as_collection(self): """Returns the Collection object with the ID of this Item, which must be a collection. Returns None if this Item is not a collection. """ if not self.is_collection: return None return self.session.Collection.find(self.id) @property def is_collection(self): """Returns True if this Item is a collection in a PartAssociation. Note: this is not how Aquarium does this test in the `collection?` method. """ assoc_list = self.session.PartAssociation.where( {"collection_id": self.id}) return bool(assoc_list) # TODO: add to change log @property def collection(self): return self.collections[0] # TODO: add to change log @property def part_association(self): return self.part_associations[0]
class Sample(FieldValueInterface, ModelBase): """A Sample model.""" fields = dict( # sample relationships sample_type=HasOne("SampleType"), items=HasMany("Item", ref="sample_id"), field_values=HasMany("FieldValue", ref="parent_id", additional_args={"parent_class": "Sample"}), user=HasOne("User"), ) METATYPE = "sample_type" def __init__( self, name=None, project=None, description=None, sample_type=None, sample_type_id=None, properties=None, field_values=None, ): """ :param name: :type name: :param project: :type project: :param description: :type description: :param sample_type_id: :type sample_type_id: :param properties: :type properties: """ super().__init__( name=name, project=project, description=description, sample_type_id=sample_type_id, sample_type=sample_type, field_values=field_values, items=None, ) if properties is not None: self.update_properties(properties) @property def identifier(self): """Return the identifier used by Aquarium in autocompletes.""" return "{}: {}".format(self.id, self.name) def field_value(self, name): """Returns the :class:`FieldValue` associated with the sample by its name. If the there is more than one FieldValue with the same name (as in field_value arrays), it will return the first FieldValue. See the. :meth:`Sample.field_value_array` method. :param name: name of the field value :type name: str :return: the field value :rtype: FieldValue """ return self.get_field_value(name, None) def field_value_array(self, name): return self.get_field_value_array(name, None) def _property_accessor(self, fv): ft = self.safe_get_field_type(fv) if ft: if ft.ftype == "sample": return fv.sample else: if ft.ftype == "number": if isinstance(fv.value, str): return json.loads(fv.value) return fv.value @property def properties(self): return self._field_value_dictionary(lambda ft: ft.name, self._property_accessor) def field_value_dictionary(self): return self._field_value_dictionary(lambda ft: ft.name, lambda x: x) def update_properties(self, prop_dict): """Update the FieldValues properties for this sample. :param prop_dict: values to update :type pro fp_dict: dict :return: self :rtype: Sample """ ft_dict = {ft.name: ft for ft in self.get_field_types()} for name, val in prop_dict.items(): ft = ft_dict[name] if ft.is_parameter(): key = "value" else: key = "sample" if issubclass(type(val), Sequence) and ft.array: self.set_field_value_array(name, None, [{key: v} for v in val]) else: self.set_field_value(name, None, {key: val}) def create(self) -> List["Sample"]: """Creates this sample in the Aquarium instance if the sample has all fields required by the sample type. Uses is_savable to check for missing fields. .. versionchanged:: 0.1.5a17 Raises `AquariumModelError` if sample is missing required properties :return: the list of saved samples :raises AquariumModelError: if required FieldValues are missing. """ self.is_savable(do_raise=True) return self.session.utils.create_samples([self]) def is_savable(self, do_raise: bool = True) -> Tuple[bool, List[str]]: """Checks whether this sample has fields required by the sample type. If so, the sample can be saved or updated. .. versionadded:: 0.1.5a17 :param do_raise: raise an exception when required field values are missing :return: a boolean whether there are errors, and list of error messages :raises AquariumModelError: if do_raise is True and required fields are missing """ errors = [] for k, v in self.properties.items(): if v is None and self.sample_type.field_type(k).required: errors.append("FieldValue '{}' is required.".format(k)) if do_raise and errors: raise AquariumModelError( "Cannot update/save due to the following:\n" "Sample: id={} name={} ({})\n\t{}".format( self.id, self.name, self.sample_type.name, "\n\t".join(errors))) return len(errors) == 0, errors def save(self): """Saves the sample, either by creating a new sample (if id=None) or updating the existing sample on the server. .. versionchanged:: 0.1.5a17 Raises `AquariumModelError` if sample is missing required properties :return: """ if self.id: self.update() else: self.create() def update(self) -> "Sample": """Updates the sample on the server. .. versionchanged:: 0.1.5a17 Raises `AquariumModelError` if sample is missing required properties :return: """ self.is_savable(do_raise=True) for fv in self.field_values: fv.reload(fv.save()) new_fvs = self.field_values server_fvs = self.session.FieldValue.where( dict(parent_id=self.id, parent_class="Sample")) to_remove = [ fv for fv in server_fvs if fv.id not in [_fv.id for _fv in new_fvs] ] if to_remove: warn( "Trident tried to save a Sample, but it required FieldValues to be deleted." ) for fv in to_remove: fv.parent_id = None fv.save() self.reload(self.session.utils.json_save("Sample", self.dump())) return self def merge(self): """Merge sample by name. If a sample with the same name and sample_type_id is found on the server, update that model and save the updated data to the server. Else, create a new sample on the server. :param sample: sample to merge. :return: True if merged, False otherwise """ try: self.save() except Exception as e: existing = self.session.Sample.find_by_name(self.name) if existing: if self.sample_type_id == existing.sample_type_id: existing.update_properties(self.properties) existing.description = self.description existing.project = self.project existing.save() self.reload(existing.dump()) return True else: raise e else: raise e return False def available_items(self, object_type_name=None, object_type_id=None): query = {"name": object_type_name, "id": object_type_id} query = {k: v for k, v in query.items() if v is not None} if query == {}: return [i for i in self.items if i.location != "deleted"] else: object_types = self.session.ObjectType.where(query) object_type = object_types[0] return [ i for i in self.items if i.location != "deleted" and i.object_type_id == object_type.id ] def copy(self): """Return a copy of this sample.""" copied = super().copy() copied.anonymize() return copied def __str__(self): return self._to_str("id", "name", "sample_type")
class Budget(ModelBase): """A Budget model.""" fields = dict(user_budget_associations=HasMany("UserBudgetAssociation", "Budget"))
class OperationType(FieldTypeInterface, SaveMixin, ModelBase): """Represents an OperationType, which is the definition of a protocol in Aquarium.""" fields = dict( operations=HasMany("Operation", "OperationType"), field_types=HasMany( "FieldType", ref="parent_id", additional_args={"parent_class": "OperationType"}, ), codes=HasManyGeneric("Code"), cost_model=HasOneFromMany( "Code", ref="parent_id", additional_args={ "parent_class": "OperationType", "name": "cost_model" }, ), documentation=HasOneFromMany( "Code", ref="parent_id", additional_args={ "parent_class": "OperationType", "name": "documentation" }, ), precondition=HasOneFromMany( "Code", ref="parent_id", additional_args={ "parent_class": "OperationType", "name": "precondition" }, ), protocol=HasOneFromMany( "Code", ref="parent_id", additional_args={ "parent_class": "OperationType", "name": "protocol" }, ), test=HasOneFromMany( "Code", ref="parent_id", additional_args={ "parent_class": "OperationType", "name": "test" }, ), ) def code(self, accessor): if accessor in [ "protocol", "precondition", "documentation", "cost_model", "test", ]: return getattr(self, accessor) return None def instance(self, xpos=0, ypos=0): operation = self.session.Operation.new(operation_type_id=self.id, status="planning", x=xpos, y=ypos) operation.operation_type = self operation.init_field_values() return operation def field_type(self, name, role): if self.field_types: fts = filter_list(self.field_types, role=role, name=name) if len(fts) > 0: return fts[0] def sample_type(self): sample_types = [] for field_type in self.field_types: for allowable_field_type in field_type.allowable_field_types: sample_types.append(allowable_field_type.sample_type) return sample_types def object_type(self): object_types = [] for field_type in self.field_types: for allowable_field_type in field_type.allowable_field_types: object_types.append(allowable_field_type.object_type) return object_types def output(self, name): return self.field_type(name, "output") def input(self, name): return self.field_type(name, "input") def save(self): """Saves the Operation Type to the Aquarium server. Requires this Operation Type to be connected to a session. """ return self.reload(self.session.utils.create_operation_type(self)) def to_save_json(self): op_data = self.dump( include={ "field_types": { "allowable_field_types": {} }, "protocol": {}, "cost_model": {}, "documentation": {}, "precondition": {}, "test": {}, }) # Format 'sample_type' and 'object_type' keys for afts for ft_d, ft in zip(op_data["field_types"], self.field_types): for aft_d, aft in zip(ft_d["allowable_field_types"], ft.allowable_field_types): aft_d["sample_type"] = {"name": aft.sample_type.name} aft_d["object_type"] = {"name": aft.object_type.name} return op_data def _get_create_json(self): return self.to_save_json() def _get_update_json(self): return self.to_save_json() def __str__(self): return self._to_str("id", "name", "category")
class Operation(FieldValueInterface, DataAssociatorMixin, ModelBase): """A Operation model.""" fields = dict( field_values=HasMany("FieldValue", ref="parent_id", additional_args={"parent_class": "Operation"}), data_associations=HasManyGeneric( "DataAssociation", additional_args={"parent_class": "Operation"}), operation_type=HasOne("OperationType"), job_associations=HasMany("JobAssociation", "Operation"), jobs=HasManyThrough("Job", "JobAssociation"), plan_associations=HasMany("PlanAssociation", "Operation"), plans=HasManyThrough("Plan", "PlanAssociation"), status=Raw(default="planning"), routing=Function("get_routing"), user=HasOne("User"), ) METATYPE = "operation_type" def __init__(self, operation_type_id=None, operation_type=None, status=None, x=0, y=0): super().__init__( operation_type_id=operation_type_id, operation_type=operation_type, status=status, field_values=None, x=x, y=y, ) def get_routing(self): routing_dict = {} fvs = self.field_values ot = self.operation_type if ot is None: return routing_dict for ft in ot.field_types: if ft.routing is not None: routing_dict[ft.routing] = None if fvs is not None: for fv in self.field_values: ft = self.safe_get_field_type(fv) if ft.routing is not None: routing_dict[ft.routing] = fv.sid return routing_dict @property def successors(self): successors = [] if self.outputs: for output in self.outputs: for s in output.successors: successors.append(s.operation) return successors @property def predecessors(self): predecessors = [] if self.inputs: for inputs in self.inputs: for s in inputs.predecessors: predecessors.append(s.operation) return predecessors def init_field_values(self): """Initialize the :class:`FieldValue` from the :class:`FieldType` of the parent :class:`Operation` type.""" self.field_values = [] for field_type in self.get_metatype().field_types: if not field_type.array: self.new_field_value_from_field_type(field_type) return self def field_value_array(self, name, role): """Returns :class:`FieldValue` array with name and role.""" return filter_list(self.get_field_value_array(name, role)) def field_value(self, name, role): """Returns :class:`FieldValue` with name and role. Return None if not found. """ if self.field_values: fvs = self.field_value_array(name, role) if len(fvs) == 0: return None if len(fvs) == 1: return fvs[0] msg = "More than one FieldValue found for the field value" msg += ( " of operation {}.(id={}).{}.{}. Are you sure you didn't mean to " "call 'field_value_array'?") raise AquariumModelError( msg.format(self.operation_type, self.id, role, name)) @property def plan(self): return self.plans[0] def input_array(self, name): return self.get_field_value_array(name, "input") def output_array(self, name): return self.get_field_value_array(name, "output") def input(self, name): """Returns the input :class:`FieldValue` by name.""" return self.field_value(name, "input") def output(self, name): """Returns the output :class:`FieldValue` by name.""" return self.field_value(name, "output") def add_to_input_array(self, name, sample=None, item=None, value=None, container=None): """Creates and adds a new input :class:`FieldValue`. When setting values to items/samples/containers, the item/sample/container must be saved. :param name: name of the FieldType/FieldValue :type name: string :param sample: an existing Sample :type sample: Sample :param item: an existing Item :type item: Item :param value: a string or number value :type value: string|integer :param container: an existing ObjectType :type container: ObjectType :return: the newly created FieldValue :rtype: FieldValue """ return self.new_field_value( name, "input", dict(sample=sample, item=item, value=value, container=container), ) def add_to_output_array(self, name, sample=None, item=None, value=None, container=None): """Creates and adds a new output :class:`FieldValue`. When setting values to items/samples/containers, the item/sample/container must be saved. :param name: name of the FieldType/FieldValue :type name: string :param sample: an existing Sample :type sample: Sample :param item: an existing Item :type item: Item :param value: a string or number value :type value: string|integer :param container: an existing ObjectType :type container: ObjectType :return: the newly created FieldValue :rtype: FieldValue """ return self.new_field_value( name, "output", dict(sample=sample, item=item, value=value, container=container), ) @property def inputs(self): """Return a list of all input :class:`FieldValues`""" return [fv for fv in self.field_values if fv.role == "input"] @property def outputs(self): """Return a list of all output :class:`FieldValues`""" return [fv for fv in self.field_values if fv.role == "output"] def set_input(self, name, sample=None, item=None, value=None, container=None, object_type=None): """Sets a input :class:`FieldValue` to a value. When setting values to items/samples/containers, the item/sample/container must be saved. :param name: name of the FieldValue/FieldType :type name: string :param sample: an existing Sample :type sample: Sample :param item: an existing Item :type item: Item :param value: a string or number value :type value: string|integer :param container: an existing ObjectType :type container: ObjectType :return: the existing FieldValue modified :rtype: FieldValue """ if object_type is None and container: object_type = container return self.set_field_value( name, "input", dict(sample=sample, item=item, value=value, object_type=container), ) def set_output(self, name, sample=None, item=None, value=None, container=None, object_type=None): """Sets a output :class:`FieldValue` to a value. When setting values to items/samples/containers, the item/sample/container must be saved. :param name: name of the FieldValue/FieldType :type name: string :param sample: an existing Sample :type sample: Sample :param item: an existing Item :type item: Item :param value: a string or number value :type value: string|integer :param container: an existing ObjectType :type container: ObjectType :return: the existing FieldValue modified :rtype: FieldValue """ if object_type is None and container: object_type = container return self.set_field_value( name, "output", dict(sample=sample, item=item, value=value, object_type=object_type), ) def set_input_array(self, name, values): """Sets input :class:`FieldValue` array using values. Values should be a list of dictionaries containing sample, item, container, or values keys. When setting values to items/samples/containers, the item/sample/container must be saved. :param name: name of the FieldType/FieldValues being modified :type name: string :param values: list of dictionary of values to set (e.g. [{"sample": mysample}, {"item": myitem}]) :type values: list :return: the list of modified FieldValues :rtype: list """ return self.set_field_value_array(name, "input", values) def set_output_array(self, name, values): """Sets output :class:`FieldValue` array using values. Values should be a list of dictionaries containing sample, item, container, or values keys. When setting values to items/samples/containers, the item/sample/container must be saved. :param name: name of the FieldType/FieldValues being modified :type name: string :param values: list of dictionary of values to set (e.g. [{"sample": mysample}, {"item": myitem}]) :type values: list :return: the list of modified FieldValues :rtype: list """ return self.set_field_value_array(name, "output", values) def show(self, pre=""): """Print the operation nicely.""" print(pre + self.operation_type.name + " " + str(self.cost)) for field_value in self.field_values: field_value.show(pre=pre + " ") def __str__(self): return self._to_str(operation_type_name=self.operation_type.name)