class Thing(db.Model): """The base class of all Devicehub resources. This is a loose copy of `schema.org's Thing class <https://schema.org/Thing>`_ using only needed fields. """ __abstract__ = True updated = db.Column(db.TIMESTAMP(timezone=True), nullable=False, index=True, server_default=db.text('CURRENT_TIMESTAMP')) updated.comment = """The last time Devicehub recorded a change for this thing. """ created = db.Column(db.TIMESTAMP(timezone=True), nullable=False, index=True, server_default=db.text('CURRENT_TIMESTAMP')) created.comment = """When Devicehub created this.""" def __init__(self, **kwargs) -> None: # We need to set 'created' before sqlalchemy inits the class # to be able to use sorted containers self.created = kwargs.get('created', datetime.now(timezone.utc)) super().__init__(**kwargs)
class Document(Thing): """This represent a generic document.""" id = Column(BigInteger, Sequence('device_seq'), primary_key=True) id.comment = """The identifier of the device for this database. Used only internally for software; users should not use this. """ document_type = Column(Unicode(STR_SM_SIZE), nullable=False) date = Column(db.DateTime, nullable=True) date.comment = """The date of document, some documents need to have one date """ id_document = Column(CIText(), nullable=True) id_document.comment = """The id of one document like invoice so they can be linked.""" owner_id = db.Column(UUID(as_uuid=True), db.ForeignKey(User.id), nullable=False, default=lambda: g.user.id) owner = db.relationship(User, primaryjoin=owner_id == User.id) file_name = Column(db.CIText(), nullable=False) file_name.comment = """This is the name of the file when user up the document.""" file_hash = Column(db.CIText(), nullable=False) file_hash.comment = """This is the hash of the file produced from frontend.""" url = db.Column(URL(), nullable=True) url.comment = """This is the url where resides the document.""" def __str__(self) -> str: return '{0.file_name}'.format(self)
class Manufacturer(db.Model): """The normalized information about a manufacturer. Ideally users should use the names from this list when submitting devices. """ CSV_DELIMITER = csv.get_dialect('excel').delimiter name = db.Column(CIText(), primary_key=True) name.comment = """The normalized name of the manufacturer.""" url = db.Column(URL(), unique=True) url.comment = """An URL to a page describing the manufacturer.""" logo = db.Column(URL()) logo.comment = """An URL pointing to the logo of the manufacturer.""" __table_args__ = ( # from https://niallburkley.com/blog/index-columns-for-like-in-postgres/ db.Index('name_index', text('name gin_trgm_ops'), postgresql_using='gin'), { 'schema': 'common' }) @classmethod def add_all_to_session(cls, session: db.Session): """Adds all manufacturers to session.""" cursor = session.connection().connection.cursor() #: Dialect used to write the CSV with pathlib.Path(__file__).parent.joinpath( 'manufacturers.csv').open() as f: cursor.copy_expert( 'COPY common.manufacturer FROM STDIN (FORMAT csv)', f)
class Mobile(Device): """A mobile device consisting of smartphones, tablets, and cellphones.""" id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) imei = Column(BigInteger) imei.comment = """The International Mobile Equipment Identity of the smartphone as an integer. """ meid = Column(Unicode) meid.comment = """The Mobile Equipment Identifier as a hexadecimal string. """ ram_size = db.Column(db.Integer, check_range(1, )) ram_size.comment = """The total of RAM of the device in MB.""" data_storage_size = db.Column(db.Integer) @validates('imei') def validate_imei(self, _, value: int): if not imei.is_valid(str(value)): raise ValidationError('{} is not a valid imei.'.format(value)) return value @validates('meid') def validate_meid(self, _, value: str): if not meid.is_valid(value): raise ValidationError('{} is not a valid meid.'.format(value)) return value
class Thing(db.Model): __abstract__ = True updated = db.Column(db.DateTime, onupdate=datetime.utcnow) updated.comment = """ When this was last changed. """ created = db.Column(db.DateTime, default=datetime.utcnow) created.comment = """
class UserInventory(db.Model): """Relationship between users and their inventories.""" __table_args__ = {'schema': 'common'} user_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(User.id), primary_key=True) inventory_id = db.Column(db.Unicode(), db.ForeignKey(Inventory.id), primary_key=True)
class LotDevice(db.Model): device_id = db.Column(db.BigInteger, db.ForeignKey(Device.id), primary_key=True) lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), primary_key=True) created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) author_id = db.Column(UUID(as_uuid=True), db.ForeignKey(User.id), nullable=False, default=lambda: g.user.id) author = db.relationship(User, primaryjoin=author_id == User.id) author_id.comment = """The user that put the device in the lot."""
class Path(db.Model): id = db.Column(db.UUID(as_uuid=True), primary_key=True, server_default=db.text('gen_random_uuid()')) lot_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False) lot = db.relationship(Lot, backref=db.backref('paths', lazy=True, collection_class=set, cascade=CASCADE_OWN), primaryjoin=Lot.id == lot_id) path = db.Column(LtreeType, nullable=False) created = db.Column(db.TIMESTAMP(timezone=True), server_default=db.text('CURRENT_TIMESTAMP')) created.comment = """ When Devicehub created this. """ __table_args__ = ( # dag.delete_edge needs to disable internally/temporarily the unique constraint db.UniqueConstraint(path, name='path_unique', deferrable=True, initially='immediate'), db.Index('path_gist', path, postgresql_using='gist'), db.Index('path_btree', path, postgresql_using='btree'), db.Index('lot_id_index', lot_id, postgresql_using='hash')) def __init__(self, lot: Lot) -> None: super().__init__(lot=lot) self.path = UUIDLtree(lot.id) @classmethod def add(cls, parent_id: uuid.UUID, child_id: uuid.UUID): """Creates an edge between parent and child.""" db.session.execute(db.func.add_edge(str(parent_id), str(child_id))) @classmethod def delete(cls, parent_id: uuid.UUID, child_id: uuid.UUID): """Deletes the edge between parent and child.""" db.session.execute(db.func.delete_edge(str(parent_id), str(child_id))) @classmethod def has_lot(cls, parent_id: uuid.UUID, child_id: uuid.UUID) -> bool: parent_id = UUIDLtree.convert(parent_id) child_id = UUIDLtree.convert(child_id) return bool( db.session.execute( "SELECT 1 from path where path ~ '*.{}.*.{}.*'".format( parent_id, child_id)).first())
class Image(Thing): id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid4) name = Column(Unicode(STR_BIG_SIZE), default='', nullable=False) content = db.Column(db.LargeBinary, nullable=False) file_format = db.Column(DBEnum(ImageMimeTypes), nullable=False) orientation = db.Column(DBEnum(Orientation), nullable=False) image_list_id = Column(UUID(as_uuid=True), ForeignKey(ImageList.id), nullable=False) image_list = relationship(ImageList, primaryjoin=ImageList.id == image_list_id, backref=backref('images', cascade=CASCADE_OWN, order_by=lambda: Image.created, collection_class=OrderedSet))
class ReportHash(db.Model): """Save the hash than is create when one report is download. """ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) id.comment = """The identifier of the device for this database. Used only internally for software; users should not use this. """ created = db.Column(db.TIMESTAMP(timezone=True), nullable=False, index=True, server_default=db.text('CURRENT_TIMESTAMP')) created.comment = """When Devicehub created this.""" hash3 = db.Column(CIText(), nullable=False) hash3.comment = """The normalized name of the hash."""
class Battery(JoinedComponentTableMixin, Component): wireless = db.Column(db.Boolean) wireless.comment = """If the battery can be charged wirelessly.""" technology = db.Column(db.Enum(BatteryTechnology)) size = db.Column(db.Integer, nullable=False) size.comment = """Maximum battery capacity by design, in mAh. Use BatteryTest's "size" to get the actual size of the battery. """ @property def capacity(self) -> float: """The quantity of """ from ereuse_devicehub.resources.event.models import MeasureBattery real_size = self.last_event_of(MeasureBattery).size return real_size / self.size if real_size and self.size else None
class ImageList(Thing): id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid4) device_id = Column(BigInteger, ForeignKey(Device.id), nullable=False) device = relationship(Device, primaryjoin=Device.id == device_id, backref=backref('images', lazy=True, cascade=CASCADE_OWN, order_by=lambda: ImageList.created, collection_class=OrderedSet))
class Inventory(Thing): id = db.Column(db.Unicode(), primary_key=True) id.comment = """The name of the inventory as in the URL and schema.""" name = db.Column(db.CIText(), nullable=False, unique=True) name.comment = """The human name of the inventory.""" tag_provider = db.Column(db.URL(), nullable=False) tag_token = db.Column(db.UUID(as_uuid=True), unique=True, nullable=False) tag_token.comment = """The token to access a Tag service.""" # todo no validation that UUID is from an existing organization org_id = db.Column(db.UUID(as_uuid=True), nullable=False) __table_args__ = (db.Index('id_hash', id, postgresql_using='hash'), { 'schema': 'common' }) @classproperty def current(cls) -> 'Inventory': """The inventory of the current_app.""" return Inventory.query.filter_by(id=current_app.id).one()
class Session(Thing): __table_args__ = {'schema': 'common'} id = Column(BigInteger, Sequence('device_seq'), primary_key=True) expired = Column(BigInteger, default=0) token = Column(UUID(as_uuid=True), default=uuid4, unique=True, nullable=False) type = Column(IntEnum(SessionType), default=SessionType.Internal, nullable=False) user_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(User.id)) user = db.relationship(User, backref=db.backref('sessions', lazy=True, collection_class=set), collection_class=set) def __str__(self) -> str: return '{0.token}'.format(self)
class DeviceSearch(db.Model): """Temporary table that stores full-text device documents. It provides methods to auto-run """ device_id = db.Column(db.BigInteger, db.ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True) device = db.relationship(Device, primaryjoin=Device.id == device_id) properties = db.Column(TSVECTOR, nullable=False) tags = db.Column(TSVECTOR) devicehub_ids = db.Column(TSVECTOR) __table_args__ = ( # todo to add concurrency this should be commited separately # see https://docs.sqlalchemy.org/en/latest/dialects/postgresql.html#indexes-with-concurrently db.Index('properties gist', properties, postgresql_using='gist'), db.Index('tags gist', tags, postgresql_using='gist'), db.Index('devicehub_ids gist', devicehub_ids, postgresql_using='gist'), { 'prefixes': ['UNLOGGED'] # Only for temporal tables, can cause table to empty on turn on }) @classmethod def update_modified_devices(cls, session: db.Session): """Updates the documents of the devices that are part of a modified action, or tag in the passed-in session. This method is registered as a SQLAlchemy listener in the Devicehub class. """ devices_to_update = set() for model in chain(session.new, session.dirty): if isinstance(model, Action): if isinstance(model, ActionWithMultipleDevices): devices_to_update |= model.devices elif isinstance(model, ActionWithOneDevice): devices_to_update.add(model.device) if model.parent: devices_to_update.add(model.parent) devices_to_update |= model.components elif isinstance(model, Tag) and model.device: devices_to_update.add(model.device) # this flush is controversial: # see https://groups.google.com/forum/#!topic/sqlalchemy/hBzfypgPfYo # todo probably should replace it with what the solution says session.flush() for device in (d for d in devices_to_update if not isinstance(d, Component)): cls.set_device_tokens(session, device) @classmethod def set_all_devices_tokens_if_empty(cls, session: db.Session): """Generates the search docs if the table is empty. This can happen if Postgres' shut down unexpectedly, as it deletes unlogged tables as ours. """ if not DeviceSearch.query.first(): cls.regenerate_search_table(session) @classmethod def regenerate_search_table(cls, session: db.Session): """Deletes and re-computes all the search table.""" DeviceSearch.query.delete() for device in Device.query: if not isinstance(device, Component): cls.set_device_tokens(session, device) @classmethod def set_device_tokens(cls, session: db.Session, device: Device): """(Re)Generates the device search tokens.""" assert not isinstance(device, Component) tokens = [(str(device.id), search.Weight.A), (inflection.humanize(device.type), search.Weight.B), (Device.model, search.Weight.B), (Device.manufacturer, search.Weight.C), (Device.serial_number, search.Weight.A)] if device.manufacturer: # todo this has to be done using a dictionary manufacturer = device.manufacturer.lower() if 'asus' in manufacturer: tokens.append(('asus', search.Weight.B)) if 'hewlett' in manufacturer or 'hp' in manufacturer or 'h.p' in manufacturer: tokens.append(('hp', search.Weight.B)) tokens.append(('h.p', search.Weight.C)) tokens.append(('hewlett', search.Weight.C)) tokens.append(('packard', search.Weight.C)) if isinstance(device, Computer): # Aggregate the values of all the components of pc Comp = aliased(Component) tokens.extend(( (db.func.string_agg(db.cast(Comp.id, db.TEXT), ' '), search.Weight.D), (db.func.string_agg(Comp.model, ' '), search.Weight.C), (db.func.string_agg(Comp.manufacturer, ' '), search.Weight.D), (db.func.string_agg(Comp.serial_number, ' '), search.Weight.B), (db.func.string_agg(Comp.type, ' '), search.Weight.B), ('Computer', search.Weight.C), ('PC', search.Weight.C), )) properties = session \ .query(search.Search.vectorize(*tokens)) \ .filter(Device.id == device.id) if isinstance(device, Computer): # Join to components properties = properties \ .outerjoin(Comp, Computer.components) \ .group_by(Device.id) tags = session.query( search.Search.vectorize( (db.func.string_agg(Tag.id, ' '), search.Weight.A), (db.func.string_agg(Tag.secondary, ' '), search.Weight.A), (db.func.string_agg(Organization.name, ' '), search.Weight.B))).filter(Tag.device_id == device.id).join( Tag.org) devicehub_ids = session.query( search.Search.vectorize((db.func.string_agg( Device.devicehub_id, ' '), search.Weight.A), )).filter( Device.devicehub_id == device.devicehub_id) # Note that commit flushes later # todo see how to get rid of the one_or_none() by embedding those as subqueries # I don't like this but I want the 'on_conflict_on_update' thingie device_document = dict(properties=properties.one_or_none(), tags=tags.one_or_none(), devicehub_ids=devicehub_ids.one_or_none()) insert = postgresql.insert(DeviceSearch.__table__) \ .values(device_id=device.id, **device_document) \ .on_conflict_do_update(constraint='device_search_pkey', set_=device_document) session.execute(insert)
class Device(Thing): """Base class for any type of physical object that can be identified. Device partly extends `Schema's IndividualProduct <https ://schema.org/IndividualProduct>`_, adapting it to our use case. A device requires an identification method, ideally a serial number, although it can be identified only with tags too. More ideally both methods are used. Devices can contain ``Components``, which are just a type of device (it is a recursive relationship). """ EVENT_SORT_KEY = attrgetter('created') id = Column(BigInteger, Sequence('device_seq'), primary_key=True) id.comment = """ The identifier of the device for this database. Used only internally for software; users should not use this. """ type = Column(Unicode(STR_SM_SIZE), nullable=False) hid = Column(Unicode(), check_lower('hid'), unique=True) hid.comment = """ The Hardware ID (HID) is the unique ID traceability systems use to ID a device globally. This field is auto-generated from Devicehub using literal identifiers from the device, so it can re-generated *offline*. """ + HID_CONVERSION_DOC model = Column(Unicode, check_lower('model')) model.comment = """The model of the device in lower case. The model is the unambiguous, as technical as possible, denomination for the product. This field, among others, is used to identify the product. """ manufacturer = Column(Unicode(), check_lower('manufacturer')) manufacturer.comment = """The normalized name of the manufacturer, in lower case. Although as of now Devicehub does not enforce normalization, users can choose a list of normalized manufacturer names from the own ``/manufacturers`` REST endpoint. """ serial_number = Column(Unicode(), check_lower('serial_number')) serial_number.comment = """The serial number of the device in lower case.""" brand = db.Column(CIText()) brand.comment = """A naming for consumers. This field can represent several models, so it can be ambiguous, and it is not used to identify the product. """ generation = db.Column(db.SmallInteger, check_range('generation', 0)) generation.comment = """The generation of the device.""" weight = Column(Float(decimal_return_scale=3), check_range('weight', 0.1, 5)) weight.comment = """ The weight of the device. """ width = Column(Float(decimal_return_scale=3), check_range('width', 0.1, 5)) width.comment = """ The width of the device in meters. """ height = Column(Float(decimal_return_scale=3), check_range('height', 0.1, 5)) height.comment = """ The height of the device in meters. """ depth = Column(Float(decimal_return_scale=3), check_range('depth', 0.1, 5)) depth.comment = """ The depth of the device in meters. """ color = Column(ColorType) color.comment = """The predominant color of the device.""" production_date = Column(db.DateTime) production_date.comment = """The date of production of the device. This is timezone naive, as Workbench cannot report this data with timezone information. """ variant = Column(Unicode) variant.comment = """A variant or sub-model of the device.""" _NON_PHYSICAL_PROPS = { 'id', 'type', 'created', 'updated', 'parent_id', 'hid', 'production_date', 'color', # these are only user-input thus volatile 'width', 'height', 'depth', 'weight', 'brand', 'generation', 'production_date', 'variant' } __table_args__ = (db.Index('device_id', id, postgresql_using='hash'), db.Index('type_index', type, postgresql_using='hash')) def __init__(self, **kw) -> None: super().__init__(**kw) with suppress(TypeError): self.hid = Naming.hid(self.type, self.manufacturer, self.model, self.serial_number) @property def events(self) -> list: """ All the events where the device participated, including: 1. Events performed directly to the device. 2. Events performed to a component. 3. Events performed to a parent device. Events are returned by descending ``created`` time. """ return sorted(chain(self.events_multiple, self.events_one), key=self.EVENT_SORT_KEY) @property def problems(self): """Current events with severity.Warning or higher. There can be up to 3 events: current Snapshot, current Physical event, current Trading event. """ from ereuse_devicehub.resources.device import states from ereuse_devicehub.resources.event.models import Snapshot events = set() with suppress(LookupError, ValueError): events.add(self.last_event_of(Snapshot)) with suppress(LookupError, ValueError): events.add(self.last_event_of(*states.Physical.events())) with suppress(LookupError, ValueError): events.add(self.last_event_of(*states.Trading.events())) return self._warning_events(events) @property def physical_properties(self) -> Dict[str, object or None]: """ Fields that describe the physical properties of a device. :return A generator where each value is a tuple with tho fields: - Column. - Actual value of the column or None. """ # todo ensure to remove materialized values when start using them # todo or self.__table__.columns if inspect fails return { c.key: getattr(self, c.key, None) for c in inspect(self.__class__).attrs if isinstance(c, ColumnProperty) and not getattr(c, 'foreign_keys', None) and c.key not in self._NON_PHYSICAL_PROPS } @property def url(self) -> urlutils.URL: """The URL where to GET this device.""" return urlutils.URL(url_for_resource(Device, item_id=self.id)) @property def rate(self): """The last AggregateRate of the device.""" with suppress(LookupError, ValueError): from ereuse_devicehub.resources.event.models import AggregateRate return self.last_event_of(AggregateRate) @property def price(self): """The actual Price of the device, or None if no price has ever been set.""" with suppress(LookupError, ValueError): from ereuse_devicehub.resources.event.models import Price return self.last_event_of(Price) @property def trading(self): """The actual trading state, or None if no Trade event has ever been performed to this device.""" from ereuse_devicehub.resources.device import states with suppress(LookupError, ValueError): event = self.last_event_of(*states.Trading.events()) return states.Trading(event.__class__) @property def physical(self): """The actual physical state, None otherwise.""" from ereuse_devicehub.resources.device import states with suppress(LookupError, ValueError): event = self.last_event_of(*states.Physical.events()) return states.Physical(event.__class__) @property def physical_possessor(self): """The actual physical possessor or None. The physical possessor is the Agent that has physically the device. It differs from legal owners, usufructuarees or reserves in that the physical possessor does not have a legal relation per se with the device, but it is the one that has it physically. As an example, a transporter could be a physical possessor of a device although it does not own it legally. Note that there can only be one physical possessor per device, and :class:`ereuse_devicehub.resources.event.models.Receive` changes it. """ from ereuse_devicehub.resources.event.models import Receive with suppress(LookupError): event = self.last_event_of(Receive) return event.agent @property def working(self): """A list of the current tests with warning or errors. A device is working if the list is empty. This property returns, for the last test performed of each type, the one with the worst ``severity`` of them, or ``None`` if no test has been executed. """ from ereuse_devicehub.resources.event.models import Test current_tests = unique_everseen( (e for e in reversed(self.events) if isinstance(e, Test)), key=attrgetter('type')) # last test of each type return self._warning_events(current_tests) @declared_attr def __mapper_args__(cls): """ Defines inheritance. From `the guide <http://docs.sqlalchemy.org/en/latest/orm/ extensions/declarative/api.html #sqlalchemy.ext.declarative.declared_attr>`_ """ args = {POLYMORPHIC_ID: cls.t} if cls.t == 'Device': args[POLYMORPHIC_ON] = cls.type return args def last_event_of(self, *types): """Gets the last event of the given types. :raise LookupError: Device has not an event of the given type. """ try: # noinspection PyTypeHints return next(e for e in reversed(self.events) if isinstance(e, types)) except StopIteration: raise LookupError( '{!r} does not contain events of types {}.'.format( self, types)) def _warning_events(self, events): return sorted((ev for ev in events if ev.severity >= Severity.Warning), key=self.EVENT_SORT_KEY) def __lt__(self, other): return self.id < other.id def __str__(self) -> str: return '{0.t} {0.id}: model {0.model}, S/N {0.serial_number}'.format( self) def __format__(self, format_spec): if not format_spec: return super().__format__(format_spec) v = '' if 't' in format_spec: v += '{0.t} {0.model}'.format(self) if 's' in format_spec: v += '({0.manufacturer})'.format(self) if self.serial_number: v += ' S/N ' + self.serial_number.upper() return v
class TradeDocument(Thing): """This represent a document involved in a trade action. Every document is added to a lot. When this lot is converted in one trade, the action trade is added to the document and the action trade need to be confirmed for the both users of the trade. This confirmation can be revoked and this revoked need to be ConfirmRevoke for have some efect. This documents can be invoices or list of devices or certificates of erasure of one disk. Like a Devices one document have actions and is possible add or delete of one lot if this lot don't have a trade The document is saved in the database """ id = Column(BigInteger, Sequence('device_seq'), primary_key=True) id.comment = """The identifier of the device for this database. Used only internally for software; users should not use this. """ # type = Column(Unicode(STR_SM_SIZE), nullable=False) date = Column(db.DateTime) date.comment = """The date of document, some documents need to have one date """ id_document = Column(CIText()) id_document.comment = """The id of one document like invoice so they can be linked.""" description = Column(db.CIText()) description.comment = """A description of document.""" owner_id = db.Column(UUID(as_uuid=True), db.ForeignKey(User.id), nullable=False, default=lambda: g.user.id) owner = db.relationship(User, primaryjoin=owner_id == User.id) lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey('lot.id'), nullable=False) lot = db.relationship('Lot', backref=backref('documents', lazy=True, cascade=CASCADE_OWN, **_sorted_documents), primaryjoin='TradeDocument.lot_id == Lot.id') lot.comment = """Lot to which the document is associated""" file_name = Column(db.CIText()) file_name.comment = """This is the name of the file when user up the document.""" file_hash = Column(db.CIText()) file_hash.comment = """This is the hash of the file produced from frontend.""" url = db.Column(URL()) url.comment = """This is the url where resides the document.""" weight = db.Column(db.Float(nullable=True)) weight.comment = """This is the weight of one container than this document express.""" __table_args__ = ( db.Index('document_id', id, postgresql_using='hash'), # db.Index('type_doc', type, postgresql_using='hash') ) @property def actions(self) -> list: """All the actions where the device participated, including: 1. Actions performed directly to the device. 2. Actions performed to a component. 3. Actions performed to a parent device. Actions are returned by descending ``created`` time. """ return sorted(self.actions_docs, key=lambda x: x.created) @property def trading(self): """The trading state, or None if no Trade action has ever been performed to this device. This extract the posibilities for to do""" confirm = 'Confirm' need_confirm = 'Need Confirmation' double_confirm = 'Document Confirmed' revoke = 'Revoke' revoke_pending = 'Revoke Pending' confirm_revoke = 'Document Revoked' ac = self.last_action_trading() if not ac: return if ac.type == 'ConfirmRevokeDocument': # can to do revoke_confirmed return confirm_revoke if ac.type == 'RevokeDocument': if ac.user == g.user: # can todo revoke_pending return revoke_pending else: # can to do confirm_revoke return revoke if ac.type == 'ConfirmDocument': if ac.user == self.owner: if self.owner == g.user: # can to do revoke return confirm else: # can to do confirm return need_confirm else: # can to do revoke return double_confirm @property def total_weight(self): """Return all weight than this container have.""" weight = self.weight or 0 for x in self.actions: if not x.type == 'MoveOnDocument' or not x.weight: continue if self == x.container_from: continue weight += x.weight return weight def last_action_trading(self): """which is the last action trading""" with suppress(StopIteration, ValueError): actions = copy.copy(self.actions) actions.sort(key=lambda x: x.created) t_trades = [ 'Trade', 'Confirm', 'ConfirmRevokeDocument', 'RevokeDocument', 'ConfirmDocument' ] return next(e for e in reversed(actions) if e.t in t_trades) def _warning_actions(self, actions): """Show warning actions""" return sorted(ev for ev in actions if ev.severity >= Severity.Warning) def __lt__(self, other): return self.id < other.id def __str__(self) -> str: return '{0.file_name}'.format(self)
class Lot(Thing): id = db.Column(UUID(as_uuid=True), primary_key=True) # uuid is generated on init by default name = db.Column(CIText(), nullable=False) description = db.Column(CIText()) description.comment = """A comment about the lot.""" closed = db.Column(db.Boolean, default=False, nullable=False) closed.comment = """ A closed lot cannot be modified anymore. """ devices = db.relationship(Device, backref=db.backref('lots', lazy=True, collection_class=set), secondary=lambda: LotDevice.__table__, lazy=True, collection_class=set) """ The **children** devices that the lot has. Note that the lot can have more devices, if they are inside descendant lots. """ parents = db.relationship( lambda: Lot, viewonly=True, lazy=True, collection_class=set, secondary=lambda: LotParent.__table__, primaryjoin=lambda: Lot.id == LotParent.child_id, secondaryjoin=lambda: LotParent.parent_id == Lot.id, cascade='refresh-expire', # propagate changes outside ORM backref=db.backref('children', viewonly=True, lazy=True, cascade='refresh-expire', collection_class=set)) """The parent lots.""" all_devices = db.relationship( Device, viewonly=True, lazy=True, collection_class=set, secondary=lambda: LotDeviceDescendants.__table__, primaryjoin=lambda: Lot.id == LotDeviceDescendants.ancestor_lot_id, secondaryjoin=lambda: LotDeviceDescendants.device_id == Device.id) """All devices, including components, inside this lot and its descendants. """ def __init__(self, name: str, closed: bool = closed.default.arg, description: str = None) -> None: """ Initializes a lot :param name: :param closed: """ super().__init__(id=uuid.uuid4(), name=name, closed=closed, description=description) Path(self) # Lots have always one edge per default. @property def type(self) -> str: return self.__class__.__name__ @property def url(self) -> urlutils.URL: """The URL where to GET this event.""" return urlutils.URL(url_for_resource(Lot, item_id=self.id)) @property def descendants(self): return self.descendantsq(self.id) @classmethod def descendantsq(cls, id): _id = UUIDLtree.convert(id) return (cls.id == Path.lot_id) & Path.path.lquery( exp.cast('*.{}.*'.format(_id), LQUERY)) @classmethod def roots(cls): """Gets the lots that are not under any other lot.""" return cls.query.join(cls.paths).filter(db.func.nlevel(Path.path) == 1) def add_children(self, *children): """Add children lots to this lot. This operation is highly costly as it forces refreshing many models in session. """ for child in children: if isinstance(child, Lot): Path.add(self.id, child.id) db.session.refresh(child) else: assert isinstance(child, uuid.UUID) Path.add(self.id, child) # We need to refresh the models involved in this operation # outside the session / ORM control so the models # that have relationships to this model # with the cascade 'refresh-expire' can welcome the changes db.session.refresh(self) def remove_children(self, *children): """Remove children lots from this lot. This operation is highly costly as it forces refreshing many models in session. """ for child in children: if isinstance(child, Lot): Path.delete(self.id, child.id) db.session.refresh(child) else: assert isinstance(child, uuid.UUID) Path.delete(self.id, child) db.session.refresh(self) def delete(self): """Deletes the lot. This method removes the children lots and children devices orphan from this lot and then marks this lot for deletion. """ self.remove_children(*self.children) db.session.delete(self) def _refresh_models_with_relationships_to_lots(self): session = db.Session.object_session(self) for model in session: if isinstance(model, (Device, Lot, Path)): session.expire(model) def __contains__(self, child: Union['Lot', Device]): if isinstance(child, Lot): return Path.has_lot(self.id, child.id) elif isinstance(child, Device): device = db.session.query(LotDeviceDescendants) \ .filter(LotDeviceDescendants.device_id == child.id) \ .filter(LotDeviceDescendants.ancestor_lot_id == self.id) \ .one_or_none() return device else: raise TypeError( 'Lot only contains devices and lots, not {}'.format( child.__class__)) def __repr__(self) -> str: return '<Lot {0.name} devices={0.devices!r}>'.format(self)
class Deliverynote(Thing): id = db.Column(UUID(as_uuid=True), primary_key=True) # uuid is generated on init by default document_id = db.Column(CIText(), nullable=False) creator_id = db.Column(UUID(as_uuid=True), db.ForeignKey(User.id), nullable=False, default=lambda: g.user.id) creator = db.relationship(User, primaryjoin=creator_id == User.id) supplier_email = db.Column(CIText(), db.ForeignKey(User.email), nullable=False, default=lambda: g.user.email) supplier = db.relationship( User, primaryjoin=lambda: Deliverynote.supplier_email == User.email) receiver_address = db.Column(CIText(), db.ForeignKey(User.email), nullable=False, default=lambda: g.user.email) receiver = db.relationship( User, primaryjoin=lambda: Deliverynote.receiver_address == User.email) date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) date.comment = 'The date the DeliveryNote initiated' amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0) # The following fields are supposed to be 0:N relationships # to SnapshotDelivery entity. # At this stage of implementation they will treated as a # comma-separated string of the devices expexted/transfered expected_devices = db.Column(JSONB, nullable=False) # expected_devices = db.Column(db.ARRAY(JSONB, dimensions=1), nullable=False) transferred_devices = db.Column(db.ARRAY(db.Integer, dimensions=1), nullable=True) transfer_state = db.Column(IntEnum(TransferState), default=TransferState.Initial, nullable=False) transfer_state.comment = TransferState.__doc__ lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False) lot = db.relationship(Lot, backref=db.backref('deliverynote', uselist=False, lazy=True), lazy=True, primaryjoin=Lot.id == lot_id) def __init__(self, document_id: str, amount: str, date, supplier_email: str, expected_devices: Iterable, transfer_state: TransferState) -> None: """Initializes a delivery note """ super().__init__(id=uuid.uuid4(), document_id=document_id, amount=amount, date=date, supplier_email=supplier_email, expected_devices=expected_devices, transfer_state=transfer_state) @property def type(self) -> str: return self.__class__.__name__ @property def url(self) -> urlutils.URL: """The URL where to GET this action.""" return urlutils.URL(url_for_resource(Deliverynote, item_id=self.id)) def delete(self): """Deletes the deliverynote. This method removes the delivery note. """ db.session.delete(self) def __repr__(self) -> str: return '<Deliverynote {0.document_id}>'.format(self)