class UserBudgetAssociation(ModelBase): """An association model between a User and a Budget.""" fields = dict(budget=HasOne("Budget"), user=HasOne("User")) def __init__(self): pass
class PlanAssociation(ModelBase): """A PlanAssociation model.""" fields = dict(plan=HasOne("Plan"), operation=HasOne("Operation")) def __init__(self, plan_id=None, operation_id=None): super().__init__(plan_id=plan_id, operation_id=operation_id)
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)
def test_has_one(): """Tests the HasOne relationship. Its expected that with MyModel, that the returned params should be: .. code-block:: python params = lambda x: x.my_model_id """ class MyModel: my_model_id = 4 my_model_name = "myname" # has_many tries to return lambda x: x.my_model_id hasone = HasOne("MyModel") assert hasone.nested == "MyModel" assert hasone.callback_args[1](MyModel) == 4 # has_many tries to return lambda x: x.my_model_name hasone = HasOne("MyModel", attr="name") assert hasone.callback_args[1](MyModel) == "myname"
class PartAssociation(JSONSaveMixin, ModelBase): """Represents a PartAssociation linking a part to a collection. Collections contain many `parts`, each of which can refer to a different sample. """ fields = dict(part=HasOne("Item", ref="part_id"), collection=HasOne("Collection")) def __init__(self, part_id=None, collection_id=None, row=None, column=None): super().__init__(part_id=part_id, collection_id=collection_id, row=row, column=column) def get_sample_id(self) -> Union[None, int]: if self.sample_id: return self.sample_id return self.sample.id def has_unsaved_sample(self) -> bool: if self.is_deserialized("part") and self.part.is_deserialized( "sample"): if self.part.sample.id is None: return True return False def is_empty(self) -> bool: if self.get_sample_id(): return True return False
class Code(ModelBase): """A Code model.""" fields = dict( user=HasOne("User"), operation_type=One("OperationType", callback="get_parent", callback_args=None), library=One("Library", callback="get_parent", callback_args=None), ) def get_parent(self, parent_class, *args): if parent_class != self.parent_class: return None return self.session.model_interface(self.parent_class).find( self.parent_id) def update(self): # since they may not always be tied to specific parent # controllers self.session.utils.update_code(self)
class DataAssociation(JSONDeleteMixin, JSONSaveMixin, ModelBase): """A DataAssociation model.""" fields = dict(object=JSON(), upload=HasOne("Upload")) @property def value(self): return self.object.get(self.key, None) @value.setter def value(self, new_value): self.object = {self.key: new_value} # def save(self): # if self.upload and not self.upload_id: # self.upload.save() # self.upload_id = self.upload_id # super().save() # # data_association = self.parent.session.DataAssociation.find(self.id) # if data_association.id not in [da.id for da in self.parent.data_associations]: # self.parent.data_associations.append(data_association) # return data_association # else: # for da in self.parent.data_associations: # if da.id == data_association.id: # return da def save(self): if self.parent_id is None: raise ValueError( "Cannot save DataAssociation. `parent_id` cannot be None") if self.parent_class is None: raise ValueError( "Cannot save DataAssociation. `parent_class` cannot be None") super().save(do_reload=True) def __str__(self): return self._to_str("id", "object")
class JobAssociation(ModelBase): """A JobAssociation model.""" fields = dict(job=HasOne("Job"), operation=HasOne("Operation"))
class Wire(DeleteMixin, ModelBase): """A Wire model.""" fields = { "source": HasOne("FieldValue", ref="from_id"), "destination": HasOne("FieldValue", ref="to_id"), } WIRABLE_PARENT_CLASSES = ["Operation"] def __init__(self, source=None, destination=None): self._validate_field_values(source, destination) if hasattr(source, "id"): from_id = source.id else: from_id = None if hasattr(destination, "id"): to_id = destination.id else: to_id = None if (source and not destination) or (destination and not source): raise AquariumModelError( "Cannot wire. Either source ({}) or destination ({}) is None". format(source, destination)) if source: if not source.role: raise AquariumModelError( "Cannot wire. FieldValue {} does not have a role".format( source.role)) elif source.role != "output": raise AquariumModelError( "Cannot wire an '{}' FieldValue as a source".format( source.role)) if destination: if not destination.role: raise AquariumModelError( "Cannot wire. FieldValue {} does not have a role".format( destination.role)) elif destination.role != "input": raise AquariumModelError( "Cannot wire an '{}' FieldValue as a destination".format( destination.role)) super().__init__( **{ "source": source, "from_id": from_id, "destination": destination, "to_id": to_id, }, active=True, ) @property def identifier(self): if not self.source: source_id = self.from_id else: source_id = "r" + str(self.source.rid) if not self.destination: destination_id = self.to_id else: destination_id = "r" + str(self.destination.rid) return "{}_{}".format(source_id, destination_id) @classmethod def _validate_field_values(cls, src, dest): if src: cls._validate_field_value(src, "source") if dest: cls._validate_field_value(dest, "destination") if src and dest and src.rid == dest.rid: raise AquariumModelError( "Cannot create wire because source and destination are the same " "instance.") @classmethod def _validate_field_value(cls, fv, name): if not issubclass(type(fv), FieldValue): raise AquariumModelError( "Cannot create wire because {} FieldValue is {}.".format( name, fv.__class__.__name__)) if fv.parent_class not in cls.WIRABLE_PARENT_CLASSES: raise AquariumModelError( "Cannot create wire because the {} FieldValue is has '{}' " "parent class. Only {} parent classes are wirable".format( name, fv.parent_class, cls.WIRABLE_PARENT_CLASSES)) def validate(self): self._validate_field_values(self.source, self.destination) def to_save_json(self): save_json = { "id": self.id, "from_id": self.source.id, "to_id": self.destination.id, "from": { "rid": self.source.rid }, "to": { "rid": self.destination.rid }, "active": self.active, } return save_json def delete(self): """Permanently deletes the wire instance on the Aquarium server. :return: :rtype: """ return self.session.utils.delete_wire(self) def show(self, pre=""): """Show the wire nicely.""" source = self.source dest = self.destination from_op_type_name = "None" from_name = "None" if source: from_op_type_name = self.source.operation.operation_type.name from_name = source.name to_op_type_name = "None" to_name = "None" if dest: to_op_type_name = self.destination.operation.operation_type.name to_name = dest.name print(pre + from_op_type_name + ":" + from_name + " --> " + to_op_type_name + ":" + to_name) def does_wire_to(self, destination): if destination and self.destination: return destination.rid == self.destination.rid return False def does_wire_from(self, source): if source and self.source: return source.rid == self.source.rid return False def does_wire(self, source, destination): """Checks whether this Wire is a wire between the source and destination FieldValues. If any of the source or destination FieldValues are None, returns False. """ if source and destination and self.source and self.destination: return (source.rid == self.source.rid and destination.rid == self.destination.rid) return False
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 Upload(ModelBase): """An Upload model.""" fields = dict(job=HasOne("Job")) def __init__(self, job_id=None, file=None): """Create a new upload. :param job_id: job id to associate the upload to :type job_id: int :param file: file to upload :type file: file object """ super().__init__(job_id=job_id) self.file = file query_hook = dict(methods=["size", "name", "job"]) # def _get_uploads_from_job_id(self, job_id): def _get_uploads_from_job(self): http = self.session._http return http.get("krill/uploads?job={}".format(self.job_id))["uploads"] def temp_url(self): data = self.session.Upload.where({"id": self.id}, methods=["expiring_url"])[0].raw return data["expiring_url"] @staticmethod def _download_file_from_url(url, outpath): """Downloads a file from a url. :param url: url of file :type url: str :param outpath: filepath of out file :type outpath: str :return: http response :rtype: str """ response = requests.get(url, stream=True) with open(outpath, "wb") as out_file: shutil.copyfileobj(response.raw, out_file) return response.raw @staticmethod @make_async(1) def async_download(uploads, outdir=None, overwrite=True): """Asynchronously downloads from list of :class:`Upload` models. :param uploads: list of Uploads :type uploads: list :param outdir: path to output directory to save downloaded files :type outdir: str :param overwrite: if True, will overwrite existing files :type overwrite: bool :return: list of filepaths :rtype: list """ return Upload._download_files(uploads, outdir, overwrite) @staticmethod def _download_files(uploads, outdir, overwrite): """Downloads uploaded file from list of :class:`Upload` models. :param uploads: list of Uploads :type uploads: list :param outdir: path to output directory to save downloaded files (defaults to current directory) :type outdir: str :param overwrite: if True, will overwrite existing files (default: True) :type overwrite: bool :return: list of filepaths :rtype: list """ filepaths = [] for upload in uploads: filepath = upload.download(outdir=outdir, overwrite=overwrite) filepaths.append(filepath) return filepaths def fetch(self, outdir: str = None, filename: str = None, overwrite: bool = True): """Alias for `download` :param outdir: path of directory of output file (default is current directory) :param outfile: filename of output file (defaults to upload_filename) :param overwrite: whether to overwrite file if it already exists :return: filepath of the downloaded file """ return self.download(outdir=outdir, filename=filename, overwrite=overwrite) def download(self, outdir: str = None, filename: str = None, overwrite: bool = True): """Downloads the uploaded file to the specified output directory. If no output directory is specified, the file will be downloaded to the current directory. :param outdir: path of directory of output file (default is current directory) :param outfile: filename of output file (defaults to upload_filename) :param overwrite: whether to overwrite file if it already exists :return: filepath of the downloaded file """ if outdir is None: outdir = "." if filename is None: filename = "{}_{}".format(self.id, self.upload_file_name) filepath = os.path.join(outdir, filename) if not os.path.exists(filepath) or overwrite: self._download_file_from_url(self.temp_url(), filepath) return filepath @property def data(self): """Return the data associated with the upload.""" result = requests.get(self.temp_url()) return result.content def create(self): """Save the upload to the server.""" return self.session.utils.create_upload(self) def save(self): return self.create()
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 Membership(ModelBase): fields = dict(user=HasOne("User"), group=HasOne("Group"))
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)