diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 768962e6f..ee3791cd5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: '--remove-duplicate-keys', ] - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.7.0 + rev: v5.8.0 hooks: - id: isort additional_dependencies: @@ -56,12 +56,12 @@ repos: - id: requirements-txt-fixer - id: trailing-whitespace - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.790 + rev: v0.812 hooks: - id: mypy additional_dependencies: [sqlalchemy-stubs==0.4] - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 + rev: 3.9.1 hooks: - id: flake8 additional_dependencies: diff --git a/funnel/models/__init__.py b/funnel/models/__init__.py index 7ea2f15cd..514ca25b2 100644 --- a/funnel/models/__init__.py +++ b/funnel/models/__init__.py @@ -50,6 +50,7 @@ from .membership_mixin import * # isort:skip from .organization_membership import * # isort:skip from .project_membership import * # isort:skip +from .sponsor_membership import * # isort:skip from .proposal_membership import * # isort:skip from .site_membership import * # isort:skip from .moderation import * # isort:skip diff --git a/funnel/models/commentset_membership.py b/funnel/models/commentset_membership.py index bcf0c7bb0..03c041934 100644 --- a/funnel/models/commentset_membership.py +++ b/funnel/models/commentset_membership.py @@ -1,3 +1,5 @@ +from typing import Set + from werkzeug.utils import cached_property from coaster.sqlalchemy import immutable, with_roles @@ -5,12 +7,12 @@ from . import User, db from .commentvote import Commentset from .helpers import reopen -from .membership_mixin import ImmutableMembershipMixin +from .membership_mixin import ImmutableUserMembershipMixin __all__ = ['CommentsetMembership'] -class CommentsetMembership(ImmutableMembershipMixin, db.Model): +class CommentsetMembership(ImmutableUserMembershipMixin, db.Model): """Membership roles for users who are commentset users and subscribers.""" __tablename__ = 'commentset_membership' @@ -57,7 +59,7 @@ class CommentsetMembership(ImmutableMembershipMixin, db.Model): ) @cached_property - def offered_roles(self): + def offered_roles(self) -> Set[str]: """ Roles offered by this membership record. diff --git a/funnel/models/membership_mixin.py b/funnel/models/membership_mixin.py index 72588f3a5..b1f146c16 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -15,6 +15,7 @@ from ..typing import OptionalMigratedTables from . import BaseMixin, UuidMixin, db +from .profile import Profile from .user import User __all__ = [ @@ -53,6 +54,8 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): """Support class for immutable memberships.""" __uuid_primary_key__ = True + #: Can granted_by be null? Only in memberships based on legacy data + __null_granted_by__ = False #: List of columns that will be copied into a new row when a membership is amended __data_columns__: Iterable[str] = () #: Parent column (declare as synonym of 'profile_id' or 'project_id' in subclasses) @@ -74,7 +77,7 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): read={'subject', 'editor'}, ) #: Record type - record_type = immutable( + record_type: db.Column = immutable( with_roles( db.Column( db.Integer, @@ -86,23 +89,6 @@ class ImmutableMembershipMixin(UuidMixin, BaseMixin): ) ) - # mypy type declaration - user_id: db.Column - - @declared_attr # type: ignore[no-redef] - def user_id(cls): # skipcq: PYL-E0102 - 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 immutable(db.relationship(User, foreign_keys=[cls.user_id])) - @declared_attr def revoked_by_id(cls): """Id of user who revoked the membership.""" @@ -125,7 +111,9 @@ def granted_by_id(cls): for granted_by. """ return db.Column( - None, db.ForeignKey('user.id', ondelete='SET NULL'), nullable=True + None, + db.ForeignKey('user.id', ondelete='SET NULL'), + nullable=cls.__null_granted_by__, ) @with_roles(read={'subject', 'editor'}, grants={'editor'}) @@ -154,42 +142,8 @@ def is_active(cls): # NOQA: N805 def is_invite(self) -> bool: return self.record_type == MEMBERSHIP_RECORD_TYPE.INVITE - @with_roles(read={'subject', 'editor'}) - @hybrid_property - def is_self_granted(self) -> bool: - """Return True if the subject of this record is also the granting actor.""" - return self.user_id == self.granted_by_id - - @with_roles(read={'subject', 'editor'}) - @hybrid_property - def is_self_revoked(self) -> bool: - """Return True if the subject of this record is also the revoking actor.""" - return self.user_id == self.revoked_by_id - - @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) -> Set: + def offered_roles(self) -> Set[str]: """Return roles offered by this membership record.""" return set() @@ -205,9 +159,13 @@ def revoke(self, actor: User) -> None: self.revoked_at = db.func.utcnow() self.revoked_by = actor + def copy_template(self: MembershipType, **kwargs) -> MembershipType: + """Make a copy of self for customization.""" + raise NotImplementedError("Subclasses must implement copy_template") + @with_roles(call={'editor'}) def replace( - self, actor: User, accept=False, **roles: Dict[str, Any] + self: MembershipType, actor: User, accept=False, **roles: Dict[str, Any] ) -> MembershipType: """Replace this membership record with changes to role columns.""" if self.revoked_at is not None: @@ -237,9 +195,7 @@ def replace( 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 - ) + new = self.copy_template(parent_id=self.parent_id, granted_by=self.granted_by) # if existing record type is INVITE, then ACCEPT or amend as new INVITE # else replace it with AMEND @@ -259,7 +215,9 @@ def replace( db.session.add(new) return new - def merge_and_replace(self, actor: User, other: MembershipType) -> MembershipType: + def merge_and_replace( + self: MembershipType, actor: User, other: MembershipType + ) -> MembershipType: """Replace this record by merging roles from an independent record.""" if self.__class__ is not other.__class__: raise TypeError("Merger requires membership records of the same type") @@ -293,14 +251,72 @@ def merge_and_replace(self, actor: User, other: MembershipType) -> MembershipTyp return replacement @with_roles(call={'subject'}) - def accept(self, actor: User) -> MembershipType: + def accept(self: MembershipType, actor: User) -> MembershipType: """Accept a membership invitation.""" if self.record_type != MEMBERSHIP_RECORD_TYPE.INVITE: raise MembershipRecordTypeError("This membership record is not an invite") - if actor != self.user: + if 'subject' not in self.roles_for(actor): raise ValueError("Invite must be accepted by the invited user") return self.replace(actor, accept=True) + +class ImmutableUserMembershipMixin(ImmutableMembershipMixin): + """Support class for immutable memberships for users.""" + + # mypy type declaration + user_id: db.Column + + @declared_attr # type: ignore[no-redef] + def user_id(cls): # skipcq: PYL-E0102 + 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 immutable(db.relationship(User, foreign_keys=[cls.user_id])) + + @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'), + ), + ) + + @with_roles(read={'subject', 'editor'}) + @hybrid_property + def is_self_granted(self) -> bool: + """Return True if the subject of this record is also the granting actor.""" + return self.user_id == self.granted_by_id + + @with_roles(read={'subject', 'editor'}) + @hybrid_property + def is_self_revoked(self) -> bool: + """Return True if the subject of this record is also the revoking actor.""" + return self.user_id == self.revoked_by_id + + def copy_template(self: MembershipType, **kwargs) -> MembershipType: + return type(self)(user=self.user, **kwargs) + @classmethod def migrate_user(cls, old_user: User, new_user: User) -> OptionalMigratedTables: """ @@ -343,5 +359,117 @@ def migrate_user(cls, old_user: User, new_user: User) -> OptionalMigratedTables: cls.query.filter(cls.user == old_user).update( {'user_id': new_user.id}, synchronize_session=False ) + # Also update the revoked_by and granted_by user accounts + cls.query.filter(cls.revoked_by == old_user).update( + {'revoked_by_id': new_user.id}, synchronize_session=False + ) + cls.query.filter(cls.granted_by == old_user).update( + {'granted_by_id': new_user.id}, synchronize_session=False + ) + db.session.flush() + return None + + +class ImmutableProfileMembershipMixin(ImmutableMembershipMixin): + """Support class for immutable memberships for profiles.""" + + # mypy type declaration + profile_id: db.Column + + @declared_attr # type: ignore[no-redef] + def profile_id(cls): # skipcq: PYL-E0102 + return db.Column( + None, + db.ForeignKey('profile.id', ondelete='CASCADE'), + nullable=False, + index=True, + ) + + @declared_attr + def __table_args__(cls): + if cls.parent_id is not None: + return ( + db.Index( + 'ix_' + cls.__tablename__ + '_active', + cls.parent_id.name, + 'profile_id', + unique=True, + postgresql_where=db.text('revoked_at IS NULL'), + ), + ) + else: + return ( + db.Index( + 'ix_' + cls.__tablename__ + '_active', + 'profile_id', + unique=True, + postgresql_where=db.text('revoked_at IS NULL'), + ), + ) + + @with_roles(read={'subject', 'editor'}) + @hybrid_property + def is_self_granted(self) -> bool: + """Return True if the subject of this record is also the granting actor.""" + return 'subject' in self.roles_for(self.granted_by) + + @with_roles(read={'subject', 'editor'}) + @hybrid_property + def is_self_revoked(self) -> bool: + """Return True if the subject of this record is also the revoking actor.""" + return 'subject' in self.roles_for(self.revoked_by) + + def copy_template(self: MembershipType, **kwargs) -> MembershipType: + return type(self)(profile=self.profile, **kwargs) + + @with_roles(read={'subject', 'editor'}, grants_via={None: {'admin': 'subject'}}) + @declared_attr + def profile(cls): + return immutable(db.relationship(Profile, foreign_keys=[cls.profile_id])) + + @classmethod + def migrate_profile( + cls, old_profile: Profile, new_profile: Profile + ) -> OptionalMigratedTables: + """ + Migrate memberhip records from one profile to another. + + If both profiles have active records, they are merged into a new record in the + new profile's favour. All revoked records for the old profile are transferred to + the new profile. + """ + # Look up all active membership records of the subclass's type for the old + # profile. `cls` here represents the subclass. + old_profile_records = cls.query.filter( + cls.profile == old_profile, cls.revoked_at.is_(None) + ).all() + # Look up all conflicting memberships for the new profile. Limit lookups by + # parent except when the membership type doesn't have a parent. + if cls.parent_id is not None: + new_profile_records = cls.query.filter( + cls.profile == new_profile, + cls.revoked_at.is_(None), + cls.parent_id.in_([r.parent_id for r in old_profile_records]), + ).all() + else: + new_profile_records = cls.query.filter( + cls.profile == new_profile, + cls.revoked_at.is_(None), + ).all() + new_profile_records_by_parent = {r.parent_id: r for r in new_profile_records} + + for record in old_profile_records: + if record.parent_id in new_profile_records_by_parent: + # Where there is a conflict, merge the records + new_profile_records_by_parent[record.parent_id].merge_and_replace( + new_profile, record + ) + db.session.flush() + + # Transfer all revoked records and non-conflicting active records. At this point + # no filter is necessary as the conflicting records have all been merged. + cls.query.filter(cls.profile == old_profile).update( + {'profile_id': new_profile.id}, synchronize_session=False + ) db.session.flush() return None diff --git a/funnel/models/organization_membership.py b/funnel/models/organization_membership.py index d2b005083..9a39d920c 100644 --- a/funnel/models/organization_membership.py +++ b/funnel/models/organization_membership.py @@ -1,16 +1,18 @@ +from typing import Set + from werkzeug.utils import cached_property from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles from . import db from .helpers import reopen -from .membership_mixin import ImmutableMembershipMixin +from .membership_mixin import ImmutableUserMembershipMixin from .user import Organization, User __all__ = ['OrganizationMembership'] -class OrganizationMembership(ImmutableMembershipMixin, db.Model): +class OrganizationMembership(ImmutableUserMembershipMixin, db.Model): """ A user can be an administrator of an organization and optionally an owner. @@ -21,7 +23,10 @@ class OrganizationMembership(ImmutableMembershipMixin, db.Model): __tablename__ = 'organization_membership' - # List of role columns in this model + # Legacy data has no granted_by + __null_granted_by__ = True + + #: List of role columns in this model __data_columns__ = ('is_owner',) __roles__ = { @@ -78,7 +83,7 @@ class OrganizationMembership(ImmutableMembershipMixin, db.Model): is_owner = immutable(db.Column(db.Boolean, nullable=False, default=False)) @cached_property - def offered_roles(self): + def offered_roles(self) -> Set[str]: """Roles offered by this membership record.""" roles = {'admin'} if self.is_owner: diff --git a/funnel/models/project_membership.py b/funnel/models/project_membership.py index 68c01eb13..bd2a8a0f3 100644 --- a/funnel/models/project_membership.py +++ b/funnel/models/project_membership.py @@ -8,7 +8,7 @@ from . import db from .helpers import reopen -from .membership_mixin import ImmutableMembershipMixin +from .membership_mixin import ImmutableUserMembershipMixin from .project import Project from .user import User @@ -24,12 +24,15 @@ } -class ProjectCrewMembership(ImmutableMembershipMixin, db.Model): +class ProjectCrewMembership(ImmutableUserMembershipMixin, db.Model): """Users can be crew members of projects, with specified access rights.""" __tablename__ = 'project_crew_membership' - # List of is_role columns in this model + #: Legacy data has no granted_by + __null_granted_by__ = True + + #: List of is_role columns in this model __data_columns__ = ('is_editor', 'is_promoter', 'is_usher') __roles__ = { @@ -110,7 +113,7 @@ def __table_args__(cls): return tuple(args) @cached_property - def offered_roles(self): + def offered_roles(self) -> Set[str]: """Roles offered by this membership record.""" roles = set() if self.is_editor: diff --git a/funnel/models/proposal_membership.py b/funnel/models/proposal_membership.py index 7761bc351..4742a6111 100644 --- a/funnel/models/proposal_membership.py +++ b/funnel/models/proposal_membership.py @@ -1,3 +1,5 @@ +from typing import Set + from sqlalchemy.ext.declarative import declared_attr from werkzeug.utils import cached_property @@ -6,14 +8,14 @@ from . import db from .helpers import reopen -from .membership_mixin import ImmutableMembershipMixin +from .membership_mixin import ImmutableUserMembershipMixin from .proposal import Proposal from .user import User __all__ = ['ProposalMembership'] -class ProposalMembership(ImmutableMembershipMixin, db.Model): +class ProposalMembership(ImmutableUserMembershipMixin, db.Model): """Users can be presenters or reviewers on proposals.""" __tablename__ = 'proposal_membership' @@ -81,7 +83,7 @@ def __table_args__(cls): return tuple(args) @cached_property - def offered_roles(self): + def offered_roles(self) -> Set[str]: """Roles offered by this membership record.""" roles = set() if self.is_reviewer: diff --git a/funnel/models/reorder_mixin.py b/funnel/models/reorder_mixin.py index 54cccced4..b868138aa 100644 --- a/funnel/models/reorder_mixin.py +++ b/funnel/models/reorder_mixin.py @@ -31,6 +31,18 @@ class ReorderMixin: #: Subclass must offer a SQLAlchemy query (this is standard from base classes) query: Query + @property + def parent_scoped_reorder_query_filter(self: Reorderable): + """ + Return a query filter that includes a scope limitation to the parent. + + Used alongside the :attr:`seq` column to retrieve a sequence value. Subclasses + may need to override if they have additional criteria relative to the parent, + such as needing to exclude revoked membership records. + """ + cls = self.__class__ + return cls.parent_id == self.parent_id + def reorder_item(self: Reorderable, other: Reorderable, before: bool) -> None: """Reorder self before or after other item.""" cls = self.__class__ @@ -60,7 +72,7 @@ def reorder_item(self: Reorderable, other: Reorderable, before: bool) -> None: items_to_reorder = ( cls.query.filter( - cls.parent_id == self.parent_id, + self.parent_scoped_reorder_query_filter, cls.seq >= min(self.seq, other.seq), cls.seq <= max(self.seq, other.seq), ) @@ -82,9 +94,8 @@ def reorder_item(self: Reorderable, other: Reorderable, before: bool) -> None: new_seq_number = self.seq # Temporarily give self an out-of-bounds number - self.seq = db.select( - [db.func.coalesce(db.func.max(cls.seq) + 1, 1)], - cls.parent_id == self.parent_id, + self.seq = db.select([db.func.coalesce(db.func.max(cls.seq) + 1, 1)]).where( + self.parent_scoped_reorder_query_filter ) # Flush it so the db doesn't complain when there's a unique constraint db.session.flush() diff --git a/funnel/models/site_membership.py b/funnel/models/site_membership.py index 4d8d6961a..03e4469b6 100644 --- a/funnel/models/site_membership.py +++ b/funnel/models/site_membership.py @@ -1,15 +1,17 @@ +from typing import Set + from sqlalchemy.ext.declarative import declared_attr from werkzeug.utils import cached_property from . import User, db from .helpers import reopen -from .membership_mixin import ImmutableMembershipMixin +from .membership_mixin import ImmutableUserMembershipMixin __all__ = ['SiteMembership'] -class SiteMembership(ImmutableMembershipMixin, db.Model): +class SiteMembership(ImmutableUserMembershipMixin, db.Model): """Membership roles for users who are site administrators.""" __tablename__ = 'site_membership' @@ -58,7 +60,7 @@ def __table_args__(cls): return tuple(args) @cached_property - def offered_roles(self): + def offered_roles(self) -> Set[str]: """ Roles offered by this membership record. diff --git a/funnel/models/sponsor_membership.py b/funnel/models/sponsor_membership.py new file mode 100644 index 000000000..feaf3af1a --- /dev/null +++ b/funnel/models/sponsor_membership.py @@ -0,0 +1,158 @@ +from typing import Set + +from sqlalchemy.ext.declarative import declared_attr + +from werkzeug.utils import cached_property + +from coaster.sqlalchemy import DynamicAssociationProxy, immutable, with_roles + +from . import db +from .helpers import reopen +from .membership_mixin import MEMBERSHIP_RECORD_TYPE, ImmutableProfileMembershipMixin +from .profile import Profile +from .project import Project +from .reorder_mixin import ReorderMixin + +__all__ = ['SponsorMembership'] + + +class SponsorMembership(ReorderMixin, ImmutableProfileMembershipMixin, db.Model): + """Sponsor of a project.""" + + __tablename__ = 'sponsor_membership' + + # List of data columns in this model that must be copied into revisions + __data_columns__ = ('seq', 'is_promoted', 'label') + + __roles__ = { + 'all': {'read': {'urls', 'profile', 'project', 'is_promoted', 'label', 'seq'}} + } + + project_id = immutable( + db.Column(None, db.ForeignKey('project.id', ondelete='CASCADE'), nullable=False) + ) + project = immutable( + db.relationship( + Project, + backref=db.backref( + 'all_sponsor_memberships', + lazy='dynamic', + cascade='all', + passive_deletes=True, + ), + ) + ) + parent = db.synonym('project') + parent_id = db.synonym('project_id') + + #: Sequence number. Not immutable, and may be overwritten by ReorderMixin as a + #: side-effect of reordering other records. This is not considered a revision. + #: However, it can be argued that relocating a sponsor in the list constitutes a + #: change that must be recorded as a revision. We may need to change our opinion + #: on `seq` being mutable in a future iteration. + seq = db.Column(db.Integer, nullable=False) + + #: Is this sponsor being promoted for commercial reasons? Projects may have a legal + #: obligation to reveal this. This column records a declaration from the project. + is_promoted = immutable(db.Column(db.Boolean, nullable=False)) + + #: Optional label, indicating the type of sponsor + label = immutable(db.Column(db.Unicode, nullable=True)) + + # This model does not offer a large text field for promotional messages, since + # revision control on such a field is a distinct problem from membership + # revisioning. The planned Page model can be used instead, with this model getting + # a page id reference column whenever that model is ready. + + @declared_attr + def __table_args__(cls): + """Table arguments.""" + args = list(super().__table_args__) + # Add unique constraint on :attr:`seq` for active records + args.append( + db.Index( + 'ix_sponsor_membership_seq', + 'project_id', + 'seq', + unique=True, + postgresql_where=db.text('revoked_at IS NULL'), + ), + ) + return tuple(args) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # Assign a default value to `seq` + if self.seq is None: + self.seq = db.select( + [db.func.coalesce(db.func.max(SponsorMembership.seq) + 1, 1)] + ).where(self.parent_scoped_reorder_query_filter) + + @property + def parent_scoped_reorder_query_filter(self): + """ + Return a query filter that includes a scope limitation to active records. + + Used by: + * :meth:`__init__` to assign an initial sequence number, and + * :class:`ReorderMixin` to reassign sequence numbers + """ + cls = self.__class__ + # During __init__, if the constructor only received `project`, it doesn't yet + # know `project_id`. Therefore we have to be prepared for two possible returns + if self.project_id is not None: + return db.and_(cls.project_id == self.project_id, cls.is_active) + return db.and_(cls.project == self.project, cls.is_active) + + @cached_property + def offered_roles(self) -> Set[str]: + """Return empty set as this membership does not offer any roles on Project.""" + return set() + + +@reopen(Project) +class __Project: + sponsor_memberships = with_roles( + db.relationship( + SponsorMembership, + lazy='dynamic', + primaryjoin=db.and_( + SponsorMembership.project_id == Project.id, + SponsorMembership.is_active, + ), + order_by=SponsorMembership.seq, + viewonly=True, + ), + read={'all'}, + ) + + sponsors = DynamicAssociationProxy('sponsor_memberships', 'profile') + + +@reopen(Profile) +class __Profile: + sponsor_memberships = db.relationship( + SponsorMembership, + lazy='dynamic', + primaryjoin=db.and_( + SponsorMembership.profile_id == Profile.id, + SponsorMembership.is_active, + ), + order_by=SponsorMembership.granted_at.desc(), + viewonly=True, + ) + + sponsor_membership_invites = with_roles( + db.relationship( + SponsorMembership, + lazy='dynamic', + primaryjoin=db.and_( + SponsorMembership.profile_id == Profile.id, + SponsorMembership.record_type == MEMBERSHIP_RECORD_TYPE.INVITE, # type: ignore[has-type] + SponsorMembership.is_active, + ), + order_by=SponsorMembership.granted_at.desc(), + viewonly=True, + ), + read={'admin'}, + ) diff --git a/funnel/static/css/app.css b/funnel/static/css/app.css index dabdc622e..3d3a62dd2 100644 --- a/funnel/static/css/app.css +++ b/funnel/static/css/app.css @@ -3632,6 +3632,11 @@ a.loginbutton.hidden, .project-details__box__content--lesspadding { padding: 0; } +.project-banner + .project-details__box + .project-details__box__content--nopadding { + padding: 0 !important; +} .project-banner .project-details__box .card__calendar { padding: 0; } diff --git a/funnel/static/sass/_project.scss b/funnel/static/sass/_project.scss index 9854cf6e4..73b9589e8 100644 --- a/funnel/static/sass/_project.scss +++ b/funnel/static/sass/_project.scss @@ -199,6 +199,9 @@ .project-details__box__content--lesspadding { padding: 0; } + .project-details__box__content--nopadding { + padding: 0 !important; + } .card__calendar { padding: 0; diff --git a/funnel/templates/project_layout.html.jinja2 b/funnel/templates/project_layout.html.jinja2 index 179910aa5..ebbe0dfa7 100644 --- a/funnel/templates/project_layout.html.jinja2 +++ b/funnel/templates/project_layout.html.jinja2 @@ -379,9 +379,9 @@ {{ project_details(project) }} -
+{% trans %}Hosted by{% endtrans %}
{% trans %}Supported by{% endtrans %}
+ {%- endif %} + {% for sponsor in project.sponsor_memberships %} +