class PersistentIdentifier(db.Model, Timestamp): """Store and register persistent identifiers. Assumptions: * Persistent identifiers can be represented as a string of max 255 chars. * An object has many persistent identifiers. * A persistent identifier has one and only one object. """ __tablename__ = 'pidstore_pid' __table_args__ = ( db.Index('uidx_type_pid', 'pid_type', 'pid_value', unique=True), db.Index('idx_status', 'status'), db.Index('idx_object', 'object_type', 'object_uuid'), ) id = db.Column(db.Integer, primary_key=True) """Id of persistent identifier entry.""" pid_type = db.Column(db.String(6), nullable=False) """Persistent Identifier Schema.""" pid_value = db.Column(db.String(255), nullable=False) """Persistent Identifier.""" pid_provider = db.Column(db.String(8), nullable=True) """Persistent Identifier Provider""" status = db.Column(ChoiceType(PIDStatus, impl=db.CHAR(1)), nullable=False) """Status of persistent identifier, e.g. registered, reserved, deleted.""" object_type = db.Column(db.String(3), nullable=True) """Object Type - e.g. rec for record.""" object_uuid = db.Column(UUIDType, nullable=True) """Object ID - e.g. a record id.""" # # Class methods # @classmethod def create( cls, pid_type, pid_value, pid_provider=None, status=PIDStatus.NEW, object_type=None, object_uuid=None, ): """Create a new persistent identifier with specific type and value. :param pid_type: Persistent identifier type. :param pid_value: Persistent identifier value. :param pid_provider: Persistent identifier provider. (default: None). :param status: Current PID status. (Default: :attr:`invenio_pidstore.models.PIDStatus.NEW`) :param object_type: The object type is a string that identify its type. (default: None). :param object_uuid: The object UUID. (default: None). :returns: A :class:`invenio_pidstore.models.PersistentIdentifier` instance. """ try: with db.session.begin_nested(): obj = cls(pid_type=pid_type, pid_value=pid_value, pid_provider=pid_provider, status=status) if object_type and object_uuid: obj.assign(object_type, object_uuid) db.session.add(obj) logger.info("Created PID {0}:{1}".format(pid_type, pid_value), extra={'pid': obj}) except IntegrityError: logger.exception("PID already exists: {0}:{1}".format( pid_type, pid_value), extra=dict( pid_type=pid_type, pid_value=pid_value, pid_provider=pid_provider, status=status, object_type=object_type, object_uuid=object_uuid, )) raise PIDAlreadyExists(pid_type=pid_type, pid_value=pid_value) except SQLAlchemyError: logger.exception("Failed to create PID: {0}:{1}".format( pid_type, pid_value), extra=dict( pid_type=pid_type, pid_value=pid_value, pid_provider=pid_provider, status=status, object_type=object_type, object_uuid=object_uuid, )) raise return obj @classmethod def get(cls, pid_type, pid_value, pid_provider=None): """Get persistent identifier. :param pid_type: Persistent identifier type. :param pid_value: Persistent identifier value. :param pid_provider: Persistent identifier provider. (default: None). :raises: :exc:`invenio_pidstore.errors.PIDDoesNotExistError` if no PID is found. :returns: A :class:`invenio_pidstore.models.PersistentIdentifier` instance. """ try: args = dict(pid_type=pid_type, pid_value=six.text_type(pid_value)) if pid_provider: args['pid_provider'] = pid_provider return cls.query.filter_by(**args).one() except NoResultFound: raise PIDDoesNotExistError(pid_type, pid_value) @classmethod def get_by_object(cls, pid_type, object_type, object_uuid): """Get a persistent identifier for a given object. :param pid_type: Persistent identifier type. :param object_type: The object type is a string that identify its type. :param object_uuid: The object UUID. :raises invenio_pidstore.errors.PIDDoesNotExistError: If no PID is found. :returns: A :class:`invenio_pidstore.models.PersistentIdentifier` instance. """ try: return cls.query.filter_by(pid_type=pid_type, object_type=object_type, object_uuid=object_uuid).one() except NoResultFound: raise PIDDoesNotExistError(pid_type, None) # # Assigned object methods # def has_object(self): """Determine if this PID has an assigned object. :returns: `True` if the PID has a object assigned. """ return bool(self.object_type and self.object_uuid) def get_assigned_object(self, object_type=None): """Return the current assigned object UUID. :param object_type: If it's specified, returns only if the PID object_type is the same, otherwise returns None. (default: None). :returns: The object UUID. """ if object_type is not None: if self.object_type == object_type: return self.object_uuid else: return None return self.object_uuid def assign(self, object_type, object_uuid, overwrite=False): """Assign this persistent identifier to a given object. Note, the persistent identifier must first have been reserved. Also, if an existing object is already assigned to the pid, it will raise an exception unless overwrite=True. :param object_type: The object type is a string that identify its type. :param object_uuid: The object UUID. :param overwrite: Force PID overwrites in case was previously assigned. :raises invenio_pidstore.errors.PIDInvalidAction: If the PID was previously deleted. :raises invenio_pidstore.errors.PIDObjectAlreadyAssigned: If the PID was previously assigned with a different type/uuid. :returns: `True` if the PID is successfully assigned. """ if self.is_deleted(): raise PIDInvalidAction( "You cannot assign objects to a deleted/redirected persistent" " identifier.") if not isinstance(object_uuid, uuid.UUID): object_uuid = uuid.UUID(object_uuid) if self.object_type or self.object_uuid: # The object is already assigned to this pid. if object_type == self.object_type and \ object_uuid == self.object_uuid: return True if not overwrite: raise PIDObjectAlreadyAssigned(object_type, object_uuid) self.unassign() try: with db.session.begin_nested(): self.object_type = object_type self.object_uuid = object_uuid db.session.add(self) except SQLAlchemyError: logger.exception("Failed to assign {0}:{1}".format( object_type, object_uuid), extra=dict(pid=self)) raise logger.info("Assigned object {0}:{1}".format(object_type, object_uuid), extra=dict(pid=self)) return True def unassign(self): """Unassign the registered object. Note: Only registered PIDs can be redirected so we set it back to registered. :returns: `True` if the PID is successfully unassigned. """ if self.object_uuid is None and self.object_type is None: return True try: with db.session.begin_nested(): if self.is_redirected(): db.session.delete(Redirect.query.get(self.object_uuid)) # Only registered PIDs can be redirected so we set it back # to registered self.status = PIDStatus.REGISTERED self.object_type = None self.object_uuid = None db.session.add(self) except SQLAlchemyError: logger.exception("Failed to unassign object.".format(self), extra=dict(pid=self)) raise logger.info("Unassigned object from {0}.".format(self), extra=dict(pid=self)) return True def get_redirect(self): """Get redirected persistent identifier. :returns: The :class:`invenio_pidstore.models.PersistentIdentifier` instance. """ return Redirect.query.get(self.object_uuid).pid # # Status methods. # def redirect(self, pid): """Redirect persistent identifier to another persistent identifier. :param pid: The :class:`invenio_pidstore.models.PersistentIdentifier` where redirect the PID. :raises invenio_pidstore.errors.PIDInvalidAction: If the PID is not registered or is not already redirecting to another PID. :raises invenio_pidstore.errors.PIDDoesNotExistError: If PID is not found. :returns: `True` if the PID is successfully redirect. """ if not (self.is_registered() or self.is_redirected()): raise PIDInvalidAction("Persistent identifier is not registered.") try: with db.session.begin_nested(): if self.is_redirected(): r = Redirect.query.get(self.object_uuid) r.pid = pid else: with db.session.begin_nested(): r = Redirect(pid=pid) db.session.add(r) self.status = PIDStatus.REDIRECTED self.object_type = None self.object_uuid = r.id db.session.add(self) except IntegrityError: raise PIDDoesNotExistError(pid.pid_type, pid.pid_value) except SQLAlchemyError: logger.exception("Failed to redirect to {0}".format(pid), extra=dict(pid=self)) raise logger.info("Redirected PID to {0}".format(pid), extra=dict(pid=self)) return True def reserve(self): """Reserve the persistent identifier. Note, the reserve method may be called multiple times, even if it was already reserved. :raises: :exc:`invenio_pidstore.errors.PIDInvalidAction` if the PID is not new or is not already reserved a PID. :returns: `True` if the PID is successfully reserved. """ if not (self.is_new() or self.is_reserved()): raise PIDInvalidAction( "Persistent identifier is not new or reserved.") try: with db.session.begin_nested(): self.status = PIDStatus.RESERVED db.session.add(self) except SQLAlchemyError: logger.exception("Failed to reserve PID.", extra=dict(pid=self)) raise logger.info("Reserved PID.", extra=dict(pid=self)) return True def register(self): """Register the persistent identifier with the provider. :raises invenio_pidstore.errors.PIDInvalidAction: If the PID is not already registered or is deleted or is a redirection to another PID. :returns: `True` if the PID is successfully register. """ if self.is_registered() or self.is_deleted() or self.is_redirected(): raise PIDInvalidAction( "Persistent identifier has already been registered" " or is deleted.") try: with db.session.begin_nested(): self.status = PIDStatus.REGISTERED db.session.add(self) except SQLAlchemyError: logger.exception("Failed to register PID.", extra=dict(pid=self)) raise logger.info("Registered PID.", extra=dict(pid=self)) return True def delete(self): """Delete the persistent identifier. If the persistent identifier haven't been registered yet, it is removed from the database. Otherwise, it's marked as :data:`invenio_pidstore.models.PIDStatus.DELETED`. :returns: `True` if the PID is successfully removed. """ removed = False try: with db.session.begin_nested(): if self.is_new(): # New persistent identifier which haven't been registered # yet. db.session.delete(self) removed = True else: self.status = PIDStatus.DELETED db.session.add(self) except SQLAlchemyError: logger.exception("Failed to delete PID.", extra=dict(pid=self)) raise if removed: logger.info("Deleted PID (removed).", extra=dict(pid=self)) else: logger.info("Deleted PID.", extra=dict(pid=self)) return True def sync_status(self, status): """Synchronize persistent identifier status. Used when the provider uses an external service, which might have been modified outside of our system. :param status: The new status to set. :returns: `True` if the PID is successfully sync. """ if self.status == status: return True try: with db.session.begin_nested(): self.status = status db.session.add(self) except SQLAlchemyError: logger.exception("Failed to sync status {0}.".format(status), extra=dict(pid=self)) raise logger.info("Synced PID status to {0}.".format(status), extra=dict(pid=self)) return True def is_redirected(self): """Return true if the persistent identifier has been registered.""" return self.status == PIDStatus.REDIRECTED def is_registered(self): """Return true if the persistent identifier has been registered. :returns: A :class:`invenio_pidstore.models.PIDStatus` status. """ return self.status == PIDStatus.REGISTERED def is_deleted(self): """Return true if the persistent identifier has been deleted. :returns: A boolean value. """ return self.status == PIDStatus.DELETED def is_new(self): """Return true if the PIDhas not yet been registered or reserved. :returns: A boolean value. """ return self.status == PIDStatus.NEW def is_reserved(self): """Return true if the PID has not yet been reserved. :returns: A boolean value. """ return self.status == PIDStatus.RESERVED def __repr__(self): """Get representation of object.""" return "<PersistentIdentifier {0}:{1}{3} ({2})>".format( self.pid_type, self.pid_value, self.status, " / {0}:{1}".format(self.object_type, self.object_uuid) if self.object_type else "")
class AccessRequest(db.Model): """Represent an request for access to restricted files in a record.""" __tablename__ = 'accessrequests_request' STATUS_CODES = { RequestStatus.EMAIL_VALIDATION: _(u'Email validation'), RequestStatus.PENDING: _(u'Pending'), RequestStatus.ACCEPTED: _(u'Accepted'), RequestStatus.REJECTED: _(u'Rejected'), } id = db.Column(db.Integer, primary_key=True, autoincrement=True) """Access request ID.""" status = db.Column(ChoiceType(STATUS_CODES.items(), impl=db.CHAR(1)), nullable=False, index=True) """Status of request.""" receiver_user_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False, default=None) """Receiver's user id.""" receiver = db.relationship(User, foreign_keys=[receiver_user_id]) """Relationship to user""" sender_user_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=True, default=None) """Sender's user id (for authenticated users).""" sender = db.relationship(User, foreign_keys=[sender_user_id]) """Relationship to user for a sender""" sender_full_name = db.Column(db.String(length=255), nullable=False, default='') """Sender's full name.""" sender_email = db.Column(db.String(length=255), nullable=False, default='') """Sender's email address.""" recid = db.Column(db.Integer, nullable=False, index=True) """Record concerned for the request.""" created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True) """Creation timestamp.""" modified = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) """Last modification timestamp.""" justification = db.Column(db.Text, default='', nullable=False) """Sender's justification for how they fulfill conditions.""" message = db.Column(db.Text, default='', nullable=False) """Receivers message to the sender.""" link_id = db.Column(db.Integer, db.ForeignKey(SecretLink.id), nullable=True, default=None) """Relation to secret link if request was accepted.""" link = db.relationship(SecretLink, foreign_keys=[link_id]) """Relationship to secret link.""" @classmethod def create(cls, recid=None, receiver=None, sender_full_name=None, sender_email=None, justification=None, sender=None): """Create a new access request. :param recid: Record id (required). :param receiver: User object of receiver (required). :param sender_full_name: Full name of sender (required). :param sender_email: Email address of sender (required). :param justification: Justification message (required). :param sender: User object of sender (optional). """ sender_user_id = None if sender is None else sender.id assert recid assert receiver assert sender_full_name assert sender_email assert justification # Determine status status = RequestStatus.EMAIL_VALIDATION if sender and sender.confirmed_at: status = RequestStatus.PENDING with db.session.begin_nested(): # Create object obj = cls(status=status, recid=recid, receiver_user_id=receiver.id, sender_user_id=sender_user_id, sender_full_name=sender_full_name, sender_email=sender_email, justification=justification) db.session.add(obj) # Send signal if obj.status == RequestStatus.EMAIL_VALIDATION: request_created.send(obj) else: request_confirmed.send(obj) return obj @classmethod def query_by_receiver(cls, user): """Get access requests for a specific receiver.""" return cls.query.filter_by(receiver_user_id=user.id) @classmethod def get_by_receiver(cls, request_id, user): """Get access request for a specific receiver.""" return cls.query.filter_by(id=request_id, receiver_user_id=user.id).first() def confirm_email(self): """Confirm that senders email is valid.""" with db.session.begin_nested(): if self.status != RequestStatus.EMAIL_VALIDATION: raise InvalidRequestStateError(RequestStatus.EMAIL_VALIDATION) self.status = RequestStatus.PENDING request_confirmed.send(self) def accept(self, message=None, expires_at=None): """Accept request.""" with db.session.begin_nested(): if self.status != RequestStatus.PENDING: raise InvalidRequestStateError(RequestStatus.PENDING) self.status = RequestStatus.ACCEPTED request_accepted.send(self, message=message, expires_at=expires_at) def reject(self, message=None): """Reject request.""" with db.session.begin_nested(): if self.status != RequestStatus.PENDING: raise InvalidRequestStateError(RequestStatus.PENDING) self.status = RequestStatus.REJECTED request_rejected.send(self, message=message) def create_secret_link(self, title, description=None, expires_at=None): """Create a secret link from request.""" self.link = SecretLink.create( title, self.receiver, extra_data=dict(recid=self.recid), description=description, expires_at=expires_at, ) return self.link
class CommunityMember(db.Model): """Represent a community member role.""" __tablename__ = 'communities_members' "Community PID ID" comm_id = db.Column( UUIDType, db.ForeignKey(CommunityMetadata.id), primary_key=True, nullable=False, ) "USER ID" user_id = db.Column( db.Integer, db.ForeignKey(User.id), primary_key=True, nullable=False, ) role = db.Column(ChoiceType(CommunityRoles, impl=db.CHAR(1)), nullable=False) user = db.relationship(User, backref='communities') community = db.relationship(CommunityMetadata, backref='members') @property def email(self): """Get user email.""" return self.user.email @classmethod def create_from_object(cls, membership_request): """Create Community Membership Role.""" with db.session.begin_nested(): obj = cls.create(comm_id=membership_request.comm_id, user_id=membership_request.user_id, role=membership_request.role) db.session.delete(membership_request) return obj @classmethod def create(cls, comm_id, user_id, role): """Create Community Membership Role.""" try: with db.session.begin_nested(): obj = cls(comm_id=comm_id, user_id=user_id, role=role) db.session.add(obj) except IntegrityError: raise CommunityMemberAlreadyExists(comm_id=comm_id, user_id=user_id, role=role) return obj @classmethod def delete(cls, comm_id, user_id): """Delete a community membership.""" try: with db.session.begin_nested(): membership = cls.query.filter_by(comm_id=comm_id, user_id=user_id).one() db.session.delete(membership) except IntegrityError: raise CommunityMemberDoesNotExist(comm_id=comm_id, user_id=user_id) @classmethod def get_admins(cls, comm_id): with db.session.begin_nested(): return cls.query.filter_by(comm_id=comm_id, role='A').all() @classmethod def get_members(cls, comm_id): with db.session.begin_nested(): return cls.query.filter_by(comm_id=comm_id)
class MembershipRequest(db.Model): """Represent a community member role.""" __tablename__ = 'communities_membership_request' id = db.Column( UUIDType, primary_key=True, default=uuid.uuid4, ) user_id = db.Column( db.Integer, db.ForeignKey(User.id), nullable=True, ) comm_id = db.Column( UUIDType, db.ForeignKey(CommunityMetadata.id), nullable=False, ) email = db.Column( db.String(255), nullable=True, ) role = db.Column(ChoiceType(CommunityRoles, impl=db.CHAR(1)), nullable=True) is_invite = db.Column(db.Boolean(name='is_invite'), nullable=False, default=True) @classmethod def create(cls, comm_id, is_invite, role=None, user_id=None, email=None): """Create Community Membership request.""" try: with db.session.begin_nested(): obj = cls(comm_id=comm_id, user_id=user_id, role=role, is_invite=is_invite, email=email) db.session.add(obj) except IntegrityError: raise CommunityMemberAlreadyExists(comm_id=comm_id, user_id=user_id, role=role) return obj @classmethod def delete(cls, id): """Delete a community membership request.""" try: with db.session.begin_nested(): membership = cls.query.get(id) db.session.delete(membership) except IntegrityError: raise CommunityMemberDoesNotExist(id)
class Release(db.Model, Timestamp): """Information about a GitHub release.""" __tablename__ = 'github_releases' id = db.Column( UUIDType, primary_key=True, default=uuid.uuid4, ) """Release identifier.""" release_id = db.Column(db.Integer, unique=True, nullable=True) """Unique GitHub release identifier.""" tag = db.Column(db.String(255)) """Release tag.""" errors = db.Column( JSONType().with_variant( postgresql.JSON(none_as_null=True), 'postgresql', ), nullable=True, ) """Release processing errors.""" repository_id = db.Column(UUIDType, db.ForeignKey(Repository.id)) """Repository identifier.""" event_id = db.Column(UUIDType, db.ForeignKey(Event.id), nullable=True) """Incoming webhook event identifier.""" record_id = db.Column( UUIDType, db.ForeignKey(RecordMetadata.id), nullable=True, ) """Record identifier.""" status = db.Column( ChoiceType(ReleaseStatus, impl=db.CHAR(1)), nullable=False, ) """Status of the release, e.g. 'processing', 'published', 'failed', etc.""" repository = db.relationship(Repository, backref=db.backref('releases', lazy='dynamic')) recordmetadata = db.relationship(RecordMetadata, backref='github_releases') event = db.relationship(Event) @classmethod def create(cls, event): """Create a new Release model.""" # Check if the release has already been received release_id = event.payload['release']['id'] existing_release = Release.query.filter_by( release_id=release_id, ).first() if existing_release: raise ReleaseAlreadyReceivedError(release=existing_release) # Create the Release repo_id = event.payload['repository']['id'] repo = Repository.get(user_id=event.user_id, github_id=repo_id) if repo.enabled: with db.session.begin_nested(): release = cls( release_id=release_id, tag=event.payload['release']['tag_name'], repository=repo, event=event, status=ReleaseStatus.RECEIVED, ) db.session.add(release) return release else: raise RepositoryDisabledError(repo=repo) @property def record(self): """Get Record object.""" if self.recordmetadata: return Record(self.recordmetadata.json, model=self.recordmetadata) else: return None @property def deposit_id(self): """Get deposit identifier.""" if self.record and '_deposit' in self.record: return self.record['_deposit']['id'] else: return None def __repr__(self): """Get release representation.""" return (u'<Release {self.tag}:{self.release_id} ({self.status.title})>' .format(self=self))
class CommunityRecord(db.Model, RecordMetadataBase): """Comunity-record relationship model.""" __tablename__ = 'communities_community_record' __table_args__ = ( db.Index( 'uidx_community_pid_record_pid', 'community_pid_id', 'record_pid_id', unique=True, ), {'extend_existing': True}, ) __versioned__ = {'versioning': False} community_pid_id = db.Column( db.Integer, db.ForeignKey(PersistentIdentifier.id), nullable=False, ) record_pid_id = db.Column( db.Integer, db.ForeignKey(PersistentIdentifier.id), nullable=False, ) request_id = db.Column( UUIDType, db.ForeignKey(Request.id), # TODO: should we also allow CommunityRecords without a request? nullable=False, ) status = db.Column( ChoiceType(CommunityRecordStatus, impl=db.CHAR(1)), nullable=False, default=CommunityRecordStatus.PENDING, ) community_pid = db.relationship( PersistentIdentifier, foreign_keys=[community_pid_id], ) record_pid = db.relationship( PersistentIdentifier, foreign_keys=[record_pid_id], ) request = db.relationship(Request) @property def commmunity(self): """Return community model.""" # TODO: make a JOIN instead? return CommunityMetadata.query.get(self.community_pid.object_uuid) @classmethod def create(cls, community_pid_id, record_pid_id, request_id, status=None, json=None): try: with db.session.begin_nested(): # TODO: check if status None works with default obj = cls( community_pid_id=community_pid_id, record_pid_id=record_pid_id, request_id=request_id, status=status, json=json, ) db.session.add(obj) # TODO: Check if actually this constraint check happens on the DB side # when db.session.add() is called. except IntegrityError: raise CommunityRecordAlreadyExists( community_pid_id=community_pid_id, record_pid_id=record_pid_id, ) return obj @classmethod def delete(cls, community_record): """Delete community record relationship.""" with db.session.begin_nested(): db.session.delete(community_record)