class MultiroleDocument(BaseMixin, db.Model): """Test model that grants multiple roles via RoleMembership""" __tablename__ = 'multirole_document' parent_id = db.Column(None, db.ForeignKey('multirole_parent.id')) parent = with_roles( db.relationship(MultiroleParent), # grants_via[None] implies that these roles are granted by parent.roles_for(), # and not via parent.`actor_attr`. While other roles may also be granted by # parent.roles_for(), we only want one, and we want to give it a different name # here. The dict maps source role to destination role. grants_via={None: { 'prole1': 'parent_prole1' }}, ) # Acquire parent_role through parent.user (a scalar relationship) # Acquire parent_other_role too (will be cached alongside parent_role) # Acquire role1 through both relationships (query and list relationships) # Acquire role2 and role3 via only one relationship each # This contrived setup is only to test that it works via all relationship types __roles__ = { 'parent_role': { 'granted_via': { 'parent': 'user' } }, 'parent_other_role': { 'granted_via': { 'parent': 'user' } }, 'role1': { 'granted_via': { 'rel_lazy': 'user', 'rel_list': 'user' } }, 'role2': { 'granted_via': { 'rel_lazy': 'user' } }, 'incorrectly_specified_role': { 'granted_via': { 'rel_list': None } }, } # Grant via a query relationship rel_lazy = db.relationship(RoleMembership, lazy='dynamic') # Grant via a list-like relationship rel_list = with_roles(db.relationship(RoleMembership), grants_via={'user': {'role3'}})
class AutoRoleModel(RoleMixin, db.Model): __tablename__ = 'auto_role_model' # This model doesn't specify __roles__. It only uses with_roles. # It should still work id = db.Column(db.Integer, primary_key=True) # NOQA: A003 with_roles(id, read={'all'}) name = db.Column(db.Unicode(250)) with_roles(name, rw={'owner'}, read={'all'})
class RoleGrantSynonym(BaseMixin, db.Model): """Test model for granting roles to synonyms""" __tablename__ = 'role_grant_synonym' # Base column has roles defined datacol = with_roles(db.Column(db.UnicodeText()), rw={'owner'}) # Synonym has no roles defined, so it acquires from the target altcol_unroled = db.synonym('datacol') # However, when the synonym has roles defined, these override the target's altcol_roled = with_roles(db.synonym('datacol'), read={'all'})
class Commentset: project = with_roles( db.relationship(Project, uselist=False, back_populates='commentset'), grants_via={None: { 'editor': 'document_subscriber' }}, )
class RoleGrantOne(BaseMixin, db.Model): """Test model for granting roles to users in a one-to-many relationship""" __tablename__ = 'role_grant_one' user_id = db.Column(None, db.ForeignKey('role_user.id')) user = with_roles(db.relationship(RoleUser), grants={'creator'})
class RoleModel(DeclaredAttrMixin, RoleMixin, db.Model): __tablename__ = 'role_model' # Approach one, declare roles in advance. # 'all' is a special role that is always granted from the base class __roles__ = {'all': {'read': {'id', 'name', 'title'}}} __datasets__ = { 'minimal': {'id', 'name'}, 'extra': {'id', 'name', 'mixed_in1'} } # Additional dataset members are defined using with_roles # Approach two, annotate roles on the attributes. # These annotations always add to anything specified in __roles__ id = db.Column(db.Integer, primary_key=True) # NOQA: A003 name = with_roles(db.Column(db.Unicode(250)), rw={'owner'}) # Specify read+write access title = with_roles( db.Column(db.Unicode(250)), write={'owner', 'editor'}, datasets={'minimal', 'extra', 'third'}, # 'third' is unique here ) # Grant 'owner' and 'editor' write but not read access defval = with_roles(db.deferred(db.Column(db.Unicode(250))), rw={'owner'}) @with_roles(call={'all'} ) # 'call' grants call access to the decorated method def hello(self): return "Hello!" # RoleMixin provides a `roles_for` that automatically grants roles from # `granted_by` declarations. See the RoleGrant models below for examples. # This is optional however, and your model can take independent responsibility # for granting roles given an actor and anchors (an iterable). The format for # anchors is not specified by RoleMixin. def roles_for(self, actor=None, anchors=()): # Calling super gives us a set with the standard roles roles = super(RoleModel, self).roles_for(actor, anchors) if 'owner-secret' in anchors: roles.add('owner') # Grant owner role return roles
class Proposal: #: For reading and setting labels from the edit form formlabels = ProposalLabelProxy() labels = with_roles( db.relationship(Label, secondary=proposal_label, back_populates='proposals'), read={'all'}, )
class Project: active_crew_memberships = with_roles( db.relationship( ProjectCrewMembership, lazy='dynamic', primaryjoin=db.and_( ProjectCrewMembership.project_id == Project.id, ProjectCrewMembership.is_active, ), viewonly=True, ), grants_via={ 'user': {'editor', 'concierge', 'usher', 'participant', 'crew'} }, ) active_editor_memberships = db.relationship( ProjectCrewMembership, lazy='dynamic', primaryjoin=db.and_( ProjectCrewMembership.project_id == Project.id, ProjectCrewMembership.is_active, ProjectCrewMembership.is_editor.is_(True), ), viewonly=True, ) active_concierge_memberships = db.relationship( ProjectCrewMembership, lazy='dynamic', primaryjoin=db.and_( ProjectCrewMembership.project_id == Project.id, ProjectCrewMembership.is_active, ProjectCrewMembership.is_concierge.is_(True), ), viewonly=True, ) active_usher_memberships = db.relationship( ProjectCrewMembership, lazy='dynamic', primaryjoin=db.and_( ProjectCrewMembership.project_id == Project.id, ProjectCrewMembership.is_active, ProjectCrewMembership.is_usher.is_(True), ), viewonly=True, ) crew = DynamicAssociationProxy('active_crew_memberships', 'user') editors = DynamicAssociationProxy('active_editor_memberships', 'user') concierges = DynamicAssociationProxy('active_concierge_memberships', 'user') ushers = DynamicAssociationProxy('active_usher_memberships', 'user')
class Commentset: proposal = with_roles( db.relationship(Proposal, uselist=False, back_populates='commentset'), # TODO: Remove creator to subscriber mapping when proposals use memberships grants_via={ None: { 'presenter': 'document_subscriber', 'creator': 'document_subscriber' } }, )
class RoleModel(DeclaredAttrMixin, RoleMixin, db.Model): __tablename__ = 'role_model' # Approach one, declare roles in advance. # 'all' is a special role that is always granted from the base class __roles__ = { 'all': { 'read': {'id', 'name', 'title'} } } # Approach two, annotate roles on the attributes. # These annotations always add to anything specified in __roles__ id = db.Column(db.Integer, primary_key=True) name = with_roles(db.Column(db.Unicode(250)), rw={'owner'}) # Specify read+write access title = with_roles(db.Column(db.Unicode(250)), write={'owner', 'editor'}) # Grant 'owner' and 'editor' write but not read access defval = with_roles(db.deferred(db.Column(db.Unicode(250))), rw={'owner'}) @with_roles(call={'all'}) # 'call' grants call access to the decorated method def hello(self): return "Hello!" # Your model is responsible for granting roles given an actor or anchors # (an iterable). The format for anchors is not specified by RoleMixin. def roles_for(self, actor=None, anchors=()): # Calling super give us a result set with the standard roles result = super(RoleModel, self).roles_for(actor, anchors) if 'owner-secret' in anchors: result.add('owner') # Grant owner role return result
class MultiroleChild(BaseMixin, db.Model): """Model that inherits roles from its parent""" __tablename__ = 'multirole_child' parent_id = db.Column(None, db.ForeignKey('multirole_document.id')) parent = with_roles( db.relationship(MultiroleDocument), grants_via={ 'parent.user': {'super_parent_role'}, # Maps to parent.parent.user 'rel_lazy.user': { # Maps to parent.rel_lazy[item].user # Map role2 and role3, but explicitly ignore role1 'role2': 'parent_role2', 'role3': 'parent_role3', }, }, )
class SharedProfileMixin: """ Common methods between User and Organization to link to Profile """ # The `name` property in User and Organization is not over here because # of what seems to be a SQLAlchemy bug: we can't override the expression # (both models need separate expressions) without triggering an inspection # of the `profile` relationship, which does not exist yet as the backrefs # are only fully setup when module loading is finished. # Doc: https://docs.sqlalchemy.org/en/latest/orm/extensions/hybrid.html#reusing-hybrid-properties-across-subclasses def is_valid_name(self, value): if not valid_username(value): return False existing = Profile.get(value) if existing and existing.owner != self: return False return True def validate_name_candidate(self, name): if name and self.name and name.lower() == self.name.lower(): # Same name, or only a case change. No validation required return return Profile.validate_name_candidate(name) @property def has_public_profile(self): """Controls the visibility state of a public profile""" return self.profile is not None and self.profile.state.PUBLIC with_roles(has_public_profile, read={'all'}, write={'owner'}) @property def avatar(self): return (self.profile.logo_url if self.profile and self.profile.logo_url and self.profile.logo_url.url else '') @with_roles(read={'all'}) @property def profile_url(self): return self.profile.url_for() if self.has_public_profile else None
class Organization: active_admin_memberships = with_roles( db.relationship( OrganizationMembership, lazy='dynamic', primaryjoin=db.and_( db.remote( OrganizationMembership.organization_id) == Organization.id, OrganizationMembership.is_active, ), order_by=OrganizationMembership.granted_at.asc(), viewonly=True, ), grants_via={'user': {'admin', 'owner'}}, ) active_owner_memberships = db.relationship( OrganizationMembership, lazy='dynamic', primaryjoin=db.and_( db.remote( OrganizationMembership.organization_id) == Organization.id, OrganizationMembership.is_active, OrganizationMembership.is_owner.is_(True), ), viewonly=True, ) active_invitations = db.relationship( OrganizationMembership, lazy='dynamic', primaryjoin=db.and_( db.remote( OrganizationMembership.organization_id) == Organization.id, OrganizationMembership.is_invite, ~OrganizationMembership.is_active, ), viewonly=True, ) owner_users = DynamicAssociationProxy('active_owner_memberships', 'user') admin_users = DynamicAssociationProxy('active_admin_memberships', 'user')
class User: all_notifications = with_roles( db.relationship( UserNotification, lazy='dynamic', order_by=UserNotification.created_at.desc(), ), read={'owner'}, ) notification_preferences = db.relationship( NotificationPreferences, collection_class=column_mapped_collection( NotificationPreferences.notification_type ), ) # This relationship is wrapped in a property that creates it on first access _main_notification_preferences = db.relationship( NotificationPreferences, primaryjoin=db.and_( NotificationPreferences.user_id == User.id, NotificationPreferences.notification_type == '', ), uselist=False, ) @property def main_notification_preferences(self): if not self._main_notification_preferences: self._main_notification_preferences = NotificationPreferences( user=self, notification_type='', by_email=True, by_sms=False, by_webpush=False, by_telegram=False, by_whatsapp=False, ) db.session.add(self._main_notification_preferences) return self._main_notification_preferences
class Proposal: active_memberships = with_roles( db.relationship( ProposalMembership, lazy='dynamic', primaryjoin=db.and_( ProposalMembership.proposal_id == Proposal.id, ProposalMembership.is_active, ), viewonly=True, ), grants_via={'user': {'reviewer', 'presenter'}}, ) active_reviewer_memberships = db.relationship( ProposalMembership, lazy='dynamic', primaryjoin=db.and_( ProposalMembership.proposal_id == Proposal.id, ProposalMembership.is_active, ProposalMembership.is_reviewer.is_(True), ), viewonly=True, ) active_presenter_memberships = db.relationship( ProposalMembership, lazy='dynamic', primaryjoin=db.and_( ProposalMembership.proposal_id == Proposal.id, ProposalMembership.is_active, ProposalMembership.is_presenter.is_(True), ), viewonly=True, ) members = DynamicAssociationProxy('active_memberships', 'user') reviewers = DynamicAssociationProxy('active_reviewer_memberships', 'user') presenters = DynamicAssociationProxy('active_presenters_memberships', 'user')
class DeclaredAttrMixin(object): # with_roles can be used within a declared attr @declared_attr def mixed_in1(cls): return with_roles(db.Column(db.Unicode(250)), rw={'owner'}) # declared_attr_roles is deprecated since 0.6.1. Use with_roles # as the outer decorator now. It remains here for the test case. @declared_attr @declared_attr_roles(rw={'owner', 'editor'}, read={'all'}) def mixed_in2(cls): return db.Column(db.Unicode(250)) # with_roles can also be used outside a declared attr @with_roles(rw={'owner'}) @declared_attr def mixed_in3(cls): return db.Column(db.Unicode(250)) # A regular column from the mixin mixed_in4 = db.Column(db.Unicode(250)) mixed_in4 = with_roles(mixed_in4, rw={'owner'})
class ImmutableMembershipMixin(UuidMixin, BaseMixin): """ Support class for immutable memberships """ __uuid_primary_key__ = True #: List of columns that will be copied into a new row when a membership is amended __data_columns__ = () #: Parent column (override as synonym of 'profile_id' or 'project_id' in the subclasses) parent_id = None #: Start time of membership, ordinarily a mirror of created_at except #: for records created when the member table was added to the database granted_at = immutable( with_roles( db.Column(db.TIMESTAMP(timezone=True), nullable=False, default=db.func.utcnow()), read={'subject', 'editor'}, )) #: End time of membership, ordinarily a mirror of updated_at revoked_at = with_roles( db.Column(db.TIMESTAMP(timezone=True), nullable=True), read={'subject', 'editor'}, ) #: Record type record_type = immutable( with_roles( db.Column( db.Integer, StateManager.check_constraint('record_type', MEMBERSHIP_RECORD_TYPE), default=MEMBERSHIP_RECORD_TYPE.DIRECT_ADD, nullable=False, ), read={'subject', 'editor'}, )) @declared_attr def user_id(cls): return db.Column( None, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False, index=True, ) @with_roles(read={'subject', 'editor'}, grants={'subject'}) @declared_attr def user(cls): return db.relationship(User, foreign_keys=[cls.user_id]) @declared_attr def revoked_by_id(cls): """Id of user who revoked the membership""" return db.Column(None, db.ForeignKey('user.id', ondelete='SET NULL'), nullable=True) @with_roles(read={'subject'}, grants={'editor'}) @declared_attr def revoked_by(cls): """User who revoked the membership""" return db.relationship(User, foreign_keys=[cls.revoked_by_id]) @declared_attr def granted_by_id(cls): """ Id of user who assigned the membership. This is nullable only for historical data. New records always require a value for granted_by """ return db.Column(None, db.ForeignKey('user.id', ondelete='SET NULL'), nullable=True) @with_roles(read={'subject', 'editor'}, grants={'editor'}) @declared_attr def granted_by(cls): """User who assigned the membership""" return db.relationship(User, foreign_keys=[cls.granted_by_id]) @hybrid_property def is_active(self): return (self.revoked_at is None and self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE) @is_active.expression def is_active(cls): # NOQA: N805 return db.and_(cls.revoked_at.is_(None), cls.record_type != MEMBERSHIP_RECORD_TYPE.INVITE) with_roles(is_active, read={'subject'}) @with_roles(read={'subject', 'editor'}) @hybrid_property def is_invite(self): return self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE @declared_attr def __table_args__(cls): if cls.parent_id is not None: return (db.Index( 'ix_' + cls.__tablename__ + '_active', cls.parent_id.name, 'user_id', unique=True, postgresql_where=db.text('revoked_at IS NULL'), ), ) else: return (db.Index( 'ix_' + cls.__tablename__ + '_active', 'user_id', unique=True, postgresql_where=db.text('revoked_at IS NULL'), ), ) @cached_property def offered_roles(self): """Roles offered by this membership record""" return set() # Subclasses must gate these methods in __roles__ @with_roles(call={'subject', 'editor'}) def revoke(self, actor): if self.revoked_at is not None: raise MembershipRevokedError( "This membership record has already been revoked") self.revoked_at = db.func.utcnow() self.revoked_by = actor @with_roles(call={'editor'}) def replace(self, actor, **roles): if self.revoked_at is not None: raise MembershipRevokedError( "This membership record has already been revoked") if not set(roles.keys()).issubset(self.__data_columns__): raise AttributeError("Unknown role") # Perform sanity check. If nothing changed, just return self has_changes = False if self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE: # If we existing record is an INVITE, this must be an ACCEPT. This is an # acceptable change has_changes = True else: # If it's not an ACCEPT, are the supplied roles different from existing? for column in roles: if roles[column] != getattr(self, column): has_changes = True if not has_changes: # Nothing is changing. This is probably a form submit with no changes. # Do nothing and return self return self # An actual change? Revoke this record and make a new record self.revoked_at = db.func.utcnow() self.revoked_by = actor new = type(self)(user=self.user, parent_id=self.parent_id, granted_by=self.granted_by) # if existing record type is INVITE, replace it with ACCEPT, # else replace it with AMEND if self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE: new.record_type = MEMBERSHIP_RECORD_TYPE.ACCEPT else: new.record_type = MEMBERSHIP_RECORD_TYPE.AMEND for column in self.__data_columns__: if column in roles: setattr(new, column, roles[column]) else: setattr(new, column, getattr(self, column)) db.session.add(new) return new @with_roles(call={'subject'}) def accept(self, actor): if self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE: raise MembershipRecordTypeError( "This membership record is not an invite") return self.replace(actor)
class User(SharedProfileMixin, UuidMixin, BaseMixin, db.Model): __tablename__ = 'user' __title_length__ = 80 # XXX: Deprecated, still here for Baseframe compatibility userid = db.synonym('buid') #: The user's fullname fullname = with_roles( db.Column(db.Unicode(__title_length__), default='', nullable=False), read={'all'}, ) #: Alias for the user's fullname title = db.synonym('fullname') #: Argon2 or Bcrypt hash of the user's password pw_hash = db.Column(db.Unicode, nullable=True) #: Timestamp for when the user's password last changed pw_set_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True) #: Expiry date for the password (to prompt user to reset it) pw_expires_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True) #: User's preferred/last known timezone timezone = with_roles(db.Column(TimezoneType(backend='pytz'), nullable=True), read={'owner'}) #: Update timezone automatically from browser activity auto_timezone = db.Column(db.Boolean, default=True, nullable=False) #: User's preferred/last known locale locale = with_roles(db.Column(LocaleType, nullable=True), read={'owner'}) #: Update locale automatically from browser activity auto_locale = db.Column(db.Boolean, default=True, nullable=False) #: User's status (active, suspended, merged, etc) status = db.Column(db.SmallInteger, nullable=False, default=USER_STATUS.ACTIVE) #: Other user accounts that were merged into this user account oldusers = association_proxy('oldids', 'olduser') search_vector = db.deferred( db.Column( TSVectorType( 'fullname', weights={'fullname': 'A'}, regconfig='english', hltext=lambda: User.fullname, ), nullable=False, )) __table_args__ = ( db.Index( 'ix_user_fullname_lower', db.func.lower(fullname).label('fullname_lower'), postgresql_ops={'fullname_lower': 'varchar_pattern_ops'}, ), db.Index('ix_user_search_vector', 'search_vector', postgresql_using='gin'), ) _defercols = [ db.defer('created_at'), db.defer('updated_at'), db.defer('pw_hash'), db.defer('pw_set_at'), db.defer('pw_expires_at'), db.defer('timezone'), ] __roles__ = { 'all': { 'read': { 'uuid', 'name', 'title', 'fullname', 'username', 'pickername', 'timezone', 'status', 'avatar', 'created_at', 'profile', 'profile_url', 'urls', }, 'call': {'views', 'forms', 'features', 'url_for'}, } } __datasets__ = { 'primary': { 'uuid', 'name', 'title', 'fullname', 'username', 'pickername', 'timezone', 'status', 'avatar', 'created_at', 'profile', 'profile_url', 'urls', }, 'related': { 'name', 'title', 'fullname', 'username', 'pickername', 'timezone', 'status', 'avatar', 'created_at', 'profile_url', }, } def __init__(self, password=None, **kwargs): self.password = password super(User, self).__init__(**kwargs) @hybrid_property def name(self): if self.profile: return self.profile.name @name.setter def name(self, value): if not value: if self.profile is not None: raise ValueError("Name is required") else: if self.profile is not None: self.profile.name = value else: self.profile = Profile(name=value, user=self, uuid=self.uuid) @name.expression def name(cls): # NOQA: N805 return db.select([Profile.name ]).where(Profile.user_id == cls.id).label('name') with_roles(name, read={'all'}) username = name @hybrid_property def is_active(self): return self.status == USER_STATUS.ACTIVE @cached_property def verified_contact_count(self): count = 0 count += len(self.emails) count += len(self.phones) return count @property def has_verified_contact_info(self): return self.verified_contact_count > 0 def merged_user(self): if self.status == USER_STATUS.MERGED: return UserOldId.get(self.uuid).user else: return self def _set_password(self, password): if password is None: self.pw_hash = None else: self.pw_hash = argon2.hash(password) # Also see :meth:`password_is` for transparent upgrade self.pw_set_at = db.func.utcnow() # Expire passwords after one year. TODO: make this configurable self.pw_expires_at = self.pw_set_at + timedelta(days=365) #: Write-only property (passwords cannot be read back in plain text) password = property(fset=_set_password) def password_has_expired(self): """True if password expiry timestamp has passed.""" return (self.pw_hash is not None and self.pw_expires_at is not None and self.pw_expires_at <= utcnow()) def password_is(self, password, upgrade_hash=False): """Test if the candidate password matches saved hash.""" if self.pw_hash is None: return False # Passwords may use the current Argon2 scheme or the older Bcrypt scheme. # Bcrypt passwords are transparently upgraded if requested. if argon2.identify(self.pw_hash): return argon2.verify(password, self.pw_hash) elif bcrypt.identify(self.pw_hash): verified = bcrypt.verify(password, self.pw_hash) if verified and upgrade_hash: self.pw_hash = argon2.hash(password) return verified return False def __repr__(self): return '<User {username} "{fullname}">'.format(username=self.username or self.buid, fullname=self.fullname) def __str__(self): return self.pickername @with_roles(read={'all'}) @property def pickername(self): if self.username: return '{fullname} (@{username})'.format(fullname=self.fullname, username=self.username) else: return self.fullname def add_email(self, email, primary=False, type=None, private=False): # NOQA: A002 useremail = UserEmail(user=self, email=email, type=type, private=private) useremail = failsafe_add(db.session, useremail, user=self, email_address=useremail.email_address) if primary: self.primary_email = useremail return useremail # FIXME: This should remove competing instances of UserEmailClaim def del_email(self, email): useremail = UserEmail.get_for(user=self, email=email) if self.primary_email in (useremail, None): self.primary_email = (UserEmail.query.filter( UserEmail.user == self, UserEmail.id != useremail.id).order_by( UserEmail.created_at.desc()).first()) db.session.delete(useremail) @with_roles(read={'owner'}) @cached_property def email(self): """ Returns primary email address for user. """ # Look for a primary address useremail = self.primary_email if useremail: return useremail # No primary? Maybe there's one that's not set as primary? useremail = UserEmail.query.filter_by(user=self).first() if useremail: # XXX: Mark as primary. This may or may not be saved depending on # whether the request ended in a database commit. self.primary_email = useremail return useremail # This user has no email address. Return a blank string instead of None # to support the common use case, where the caller will use str(user.email) # to get the email address as a string. return '' @with_roles(read={'owner'}) @cached_property def phone(self): """ Returns primary phone number for user. """ # Look for a primary phone number userphone = self.primary_phone if userphone: return userphone # No primary? Maybe there's one that's not set as primary? userphone = UserPhone.query.filter_by(user=self).first() if userphone: # XXX: Mark as primary. This may or may not be saved depending on # whether the request ended in a database commit. self.primary_phone = userphone return userphone # This user has no phone number. Return a blank string instead of None # to support the common use case, where the caller will use str(user.phone) # to get the phone number as a string. return '' def is_profile_complete(self): """ Return True if profile is complete (fullname, username and one contact are present), False otherwise. """ return bool(self.fullname and self.username and self.has_verified_contact_info) # --- Transport details @with_roles(call={'owner'}) def has_transport_email(self): return self.is_active and bool(self.email) @with_roles(call={'owner'}) def has_transport_sms(self): return self.is_active and bool(self.phone) @with_roles(call={'owner'}) def has_transport_webpush(self): # TODO # pragma: no cover return False @with_roles(call={'owner'}) def has_transport_telegram(self): # TODO # pragma: no cover return False @with_roles(call={'owner'}) def has_transport_whatsapp(self): # TODO # pragma: no cover return False @with_roles(call={'owner'}) def transport_for_email(self, context): """Return user's preferred email address within a context.""" # Per-profile/project customization is a future option return self.email if self.is_active else None @with_roles(call={'owner'}) def transport_for_sms(self, context): """Return user's preferred phone number within a context.""" # Per-profile/project customization is a future option return self.phone if self.is_active else None @with_roles(call={'owner'}) def transport_for_webpush(self, context): # TODO # pragma: no cover return None @with_roles(call={'owner'}) def transport_for_telegram(self, context): # TODO # pragma: no cover return None @with_roles(call={'owner'}) def transport_for_whatsapp(self, context): # TODO # pragma: no cover return None @with_roles(call={'owner'}) def has_transport(self, transport): """ Helper method to call ``self.has_transport_<transport>()``. ..note:: Because this method does not accept a context, it may return True for a transport that has been muted in that context. This may cause an empty background job to be queued for a notification. Revisit this method when preference contexts are supported. """ return getattr(self, 'has_transport_' + transport)() @with_roles(call={'owner'}) def transport_for(self, transport, context): """Helper method to call ``self.transport_for_<transport>(context)``.""" return getattr(self, 'transport_for_' + transport)(context) @with_roles(grants={'owner', 'admin'}) @property def _self_is_owner_and_admin_of_self(self): """Helper method for ``roles_for`` and ``actors_with``.""" return self def organizations_as_owner_ids(self): """ Return the database ids of the organizations this user is an owner of. This is used for database queries. """ return [ membership.organization_id for membership in self.active_organization_owner_memberships ] @classmethod def get(cls, username=None, buid=None, userid=None, defercols=False): """ Return a User with the given username or buid. :param str username: Username to lookup :param str buid: Buid to lookup :param bool defercols: Defer loading non-critical columns """ require_one_of(username=username, buid=buid, userid=userid) # userid parameter is temporary for Flask-Lastuser compatibility if userid: buid = userid if username is not None: query = cls.query.join(Profile).filter( db.func.lower(Profile.name) == db.func.lower(username)) else: query = cls.query.filter_by(buid=buid) if defercols: query = query.options(*cls._defercols) user = query.one_or_none() if user and user.status == USER_STATUS.MERGED: user = user.merged_user() if user and user.is_active: return user @classmethod # NOQA: A003 def all( # NOQA: A003 cls, buids=None, userids=None, usernames=None, defercols=False): """ Return all matching users. :param list buids: Buids to look up :param list userids: Alias for ``buids`` (deprecated) :param list usernames: Usernames to look up :param bool defercols: Defer loading non-critical columns """ users = set() if userids and not buids: buids = userids if buids and usernames: query = cls.query.join(Profile).filter( db.or_( cls.buid.in_(buids), db.func.lower(Profile.name).in_( [username.lower() for username in usernames]), )) elif buids: query = cls.query.filter(cls.buid.in_(buids)) elif usernames: query = cls.query.join(Profile).filter( db.func.lower(Profile.name).in_( [username.lower() for username in usernames])) else: raise Exception if defercols: query = query.options(*cls._defercols) for user in query.all(): user = user.merged_user() if user.is_active: users.add(user) return list(users) @classmethod def autocomplete(cls, query): """ Return users whose names begin with the query, for autocomplete widgets. Looks up users by fullname, username, external ids and email addresses. :param str query: Letters to start matching with """ # Escape the '%' and '_' wildcards in SQL LIKE clauses. # Some SQL dialects respond to '[' and ']', so remove them. like_query = (query.replace('%', r'\%').replace('_', r'\_').replace( '[', '').replace(']', '') + '%') # We convert to lowercase and use the LIKE operator since ILIKE isn't standard # and doesn't use an index in PostgreSQL. There's a functional index for lower() # defined above in __table_args__ that also applies to LIKE lower(val) queries. if like_query in ('%', '@%'): return [] # base_users is used in two of the three possible queries below base_users = (cls.query.join(Profile).filter( cls.status == USER_STATUS.ACTIVE, db.or_( db.func.lower(cls.fullname).like(db.func.lower(like_query)), db.func.lower(Profile.name).like(db.func.lower(like_query)), ), ).options(*cls._defercols).limit(20)) if (query != '@' and query.startswith('@') and UserExternalId.__at_username_services__): # @-prefixed, so look for usernames, including other @username-using # services like Twitter and GitHub. Make a union of three queries. users = ( # Query 1: @query -> User.username cls.query.join(Profile).filter( cls.status == USER_STATUS.ACTIVE, db.func.lower(Profile.name).like( db.func.lower(like_query[1:])), ).options(*cls._defercols).limit(20).union( # Query 2: @query -> UserExternalId.username cls.query.join(UserExternalId).filter( cls.status == USER_STATUS.ACTIVE, UserExternalId.service.in_( UserExternalId.__at_username_services__), db.func.lower(UserExternalId.username).like( db.func.lower(like_query[1:])), ).options(*cls._defercols).limit(20), # Query 3: like_query -> User.fullname cls.query.filter( cls.status == USER_STATUS.ACTIVE, db.func.lower(cls.fullname).like( db.func.lower(like_query)), ).options(*cls._defercols).limit(20), ).all()) elif '@' in query and not query.startswith('@'): # Query has an @ in the middle. Match email address (exact match only). # Use `query` instead of `like_query` because it's not a LIKE query. # Combine results with regular user search users = (cls.query.join(UserEmail, EmailAddress).filter( UserEmail.user_id == cls.id, UserEmail.email_address_id == EmailAddress.id, EmailAddress.get_filter(email=query), cls.status == USER_STATUS.ACTIVE, ).options(*cls._defercols).limit(20).union(base_users).all()) else: # No '@' in the query, so do a regular autocomplete users = base_users.all() return users @classmethod def active_user_count(cls): return cls.query.filter_by(status=USER_STATUS.ACTIVE).count() #: FIXME: Temporary values for Baseframe compatibility def organization_links(self): return []
class Team(UuidMixin, BaseMixin, db.Model): __tablename__ = 'team' __title_length__ = 250 #: Displayed name title = db.Column(db.Unicode(__title_length__), nullable=False) #: Organization organization_id = db.Column(None, db.ForeignKey('organization.id'), nullable=False) organization = with_roles( db.relationship( Organization, backref=db.backref('teams', order_by=db.func.lower(title), cascade='all'), ), grants_via={None: { 'owner': 'owner', 'admin': 'admin' }}, ) users = with_roles( db.relationship(User, secondary=team_membership, lazy='dynamic', backref='teams'), grants={'subject'}, ) is_public = db.Column(db.Boolean, nullable=False, default=False) def __repr__(self): return '<Team {team} of {organization}>'.format( team=self.title, organization=repr(self.organization)[1:-1]) @property def pickername(self): return self.title def permissions(self, user, inherited=None): perms = super(Team, self).permissions(user, inherited) if user and user in self.organization.admin_users: perms.add('edit') perms.add('delete') return perms @classmethod def migrate_user(cls, olduser, newuser): for team in list(olduser.teams): if team not in newuser.teams: # FIXME: This creates new memberships, updating `created_at`. # Unfortunately, we can't work with model instances as in the other # `migrate_user` methods as team_membership is an unmapped table. newuser.teams.append(team) olduser.teams.remove(team) return [cls.__table__.name, team_membership.name] @classmethod def get(cls, buid, with_parent=False): """ Return a Team with matching buid. :param str buid: Buid of the team """ if with_parent: query = cls.query.options(db.joinedload(cls.organization)) else: query = cls.query return query.filter_by(buid=buid).one_or_none()
class Organization(SharedProfileMixin, UuidMixin, BaseMixin, db.Model): __tablename__ = 'organization' __title_length__ = 80 title = with_roles( db.Column(db.Unicode(__title_length__), default='', nullable=False), read={'all'}, ) search_vector = db.deferred( db.Column( TSVectorType( 'title', weights={'title': 'A'}, regconfig='english', hltext=lambda: Organization.title, ), nullable=False, )) __table_args__ = (db.Index('ix_organization_search_vector', 'search_vector', postgresql_using='gin'), ) __roles__ = { 'all': { 'read': { 'name', 'title', 'pickername', 'created_at', 'profile', 'profile_url', 'urls', }, 'call': {'views', 'features', 'forms', 'url_for'}, } } __datasets__ = { 'primary': { 'name', 'title', 'username', 'pickername', 'avatar', 'created_at', 'profile', 'profile_url', }, 'related': {'name', 'title', 'pickername', 'created_at'}, } _defercols = [db.defer('created_at'), db.defer('updated_at')] def __init__(self, owner, *args, **kwargs): super(Organization, self).__init__(*args, **kwargs) db.session.add( OrganizationMembership(organization=self, user=owner, granted_by=owner, is_owner=True)) @hybrid_property def name(self): if self.profile: return self.profile.name @name.setter def name(self, value): if not value: if self.profile is not None: raise ValueError("Name is required") else: if self.profile is not None: self.profile.name = value else: self.profile = Profile(name=value, organization=self, uuid=self.uuid) @name.expression def name(cls): # NOQA: N805 return (db.select([ Profile.name ]).where(Profile.organization_id == cls.id).label('name')) with_roles(name, read={'all'}) def __repr__(self): return '<Organization {name} "{title}">'.format(name=self.name or self.buid, title=self.title) @with_roles(read={'all'}) @property def pickername(self): if self.name: return '{title} (@{name})'.format(title=self.title, name=self.name) else: return self.title def people(self): """Return a list of users from across the public teams they are in.""" return (User.query.join(team_membership).join(Team).filter( Team.organization == self, Team.is_public.is_(True)).options(db.joinedload( User.teams)).order_by(db.func.lower(User.fullname))) def permissions(self, user, inherited=None): perms = super().permissions(user, inherited) if 'view' in perms: perms.remove('view') if 'edit' in perms: perms.remove('edit') if 'delete' in perms: perms.remove('delete') if user and user in self.admin_users: perms.add('view') perms.add('edit') perms.add('view-teams') perms.add('new-team') if user and user in self.owner_users: perms.add('delete') return perms @classmethod def get(cls, name=None, buid=None, defercols=False): """ Return an Organization with matching name or buid. Note that ``name`` is the username, not the title. :param str name: Name of the organization :param str buid: Buid of the organization :param bool defercols: Defer loading non-critical columns """ require_one_of(name=name, buid=buid) if name is not None: query = cls.query.join(Profile).filter( db.func.lower(Profile.name) == db.func.lower(name)) else: query = cls.query.filter_by(buid=buid) if defercols: query = query.options(*cls._defercols) return query.one_or_none() @classmethod # NOQA: A003 def all(cls, buids=None, names=None, defercols=False): # NOQA: A003 orgs = [] if buids: query = cls.query.filter(cls.buid.in_(buids)) if defercols: query = query.options(*cls._defercols) orgs.extend(query.all()) if names: query = cls.query.join(Profile).filter( db.func.lower(Profile.name).in_( [name.lower() for name in names])) if defercols: query = query.options(*cls._defercols) orgs.extend(query.all()) return orgs
class Rsvp(UuidMixin, NoIdMixin, db.Model): __tablename__ = 'rsvp' project_id = db.Column(None, db.ForeignKey('project.id'), nullable=False, primary_key=True) project = with_roles( db.relationship(Project, backref=db.backref('rsvps', cascade='all', lazy='dynamic')), read={'owner', 'project_concierge'}, grants_via={None: project_child_role_map}, ) user_id = db.Column(None, db.ForeignKey('user.id'), nullable=False, primary_key=True) user = with_roles( db.relationship(User, backref=db.backref('rsvps', cascade='all', lazy='dynamic')), read={'owner', 'project_concierge'}, grants={'owner'}, ) _state = db.Column( 'state', db.CHAR(1), StateManager.check_constraint('state', RSVP_STATUS), default=RSVP_STATUS.AWAITING, nullable=False, ) state = with_roles( StateManager('_state', RSVP_STATUS, doc="RSVP answer"), call={'owner', 'project_concierge'}, ) __datasets__ = { 'primary': {'project', 'user', 'response'}, 'related': {'response'} } @with_roles(read={'owner', 'project_concierge'}) @property def response(self): """Return state as a raw value""" return self._state @with_roles(call={'owner'}) @state.transition( None, state.YES, title=__("Going"), message=__("Your response has been saved"), type='primary', ) def rsvp_yes(self): pass @with_roles(call={'owner'}) @state.transition( None, state.NO, title=__("Not going"), message=__("Your response has been saved"), type='dark', ) def rsvp_no(self): pass @with_roles(call={'owner'}) @state.transition( None, state.MAYBE, title=__("Maybe"), message=__("Your response has been saved"), type='accent', ) def rsvp_maybe(self): pass @with_roles(call={'owner', 'project_concierge'}) def user_email(self): """User's preferred email address for this registration.""" return self.user.transport_for_email(self.project.profile) @classmethod def migrate_user(cls, old_user, new_user): project_ids = {rsvp.project_id for rsvp in new_user.rsvps} for rsvp in old_user.rsvps: if rsvp.project_id not in project_ids: rsvp.user = new_user else: current_app.logger.warning( "Discarding conflicting RSVP (%s) from %r on %r", rsvp._state, old_user, rsvp.project, ) db.session.delete(rsvp) @classmethod def get_for(cls, project, user, create=False): if user: result = cls.query.get((project.id, user.id)) if not result and create: result = cls(project=project, user=user) db.session.add(result) return result
def mixed_in1(cls): return with_roles(db.Column(db.Unicode(250)), rw={'owner'})
def secret_is(self, candidate, name): """ Check if the provided client secret is valid. """ credential = self.credentials[name] return credential.secret_is(candidate) @property def redirect_uris(self): return tuple(self._redirect_uris.split()) @redirect_uris.setter def redirect_uris(self, value): self._redirect_uris = '\r\n'.join(value) with_roles(redirect_uris, rw={'owner'}) @property def redirect_uri(self): uris = self.redirect_uris # Assign to local var to avoid splitting twice if uris: return uris[0] def host_matches(self, url): netloc = urllib.parse.urlsplit(url or '').netloc if netloc: return netloc in (urllib.parse.urlsplit(r).netloc for r in (self.redirect_uris + (self.website, ))) return False @with_roles(read={'all'})
class Project: # Project schedule column expressions # Guide: https://docs.sqlalchemy.org/en/13/orm/mapped_sql_expr.html#using-column-property schedule_start_at = with_roles( db.column_property( db.select([ db.func.min(Session.start_at) ]).where(Session.start_at.isnot(None)).where( Session.project_id == Project.id).correlate_except(Session)), read={'all'}, datasets={'primary', 'without_parent'}, ) next_session_at = with_roles( db.column_property( db.select([db.func.min(Session.start_at) ]).where(Session.start_at.isnot(None)). where(Session.start_at > db.func.utcnow()).where( Session.project_id == Project.id).correlate_except(Session)), read={'all'}, ) schedule_end_at = with_roles( db.column_property( db.select([ db.func.max(Session.end_at) ]).where(Session.end_at.isnot(None)).where( Session.project_id == Project.id).correlate_except(Session)), read={'all'}, datasets={'primary', 'without_parent'}, ) @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property def schedule_start_at_localized(self): return (localize_timezone(self.schedule_start_at, tz=self.timezone) if self.schedule_start_at else None) @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property def schedule_end_at_localized(self): return (localize_timezone(self.schedule_end_at, tz=self.timezone) if self.schedule_end_at else None) @with_roles(read={'all'}) @cached_property def session_count(self): return self.sessions.filter(Session.start_at.isnot(None)).count() featured_sessions = with_roles( db.relationship( Session, order_by=Session.start_at.asc(), primaryjoin=db.and_(Session.project_id == Project.id, Session.featured.is_(True)), ), read={'all'}, ) scheduled_sessions = with_roles( db.relationship( Session, order_by=Session.start_at.asc(), primaryjoin=db.and_(Session.project_id == Project.id, Session.scheduled), ), read={'all'}, ) unscheduled_sessions = with_roles( db.relationship( Session, order_by=Session.start_at.asc(), primaryjoin=db.and_(Session.project_id == Project.id, Session.scheduled.isnot(True)), ), read={'all'}, ) sessions_with_video = with_roles( db.relationship( Session, lazy='dynamic', primaryjoin=db.and_( Project.id == Session.project_id, Session.video_id.isnot(None), Session.video_source.isnot(None), ), ), read={'all'}, ) @with_roles(read={'all'}) @cached_property def has_sessions_with_video(self): return self.query.session.query( self.sessions_with_video.exists()).scalar() def next_session_from(self, timestamp): """ Find the next session in this project starting at or after given timestamp. """ return (self.sessions.filter(Session.start_at.isnot(None), Session.start_at >= timestamp).order_by( Session.start_at.asc()).first()) @classmethod def starting_at(cls, timestamp, within, gap): """ Returns projects that are about to start, for sending notifications. :param datetime timestamp: The timestamp to look for new sessions at :param timedelta within: Find anything at timestamp + within delta. Lookup will be for sessions where timestamp >= start_at < timestamp+within :param timedelta gap: A project will be considered to be starting if it has no sessions ending within the gap period before the timestamp Typical use of this method is from a background worker that calls it at intervals of five minutes with parameters (timestamp, within 5m, 60m gap). """ # As a rule, start_at is queried with >= and <, end_at with > and <= because # they represent inclusive lower and upper bounds. return (cls.query.filter( cls.id.in_( db.session.query(db.func.distinct(Session.project_id)).filter( Session.start_at.isnot(None), Session.start_at >= timestamp, Session.start_at < timestamp + within, Session.project_id.notin_( db.session.query(db.func.distinct( Session.project_id)).filter( Session.start_at.isnot(None), db.or_( db.and_( Session.start_at >= timestamp - gap, Session.start_at < timestamp, ), db.and_( Session.end_at > timestamp - gap, Session.end_at <= timestamp, ), ), )), ))).join(Session.project).filter( Project.state.PUBLISHED, Project.schedule_state.PUBLISHED)) @with_roles(call={'all'}) def current_sessions(self): if self.schedule_start_at is None or ( self.schedule_start_at > utcnow() + timedelta(minutes=30)): return current_sessions = (self.sessions.outerjoin(VenueRoom).filter( Session.start_at <= db.func.utcnow() + timedelta(minutes=30)).filter( Session.end_at > db.func.utcnow()).order_by( Session.start_at.asc(), VenueRoom.seq.asc())) return { 'sessions': [ session.current_access(datasets=('without_parent', 'related')) for session in current_sessions ], 'rooms': [ room.current_access(datasets=('without_parent', 'related')) for room in self.rooms ], } def calendar_weeks(self, leading_weeks=True): # session_dates is a list of tuples in this format - # (date, day_start_at, day_end_at, event_count) session_dates = list( db.session.query('date', 'day_start_at', 'day_end_at', 'count').from_statement( db.text(''' SELECT DATE_TRUNC('day', "start_at" AT TIME ZONE :timezone) AS date, MIN(start_at) as day_start_at, MAX(end_at) as day_end_at, COUNT(*) AS count FROM "session" WHERE "project_id" = :project_id AND "start_at" IS NOT NULL AND "end_at" IS NOT NULL GROUP BY date ORDER BY date; ''')).params(timezone=self.timezone.zone, project_id=self.id)) session_dates_dict = { date.date(): { 'day_start_at': day_start_at, 'day_end_at': day_end_at, 'count': count, } for date, day_start_at, day_end_at, count in session_dates } # FIXME: This doesn't work. This code needs to be tested in isolation # session_dates = db.session.query( # db.cast( # db.func.date_trunc('day', db.func.timezone(self.timezone.zone, Session.start_at)), # db.Date).label('date'), # db.func.count().label('count') # ).filter( # Session.project == self, # Session.scheduled # ).group_by(db.text('date')).order_by(db.text('date')) # if the project's week is within next 2 weeks, send current week as well now = utcnow().astimezone(self.timezone) current_week = Week.withdate(now) if leading_weeks and self.schedule_start_at is not None: schedule_start_week = Week.withdate(self.schedule_start_at) # session_dates is a list of tuples in this format - # (date, day_start_at, day_end_at, event_count) # as these days dont have any event, day_start/end_at are None, # and count is 0. if (schedule_start_week > current_week and (schedule_start_week - current_week) <= 2): if (schedule_start_week - current_week) == 2: # add this so that the next week's dates # are also included in the calendar. session_dates.insert( 0, (now + timedelta(days=7), None, None, 0)) session_dates.insert(0, (now, None, None, 0)) weeks = defaultdict(dict) today = now.date() for project_date, _day_start_at, _day_end_at, session_count in session_dates: weekobj = Week.withdate(project_date) if weekobj.week not in weeks: weeks[weekobj.week]['year'] = weekobj.year # Order is important, and we need dict to count easily weeks[weekobj.week]['dates'] = OrderedDict() for wdate in weekobj.days(): weeks[weekobj.week]['dates'].setdefault(wdate, 0) if project_date.date() == wdate: # If the event is over don't set upcoming for current week if wdate >= today and weekobj >= current_week and session_count > 0: weeks[weekobj.week]['upcoming'] = True weeks[weekobj.week]['dates'][wdate] += session_count if 'month' not in weeks[weekobj.week]: weeks[weekobj.week]['month'] = format_date( wdate, 'MMM', locale=get_locale()) # Extract sorted weeks as a list weeks_list = [v for k, v in sorted(weeks.items())] for week in weeks_list: # Convering to JSON messes up dictionary key order even though we used OrderedDict. # This turns the OrderedDict into a list of tuples and JSON preserves that order. week['dates'] = [{ 'isoformat': date.isoformat(), 'day': format_date(date, 'd', get_locale()), 'count': count, 'day_start_at': (session_dates_dict[date]['day_start_at'].astimezone( self.timezone).strftime('%I:%M %p') if date in session_dates_dict.keys() else None), 'day_end_at': (session_dates_dict[date]['day_end_at'].astimezone( self.timezone).strftime('%I:%M %p %Z') if date in session_dates_dict.keys() else None), } for date, count in week['dates'].items()] return { 'locale': get_locale(), 'weeks': weeks_list, 'today': now.date().isoformat(), 'days': [ format_date(day, 'EEE', locale=get_locale()) for day in Week.thisweek().days() ], } @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property def calendar_weeks_full(self): return self.calendar_weeks(leading_weeks=True) @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property def calendar_weeks_compact(self): return self.calendar_weeks(leading_weeks=False)
class Session(UuidMixin, BaseScopedIdNameMixin, VideoMixin, db.Model): __tablename__ = 'session' project_id = db.Column(None, db.ForeignKey('project.id'), nullable=False) project = with_roles( db.relationship(Project, backref=db.backref('sessions', cascade='all', lazy='dynamic')), grants_via={None: project_child_role_map}, ) parent = db.synonym('project') description = MarkdownColumn('description', default='', nullable=False) speaker_bio = MarkdownColumn('speaker_bio', default='', nullable=False) proposal_id = db.Column(None, db.ForeignKey('proposal.id'), nullable=True, unique=True) proposal = db.relationship(Proposal, backref=db.backref('session', uselist=False, cascade='all')) speaker = db.Column(db.Unicode(200), default=None, nullable=True) start_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True, index=True) end_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True, index=True) venue_room_id = db.Column(None, db.ForeignKey('venue_room.id'), nullable=True) venue_room = db.relationship(VenueRoom, backref=db.backref('sessions')) is_break = db.Column(db.Boolean, default=False, nullable=False) featured = db.Column(db.Boolean, default=False, nullable=False) banner_image_url = db.Column(UrlType, nullable=True) search_vector = db.deferred( db.Column( TSVectorType( 'title', 'description_text', 'speaker_bio_text', 'speaker', weights={ 'title': 'A', 'description_text': 'B', 'speaker_bio_text': 'B', 'speaker': 'A', }, regconfig='english', hltext=lambda: db.func.concat_ws( visual_field_delimiter, Session.title, Session.speaker, Session.description_html, Session.speaker_bio_html, ), ), nullable=False, )) __table_args__ = ( db.UniqueConstraint('project_id', 'url_id'), db.CheckConstraint( '("start_at" IS NULL AND "end_at" IS NULL) OR ("start_at" IS NOT NULL AND "end_at" IS NOT NULL)', 'session_start_at_end_at_check', ), db.Index('ix_session_search_vector', 'search_vector', postgresql_using='gin'), ) __roles__ = { 'all': { 'read': { 'created_at', 'updated_at', 'title', 'project', 'speaker', 'user', 'featured', 'description', 'speaker_bio', 'start_at', 'end_at', 'venue_room', 'is_break', 'banner_image_url', 'start_at_localized', 'end_at_localized', 'scheduled', 'video', 'proposal', }, 'call': {'url_for'}, } } __datasets__ = { 'primary': { 'uuid_b58', 'title', 'speaker', 'user', 'featured', 'description', 'speaker_bio', 'start_at', 'end_at', 'venue_room', 'is_break', 'banner_image_url', 'start_at_localized', 'end_at_localized', }, 'without_parent': { 'uuid_b58', 'title', 'speaker', 'user', 'featured', 'description', 'speaker_bio', 'start_at', 'end_at', 'venue_room', 'is_break', 'banner_image_url', 'start_at_localized', 'end_at_localized', }, 'related': { 'uuid_b58', 'title', 'speaker', 'user', 'featured', 'description', 'speaker_bio', 'start_at', 'end_at', 'venue_room', 'is_break', 'banner_image_url', 'start_at_localized', 'end_at_localized', }, } @hybrid_property def user(self): if self.proposal: return self.proposal.speaker @hybrid_property def scheduled(self): # A session is scheduled only when both start and end fields have a value return self.start_at is not None and self.end_at is not None @scheduled.expression def scheduled(self): return (self.start_at.isnot(None)) & (self.end_at.isnot(None)) @cached_property def start_at_localized(self): return (localize_timezone(self.start_at, tz=self.project.timezone) if self.start_at else None) @cached_property def end_at_localized(self): return (localize_timezone(self.end_at, tz=self.project.timezone) if self.end_at else None) @classmethod def for_proposal(cls, proposal, create=False): session_obj = cls.query.filter_by(proposal=proposal).first() if session_obj is None and create: session_obj = cls( title=proposal.title, description=proposal.outline, speaker_bio=proposal.bio, project=proposal.project, proposal=proposal, ) db.session.add(session_obj) return session_obj def make_unscheduled(self): # Session is not deleted, but we remove start and end time, # so it becomes an unscheduled session. self.start_at = None self.end_at = None
class AuthToken(ScopeMixin, BaseMixin, db.Model): """Access tokens for access to data""" __tablename__ = 'auth_token' # Null for client-only tokens and public clients (user is identified via user_session.user there) user_id = db.Column(None, db.ForeignKey('user.id'), nullable=True) _user = db.relationship( User, primaryjoin=user_id == User.id, backref=db.backref('authtokens', lazy='dynamic', cascade='all'), ) #: The session in which this token was issued, null for confidential clients user_session_id = db.Column(None, db.ForeignKey('user_session.id'), nullable=True) user_session = with_roles( db.relationship(UserSession, backref=db.backref('authtokens', lazy='dynamic')), read={'owner'}, ) #: The client this authtoken is for auth_client_id = db.Column(None, db.ForeignKey('auth_client.id'), nullable=False, index=True) auth_client = with_roles( db.relationship( AuthClient, primaryjoin=auth_client_id == AuthClient.id, backref=db.backref('authtokens', lazy='dynamic', cascade='all'), ), read={'owner'}, ) #: The token token = db.Column(db.String(22), default=buid, nullable=False, unique=True) #: The token's type token_type = db.Column(db.String(250), default='bearer', nullable=False) # 'bearer', 'mac' or a URL #: Token secret for 'mac' type secret = db.Column(db.String(44), nullable=True) #: Secret's algorithm (for 'mac' type) _algorithm = db.Column('algorithm', db.String(20), nullable=True) #: Token's validity, 0 = unlimited validity = db.Column(db.Integer, nullable=False, default=0) # Validity period in seconds #: Refresh token, to obtain a new token refresh_token = db.Column(db.String(22), nullable=True, unique=True) # Only one authtoken per user and client. Add to scope as needed __table_args__ = ( db.UniqueConstraint('user_id', 'auth_client_id'), db.UniqueConstraint('user_session_id', 'auth_client_id'), ) __roles__ = {'owner': {'read': {'created_at'}}} @property def user(self): if self.user_session: return self.user_session.user else: return self._user @user.setter def user(self, value): self._user = value user = with_roles(db.synonym('_user', descriptor=user), read={'owner'}, grants={'owner'}) def __init__(self, **kwargs): super().__init__(**kwargs) self.token = buid() if self._user: self.refresh_token = buid() self.secret = newsecret() def __repr__(self): return '<AuthToken {token} of {auth_client} {user}>'.format( token=self.token, auth_client=repr(self.auth_client)[1:-1], user=repr(self.user)[1:-1], ) @property def effective_scope(self): return sorted(set(self.scope) | set(self.auth_client.scope)) @with_roles(read={'owner'}) @cached_property def last_used(self): return (db.session.query( db.func.max(auth_client_user_session.c.accessed_at) ).select_from(auth_client_user_session, UserSession).filter( auth_client_user_session.c.user_session_id == UserSession.id, auth_client_user_session.c.auth_client_id == self.auth_client_id, UserSession.user == self.user, ).scalar()) def refresh(self): """ Create a new token while retaining the refresh token. """ if self.refresh_token is not None: self.token = buid() self.secret = newsecret() @property def algorithm(self): return self._algorithm @algorithm.setter def algorithm(self, value): if value is None: self._algorithm = None self.secret = None elif value in ['hmac-sha-1', 'hmac-sha-256']: self._algorithm = value else: raise ValueError( _("Unrecognized algorithm ‘{value}’").format(value=value)) algorithm = db.synonym('_algorithm', descriptor=algorithm) def is_valid(self): if self.validity == 0: return True # This token is perpetually valid now = utcnow() if self.created_at < now - timedelta(seconds=self.validity): return False return True @classmethod def migrate_user(cls, old_user, new_user): if not old_user or not new_user: return # Don't mess with client-only tokens oldtokens = cls.query.filter_by(user=old_user).all() newtokens = {} # AuthClient: token mapping for token in cls.query.filter_by(user=new_user).all(): newtokens.setdefault(token.auth_client_id, []).append(token) for token in oldtokens: merge_performed = False if token.auth_client_id in newtokens: for newtoken in newtokens[token.auth_client_id]: if newtoken.user == new_user: # There's another token for newuser with the same client. # Just extend the scope there newtoken.scope = set(newtoken.scope) | set(token.scope) db.session.delete(token) merge_performed = True break if merge_performed is False: token.user = new_user # Reassign this token to newuser @classmethod def get(cls, token): """ Return an AuthToken with the matching token. :param str token: Token to lookup """ query = cls.query.filter_by(token=token).options( db.joinedload(cls.auth_client).load_only('id', '_scope')) return query.one_or_none() @classmethod def get_for(cls, auth_client, user=None, user_session=None): require_one_of(user=user, user_session=user_session) if user: return cls.query.filter_by(auth_client=auth_client, user=user).one_or_none() else: return cls.query.filter_by( auth_client=auth_client, user_session=user_session).one_or_none() @classmethod # NOQA: A003 def all(cls, users): # NOQA: A003 """ Return all AuthToken for the specified users. """ query = cls.query.options( db.joinedload(cls.auth_client).load_only('id', '_scope')) if isinstance(users, QueryBaseClass): count = users.count() if count == 1: return query.filter_by(user=users.first()).all() elif count > 1: return query.filter( AuthToken.user_id.in_(users.options( load_only('id')))).all() else: count = len(users) if count == 1: # Cast users into a list/tuple before accessing [0], as the source # may not be an actual list with indexed access. For example, # Organization.owner_users is a DynamicAssociationProxy. return query.filter_by(user=tuple(users)[0]).all() elif count > 1: return query.filter( AuthToken.user_id.in_([u.id for u in users])).all() return []
class Comment(UuidMixin, BaseMixin, db.Model): __tablename__ = 'comment' user_id = db.Column(None, db.ForeignKey('user.id'), nullable=True) _user = db.relationship(User, backref=db.backref('comments', lazy='dynamic', cascade='all')) commentset_id = db.Column(None, db.ForeignKey('commentset.id'), nullable=False) commentset = with_roles( db.relationship(Commentset, backref=db.backref('comments', cascade='all')), grants_via={None: {'document_subscriber'}}, ) in_reply_to_id = db.Column(None, db.ForeignKey('comment.id'), nullable=True) replies = db.relationship('Comment', backref=db.backref('in_reply_to', remote_side='Comment.id')) _message = MarkdownColumn('message', nullable=False) _state = db.Column( 'state', db.Integer, StateManager.check_constraint('state', COMMENT_STATE), default=COMMENT_STATE.PUBLIC, nullable=False, ) state = StateManager('_state', COMMENT_STATE, doc="Current state of the comment.") voteset_id = db.Column(None, db.ForeignKey('voteset.id'), nullable=False) voteset = db.relationship(Voteset, uselist=False) edited_at = with_roles( db.Column(db.TIMESTAMP(timezone=True), nullable=True), read={'all'}, datasets={'primary', 'related', 'json'}, ) __roles__ = { 'all': { 'read': {'created_at', 'urls', 'uuid_b58'}, 'call': {'state', 'commentset', 'view_for', 'url_for'}, }, 'replied_to_commenter': { 'granted_via': { 'in_reply_to': '_user' } }, } __datasets__ = { 'primary': {'created_at', 'urls', 'uuid_b58'}, 'related': {'created_at', 'urls', 'uuid_b58'}, 'json': {'created_at', 'urls', 'uuid_b58'}, } search_vector = db.deferred( db.Column( TSVectorType( 'message_text', weights={'message_text': 'A'}, regconfig='english', hltext=lambda: Comment.message_html, ), nullable=False, )) __table_args__ = (db.Index('ix_comment_search_vector', 'search_vector', postgresql_using='gin'), ) def __init__(self, **kwargs): super(Comment, self).__init__(**kwargs) self.voteset = Voteset(settype=SET_TYPE.COMMENT) @with_roles(read={'all'}, datasets={'related', 'json'}) @property def current_access_replies(self): return [ reply.current_access(datasets=('json', 'related')) for reply in self.replies if reply.state.PUBLIC ] @hybrid_property def user(self): return (deleted_user if self.state.DELETED else removed_user if self.state.SPAM else self._user) @user.setter def user(self, value): self._user = value @user.expression def user(cls): # NOQA: N805 return cls._user with_roles(user, read={'all'}, datasets={'primary', 'related', 'json'}) @hybrid_property def message(self): return (_('[deleted]') if self.state.DELETED else _('[removed]') if self.state.SPAM else self._message) @message.setter def message(self, value): self._message = value @message.expression def message(cls): # NOQA: N805 return cls._message with_roles(message, read={'all'}, datasets={'primary', 'related', 'json'}) @with_roles(read={'all'}, datasets={'primary', 'related', 'json'}) @property def absolute_url(self): return self.url_for() @with_roles(read={'all'}, datasets={'primary', 'related', 'json'}) @property def title(self): obj = self.commentset.parent if obj: return _("{user} commented on {obj}").format( user=self.user.pickername, obj=obj.title) else: return _("{user} commented").format(user=self.user.pickername) @with_roles(read={'all'}, datasets={'related', 'json'}) @property def badges(self): badges = set() if self.commentset.project is not None: if 'crew' in self.commentset.project.roles_for(self._user): badges.add(_("Crew")) elif self.commentset.proposal is not None: if self.commentset.proposal.user == self._user: badges.add(_("Proposer")) if 'crew' in self.commentset.proposal.project.roles_for( self._user): badges.add(_("Crew")) return badges @state.transition(None, state.DELETED) def delete(self): """ Delete this comment. """ if len(self.replies) > 0: self.user = None self.message = '' else: if self.in_reply_to and self.in_reply_to.state.DELETED: # If the comment this is replying to is deleted, ask it to reconsider # removing itself in_reply_to = self.in_reply_to in_reply_to.replies.remove(self) db.session.delete(self) in_reply_to.delete() else: db.session.delete(self) @state.transition(None, state.SPAM) def mark_spam(self): """ Mark this comment as spam. """ @state.transition(state.SPAM, state.PUBLIC) def mark_not_spam(self): """ Mark this comment as not a spam. """ def sorted_replies(self): return sorted(self.replies, key=lambda comment: comment.voteset.count) def permissions(self, user, inherited=None): perms = super(Comment, self).permissions(user, inherited) perms.add('view') if user is not None: perms.add('vote_comment') if user == self._user: perms.add('edit_comment') perms.add('delete_comment') return perms def roles_for(self, actor=None, anchors=()): roles = super(Comment, self).roles_for(actor, anchors) roles.add('reader') if actor is not None: if actor == self._user: roles.add('author') return roles
class AuthClient(ScopeMixin, UuidMixin, BaseMixin, db.Model): """OAuth client applications""" __tablename__ = 'auth_client' __scope_null_allowed__ = True #: User who owns this client user_id = db.Column(None, db.ForeignKey('user.id'), nullable=True) user = with_roles( db.relationship( User, primaryjoin=user_id == User.id, backref=db.backref('clients', cascade='all'), ), read={'all'}, write={'owner'}, grants={'owner'}, ) #: Organization that owns this client. Only one of this or user must be set organization_id = db.Column(None, db.ForeignKey('organization.id'), nullable=True) organization = with_roles( db.relationship( Organization, primaryjoin=organization_id == Organization.id, backref=db.backref('clients', cascade='all'), ), read={'all'}, write={'owner'}, grants_via={None: { 'owner': 'owner', 'admin': 'owner' }}, ) #: Human-readable title title = with_roles(db.Column(db.Unicode(250), nullable=False), read={'all'}, write={'owner'}) #: Long description description = with_roles( db.Column(db.UnicodeText, nullable=False, default=''), read={'all'}, write={'owner'}, ) #: Confidential or public client? Public has no secret key confidential = with_roles(db.Column(db.Boolean, nullable=False), read={'all'}, write={'owner'}) #: Website website = with_roles(db.Column(db.UnicodeText, nullable=False), read={'all'}, write={'owner'}) # TODO: Remove namespace as resources are deprecated #: Namespace: determines inter-app resource access namespace = with_roles( db.Column(db.UnicodeText, nullable=True, unique=True), read={'all'}, write={'owner'}, ) #: Redirect URIs (one or more) _redirect_uris = db.Column('redirect_uri', db.UnicodeText, nullable=True, default='') #: Back-end notification URI notification_uri = with_roles(db.Column(db.UnicodeText, nullable=True, default=''), rw={'owner'}) #: Active flag active = db.Column(db.Boolean, nullable=False, default=True) #: Allow anyone to login to this app? allow_any_login = with_roles( db.Column(db.Boolean, nullable=False, default=True), read={'all'}, write={'owner'}, ) #: Trusted flag: trusted clients are authorized to access user data #: without user consent, but the user must still login and identify themself. #: When a single provider provides multiple services, each can be declared #: as a trusted client to provide single sign-in across the services. #: However, resources in the scope column (via ScopeMixin) are granted for #: any arbitrary user without explicit user authorization. trusted = with_roles(db.Column(db.Boolean, nullable=False, default=False), read={'all'}) user_sessions = db.relationship( UserSession, lazy='dynamic', secondary=auth_client_user_session, backref=db.backref('auth_clients', lazy='dynamic'), ) __table_args__ = (db.CheckConstraint( db.case([(user_id.isnot(None), 1)], else_=0) + db.case([(organization_id.isnot(None), 1)], else_=0) == 1, name='auth_client_owner_check', ), )
class Project(UuidMixin, BaseScopedNameMixin, db.Model): __tablename__ = 'project' reserved_names = RESERVED_NAMES user_id = db.Column(None, db.ForeignKey('user.id'), nullable=False) user = db.relationship( User, primaryjoin=user_id == User.id, backref=db.backref('projects', cascade='all'), ) profile_id = db.Column(None, db.ForeignKey('profile.id'), nullable=False) profile = with_roles( db.relationship('Profile', backref=db.backref('projects', cascade='all', lazy='dynamic')), read={'all'}, # If profile grants an 'admin' role, make it 'profile_admin' here grants_via={None: { 'admin': 'profile_admin' }}, # `profile` only appears in the 'primary' dataset. It must not be included in # 'related' or 'without_parent' as it is the parent datasets={'primary'}, ) parent = db.synonym('profile') tagline = with_roles( db.Column(db.Unicode(250), nullable=False), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) description = with_roles(MarkdownColumn('description', default='', nullable=False), read={'all'}) instructions = with_roles(MarkdownColumn('instructions', default='', nullable=True), read={'all'}) location = with_roles( db.Column(db.Unicode(50), default='', nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) parsed_location = db.Column(JsonDict, nullable=False, server_default='{}') website = with_roles( db.Column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) timezone = with_roles( db.Column(TimezoneType(backend='pytz'), nullable=False, default=utc), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) _state = db.Column( 'state', db.Integer, StateManager.check_constraint('state', PROJECT_STATE), default=PROJECT_STATE.DRAFT, nullable=False, ) state = with_roles(StateManager('_state', PROJECT_STATE, doc="Project state"), call={'all'}) _cfp_state = db.Column( 'cfp_state', db.Integer, StateManager.check_constraint('cfp_state', CFP_STATE), default=CFP_STATE.NONE, nullable=False, ) cfp_state = with_roles(StateManager('_cfp_state', CFP_STATE, doc="CfP state"), call={'all'}) _schedule_state = db.Column( 'schedule_state', db.Integer, StateManager.check_constraint('schedule_state', SCHEDULE_STATE), default=SCHEDULE_STATE.DRAFT, nullable=False, ) schedule_state = with_roles( StateManager('_schedule_state', SCHEDULE_STATE, doc="Schedule state"), call={'all'}, ) cfp_start_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True) cfp_end_at = db.Column(db.TIMESTAMP(timezone=True), nullable=True) bg_image = with_roles( db.Column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) allow_rsvp = db.Column(db.Boolean, default=False, nullable=False) buy_tickets_url = db.Column(UrlType, nullable=True) banner_video_url = with_roles( db.Column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, ) boxoffice_data = with_roles( db.Column(JsonDict, nullable=False, server_default='{}'), # This is an attribute, but we deliberately use `call` instead of `read` to # block this from dictionary enumeration. FIXME: Break up this dictionary into # individual columns with `all` access for ticket embed id and `concierge` # access for ticket sync access token. call={'all'}, ) hasjob_embed_url = with_roles(db.Column(UrlType, nullable=True), read={'all'}) hasjob_embed_limit = with_roles(db.Column(db.Integer, default=8), read={'all'}) voteset_id = db.Column(None, db.ForeignKey('voteset.id'), nullable=False) voteset = db.relationship(Voteset, uselist=False) commentset_id = db.Column(None, db.ForeignKey('commentset.id'), nullable=False) commentset = db.relationship( Commentset, uselist=False, cascade='all', single_parent=True, back_populates='project', ) parent_id = db.Column(None, db.ForeignKey('project.id', ondelete='SET NULL'), nullable=True) parent_project = db.relationship('Project', remote_side='Project.id', backref='subprojects') #: Featured project flag. This can only be set by website editors, not #: project editors or profile admins. featured = with_roles( db.Column(db.Boolean, default=False, nullable=False), read={'all'}, write={'site_editor'}, datasets={'primary', 'without_parent'}, ) search_vector = db.deferred( db.Column( TSVectorType( 'name', 'title', 'description_text', 'instructions_text', 'location', weights={ 'name': 'A', 'title': 'A', 'description_text': 'B', 'instructions_text': 'B', 'location': 'C', }, regconfig='english', hltext=lambda: db.func.concat_ws( visual_field_delimiter, Project.title, Project.location, Project.description_html, Project.instructions_html, ), ), nullable=False, )) livestream_urls = with_roles( db.Column(db.ARRAY(db.UnicodeText, dimensions=1), server_default='{}'), read={'all'}, datasets={'primary', 'without_parent'}, ) venues = with_roles( db.relationship( 'Venue', cascade='all', order_by='Venue.seq', collection_class=ordering_list('seq', count_from=1), ), read={'all'}, ) labels = db.relationship( 'Label', cascade='all', primaryjoin= 'and_(Label.project_id == Project.id, Label.main_label_id == None, Label._archived == False)', order_by='Label.seq', collection_class=ordering_list('seq', count_from=1), ) all_labels = db.relationship('Label', lazy='dynamic') __table_args__ = ( db.UniqueConstraint('profile_id', 'name'), db.Index('ix_project_search_vector', 'search_vector', postgresql_using='gin'), ) __roles__ = { 'all': { 'read': { 'absolute_url', # From UrlForMixin 'name', # From BaseScopedNameMixin 'short_title', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin 'urls', # From UrlForMixin }, 'call': { 'features', # From RegistryMixin 'forms', # From RegistryMixin 'url_for', # From UrlForMixin 'view_for', # From UrlForMixin 'views', # From RegistryMixin }, }, } __datasets__ = { 'primary': { 'absolute_url', # From UrlForMixin 'name', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin 'urls', # From UrlForMixin }, 'without_parent': { 'absolute_url', # From UrlForMixin 'name', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin }, 'related': { 'absolute_url', # From UrlForMixin 'name', # From BaseScopedNameMixin 'title', # From BaseScopedNameMixin }, } schedule_state.add_conditional_state( 'PAST', schedule_state.PUBLISHED, lambda project: project.schedule_end_at is not None and utcnow() >= project.schedule_end_at, lambda project: db.func.utcnow() >= project.schedule_end_at, label=('past', __("Past")), ) schedule_state.add_conditional_state( 'LIVE', schedule_state.PUBLISHED, lambda project: (project.schedule_start_at is not None and project.schedule_start_at <= utcnow() < project.schedule_end_at), lambda project: db.and_( project.schedule_start_at <= db.func.utcnow(), db.func.utcnow() < project.schedule_end_at, ), label=('live', __("Live")), ) schedule_state.add_conditional_state( 'UPCOMING', schedule_state.PUBLISHED, lambda project: project.schedule_start_at is not None and utcnow() < project.schedule_start_at, lambda project: db.func.utcnow() < project.schedule_start_at, label=('upcoming', __("Upcoming")), ) schedule_state.add_conditional_state( 'PUBLISHED_WITHOUT_SESSIONS', schedule_state.PUBLISHED, lambda project: project.schedule_start_at is None, lambda project: project.schedule_start_at.is_(None), label=('published_without_sessions', __("Published without sessions")), ) cfp_state.add_conditional_state( 'HAS_PROPOSALS', cfp_state.EXISTS, lambda project: db.session.query(project.proposals.exists()).scalar(), label=('has_proposals', __("Has proposals")), ) cfp_state.add_conditional_state( 'HAS_SESSIONS', cfp_state.EXISTS, lambda project: db.session.query(project.sessions.exists()).scalar(), label=('has_sessions', __("Has sessions")), ) cfp_state.add_conditional_state( 'PRIVATE_DRAFT', cfp_state.NONE, lambda project: project.instructions_html != '', lambda project: db.and_(project.instructions_html.isnot(None), project. instructions_html != ''), label=('private_draft', __("Private draft")), ) cfp_state.add_conditional_state( 'DRAFT', cfp_state.PUBLIC, lambda project: project.cfp_start_at is None, lambda project: project.cfp_start_at.is_(None), label=('draft', __("Draft")), ) cfp_state.add_conditional_state( 'UPCOMING', cfp_state.PUBLIC, lambda project: project.cfp_start_at is not None and utcnow() < project .cfp_start_at, lambda project: db.and_(project.cfp_start_at.isnot(None), db.func.utcnow() < project.cfp_start_at), label=('upcoming', __("Upcoming")), ) cfp_state.add_conditional_state( 'OPEN', cfp_state.PUBLIC, lambda project: project.cfp_start_at is not None and project. cfp_start_at <= utcnow() and (project.cfp_end_at is None or (utcnow() < project.cfp_end_at)), lambda project: db.and_( project.cfp_start_at.isnot(None), project.cfp_start_at <= db.func.utcnow(), db.or_(project.cfp_end_at.is_(None), db.func.utcnow() < project.cfp_end_at), ), label=('open', __("Open")), ) cfp_state.add_conditional_state( 'EXPIRED', cfp_state.PUBLIC, lambda project: project.cfp_end_at is not None and utcnow() >= project. cfp_end_at, lambda project: db.and_(project.cfp_end_at.isnot(None), db.func.utcnow() >= project.cfp_end_at), label=('expired', __("Expired")), ) cfp_state.add_state_group('UNAVAILABLE', cfp_state.CLOSED, cfp_state.EXPIRED) def __init__(self, **kwargs): super(Project, self).__init__(**kwargs) self.voteset = Voteset(settype=SET_TYPE.PROJECT) self.commentset = Commentset(settype=SET_TYPE.PROJECT) # Add the creator as editor and concierge new_membership = ProjectCrewMembership( parent=self, user=self.user, granted_by=self.user, is_editor=True, is_concierge=True, ) db.session.add(new_membership) def __repr__(self): return '<Project %s/%s "%s">' % ( self.profile.name if self.profile else '(none)', self.name, self.title, ) @with_roles(call={'editor'}) @cfp_state.transition( cfp_state.OPENABLE, cfp_state.PUBLIC, title=__("Enable proposal submissions"), message=__("Proposals can be now submitted"), type='success', ) def open_cfp(self): pass @with_roles(call={'editor'}) @cfp_state.transition( cfp_state.PUBLIC, cfp_state.CLOSED, title=__("Disable proposal submissions"), message=__("Proposals will no longer be accepted"), type='success', ) def close_cfp(self): pass @with_roles(call={'editor'}) @schedule_state.transition( schedule_state.DRAFT, schedule_state.PUBLISHED, title=__("Publish schedule"), message=__("The schedule has been published"), type='success', ) def publish_schedule(self): pass @with_roles(call={'editor'}) @schedule_state.transition( schedule_state.PUBLISHED, schedule_state.DRAFT, title=__("Unpublish schedule"), message=__("The schedule has been moved to draft state"), type='success', ) def unpublish_schedule(self): pass @with_roles(call={'editor'}) @state.transition( state.PUBLISHABLE, state.PUBLISHED, title=__("Publish project"), message=__("The project has been published"), type='success', ) def publish(self): pass @with_roles(call={'editor'}) @state.transition( state.PUBLISHED, state.WITHDRAWN, title=__("Withdraw project"), message=__("The project has been withdrawn and is no longer listed"), type='success', ) def withdraw(self): pass @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @property def title_inline(self): """Suffix a colon if the title does not end in ASCII sentence punctuation""" if self.title and self.tagline: if not self.title[-1] in ('?', '!', ':', ';', '.', ','): return self.title + ':' return self.title @with_roles(read={'all'}) @property def title_suffix(self): """ Return the profile's title if the project's title doesn't derive from it. Used in HTML title tags to render <title>{{ project }} - {{ suffix }}</title>. """ if not self.title.startswith(self.parent.title): return self.profile.title return '' @with_roles(call={'all'}) def joined_title(self, sep='›'): """Return the project's title joined with the profile's title, if divergent.""" if self.short_title == self.title: # Project title does not derive from profile title, so use both return f"{self.profile.title} {sep} {self.title}" # Project title extends profile title, so profile title is not needed return self.title @with_roles(read={'all'}, datasets={'primary', 'without_parent', 'related'}) @cached_property def datelocation(self): """ Returns a date + location string for the event, the format depends on project dates If it's a single day event > 11 Feb 2018, Bangalore If multi-day event in same month > 09–12 Feb 2018, Bangalore If multi-day event across months > 27 Feb–02 Mar 2018, Bangalore If multi-day event across years > 30 Dec 2018–02 Jan 2019, Bangalore ``datelocation_format`` always keeps ``schedule_end_at`` format as ``–DD Mmm YYYY``. Depending on the scenario mentioned below, format for ``schedule_start_at`` changes. Above examples demonstrate the same. All the possible outputs end with ``–DD Mmm YYYY, Venue``. Only ``schedule_start_at`` format changes. """ daterange = '' if self.schedule_start_at is not None and self.schedule_end_at is not None: schedule_start_at_date = self.schedule_start_at_localized.date() schedule_end_at_date = self.schedule_end_at_localized.date() daterange_format = '{start_date}–{end_date} {year}' if schedule_start_at_date == schedule_end_at_date: # if both dates are same, in case of single day project strf_date = '' daterange_format = '{end_date} {year}' elif schedule_start_at_date.year != schedule_end_at_date.year: # if the start date and end dates are in different years, strf_date = '%d %b %Y' elif schedule_start_at_date.month != schedule_end_at_date.month: # If multi-day event across months strf_date = '%d %b' elif schedule_start_at_date.month == schedule_end_at_date.month: # If multi-day event in same month strf_date = '%d' daterange = daterange_format.format( start_date=schedule_start_at_date.strftime(strf_date), end_date=schedule_end_at_date.strftime('%d %b'), year=schedule_end_at_date.year, ) return ', '.join([_f for _f in [daterange, self.location] if _f]) # TODO: Removing Delete feature till we figure out siteadmin feature # @with_roles(call={'editor'}) # @state.transition( # state.DELETABLE, state.DELETED, title=__("Delete project"), # message=__("The project has been deleted"), type='success') # def delete(self): # pass @db.validates('name', 'profile') def _validate_and_create_redirect(self, key, value): # TODO: When labels, venues and other resources are relocated from project to # profile, this validator can no longer watch profile change. We'll need a more # elaborate transfer mechanism that remaps resources to equivalent ones in the # new profile. if key == 'name': value = value.strip() if value is not None else None if not value or (key == 'name' and not valid_name(value)): raise ValueError(f"Invalid value for {key}: {value!r}") existing_value = getattr(self, key) if value != existing_value and existing_value is not None: ProjectRedirect.add(self) return value @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property def cfp_start_at_localized(self): return (localize_timezone(self.cfp_start_at, tz=self.timezone) if self.cfp_start_at else None) @with_roles(read={'all'}, datasets={'primary', 'without_parent'}) @cached_property def cfp_end_at_localized(self): return (localize_timezone(self.cfp_end_at, tz=self.timezone) if self.cfp_end_at else None) @cached_property def location_geonameid(self): return geonameid_from_location( self.location) if self.location else set() def permissions(self, user, inherited=None): # TODO: Remove permission system entirely perms = super(Project, self).permissions(user, inherited) perms.add('view') if user is not None: if self.cfp_state.OPEN: perms.add('new-proposal') if 'editor' in self.roles_for(user): perms.update(( 'view_contactinfo', 'edit_project', 'delete-project', 'confirm-proposal', 'view-venue', 'new-venue', 'edit-venue', 'delete-venue', 'edit-schedule', 'move-proposal', 'view_rsvps', 'new-session', 'edit-session', 'new-event', 'new-ticket-type', 'new_ticket_client', 'edit_ticket_client', 'delete_ticket_client', 'edit_event', 'delete_event', 'admin', 'checkin_event', 'view-event', 'view_ticket_type', 'delete_ticket_type', 'edit-participant', 'view-participant', 'new-participant', 'view_contactinfo', 'confirm-proposal', 'view_voteinfo', 'view_status', 'delete-proposal', 'edit-schedule', 'new-session', 'edit-session', 'view-event', 'view_ticket_type', 'edit-participant', 'view-participant', 'new-participant', )) if 'usher' in self.roles_for(user): perms.add('checkin_event') return perms def roles_for(self, actor=None, anchors=()): roles = super().roles_for(actor, anchors) # https://github.com/hasgeek/funnel/pull/220#discussion_r168718052 roles.add('reader') return roles @classmethod def all_unsorted(cls, legacy=None): """ Return currently active events, not sorted. """ projects = cls.query.outerjoin(Venue).filter(cls.state.PUBLISHED) if legacy is not None: projects = projects.join(Profile).filter(Profile.legacy == legacy) return projects @classmethod # NOQA: A003 def all(cls, legacy=None): # NOQA: A003 """ Return currently active events, sorted by date. """ return cls.all_unsorted(legacy).order_by(cls.schedule_start_at.desc()) @classmethod def fetch_sorted(cls, legacy=None): currently_listed_projects = cls.query.filter_by( parent_project=None).filter(cls.state.PUBLISHED) if legacy is not None: currently_listed_projects = currently_listed_projects.join( Profile).filter(Profile.legacy == legacy) currently_listed_projects = currently_listed_projects.order_by( cls.schedule_start_at.desc()) return currently_listed_projects @classmethod def get(cls, profile_project): """Get a project by its URL slug in the form ``<profile>/<project>``.""" profile_name, project_name = profile_project.split('/') return (cls.query.join(Profile).filter( Profile.name == profile_name, Project.name == project_name).one_or_none()) @classmethod def migrate_profile(cls, old_profile, new_profile): names = {project.name for project in new_profile.projects} for project in old_profile.projects: if project.name in names: current_app.logger.warning( "Project %r had a conflicting name in profile migration, " "so renaming by adding adding random value to name", project, ) project.name += '-' + buid() project.profile = new_profile
class OrganizationMembership(ImmutableMembershipMixin, db.Model): """ A user can be an administrator of an organization and optionally an owner. Owners can manage other administrators. This model may introduce non-admin memberships in a future iteration by replacing :attr:`is_owner` with :attr:`member_level` or distinct role flags as in :class:`ProjectMembership`. """ __tablename__ = 'organization_membership' # List of role columns in this model __data_columns__ = ('is_owner', ) __roles__ = { 'all': { 'read': {'urls', 'user', 'is_owner', 'organization'} }, 'profile_admin': { 'read': { 'record_type', 'granted_at', 'granted_by', 'revoked_at', 'revoked_by', 'user', 'is_active', 'is_invite', } }, } __datasets__ = { 'primary': { 'urls', 'uuid_b58', 'offered_roles', 'is_owner', 'user', 'organization', }, 'without_parent': {'urls', 'uuid_b58', 'offered_roles', 'is_owner', 'user'}, 'related': {'urls', 'uuid_b58', 'offered_roles', 'is_owner'}, } #: Organization that this membership is being granted on organization_id = immutable( db.Column(None, db.ForeignKey('organization.id', ondelete='CASCADE'), nullable=False)) organization = immutable( with_roles( db.relationship( Organization, backref=db.backref('memberships', lazy='dynamic', cascade='all', passive_deletes=True), ), grants_via={ None: { 'admin': 'profile_admin', 'owner': 'profile_owner' } }, )) parent = immutable(db.synonym('organization')) parent_id = immutable(db.synonym('organization_id')) # Organization roles: is_owner = immutable(db.Column(db.Boolean, nullable=False, default=False)) @cached_property def offered_roles(self): """Roles offered by this membership record""" roles = {'admin'} if self.is_owner: roles.add('owner') return roles