Skip to content

Commit

Permalink
Let site editors manage placeholder accounts
Browse files Browse the repository at this point in the history
  • Loading branch information
jace committed Jul 1, 2024
1 parent 158f941 commit a50144a
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 43 deletions.
17 changes: 16 additions & 1 deletion funnel/forms/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,13 @@ def __post_init__(self) -> None:
self.logo_url.profile = self.account.name or self.account.buid
if self.account.is_user_profile:
self.make_for_user()
elif self.account.is_placeholder_profile:
self.make_for_placeholder()
if not self.account.is_verified:
del self.description

def make_for_user(self) -> None:
"""Customise form for a user account."""
"""Customize form for a user account."""
self.title.label.text = __("Your name")
self.title.description = __(
"Your full name, in the form others can recognise you by"
Expand All @@ -83,6 +85,19 @@ def make_for_user(self) -> None:
"Optional – This message will be shown on the account’s page"
)

def make_for_placeholder(self) -> None:
"""Customize form for a placeholder account."""
self.title.label.text = __("Entity name")
self.title.description = __("A common name for this entity")
self.tagline.description = __("A brief statement about this entity")
self.name.description = __(
"A unique word for this entity’s account page. Alphabets, numbers and underscores are okay. Pick something permanent: changing it will break links"
)
self.description.label.text = __("More about this entity")
self.description.description = __(
"Optional – This message will be shown on the account’s page"
)


@Account.forms('transition')
class ProfileTransitionForm(forms.Form):
Expand Down
52 changes: 35 additions & 17 deletions funnel/models/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -1466,21 +1466,6 @@ def default_email(
# This user has no email addresses
return None

@property
def _self_is_owner_of_self(self) -> Account | None:
"""
Return self in a user account.
Helper method for :meth:`roles_for` and :meth:`actors_with` to assert that the
user is owner and admin of their own account.
"""
return self if self.is_user_profile else None

with_roles(
_self_is_owner_of_self,
grants={'follower', 'member', 'admin', 'owner'},
)

def organizations_as_owner_ids(self) -> list[int]:
"""
Return the database ids of the organizations this user is an owner of.
Expand Down Expand Up @@ -2025,6 +2010,21 @@ def __init__(self, **kwargs: Any) -> None:
if self.joined_at is None:
self.joined_at = sa.func.utcnow()

@property
def _self_is_owner_of_self(self) -> Self:
"""
Return self in a user account.
Helper method for :meth:`roles_for` and :meth:`actors_with` to assert that the
user is owner and admin of their own account.
"""
return self

with_roles(
_self_is_owner_of_self,
grants={'follower', 'member', 'admin', 'owner'},
)


# XXX: Deprecated, still here for Baseframe compatibility
Account.userid = Account.uuid_b64
Expand Down Expand Up @@ -2139,7 +2139,8 @@ class Community(Account):
"""
A community account.
Communities differ from organizations in having open-ended membership.
Communities differ from organizations in having open-ended membership. This model
is currently not properly specified and therefore not exposed in UI.
"""

__mapper_args__ = {'polymorphic_identity': 'C'}
Expand All @@ -2157,11 +2158,28 @@ def __init__(self, owner: User, **kwargs: Any) -> None:


class Placeholder(Account):
"""A placeholder account."""
"""
A placeholder account.
Placeholders are managed by site editors, typically on behalf of an external entity.
"""

__mapper_args__ = {'polymorphic_identity': 'P'}
is_placeholder_profile = True

@role_check('owner', 'admin')
def site_editor_owner(
self, actor: Account | None, _anchors: Sequence[Any] = ()
) -> bool:
"""Grant 'owner' and related roles to site editors."""
return actor is not None and actor.is_site_editor

@site_editor_owner.iterable
def _(self) -> Iterable[Account]:
return Account.query.join(
SiteMembership, SiteMembership.member_id == Account.id
).filter(SiteMembership.is_active, Account.state.ACTIVE)


class Team(UuidMixin, BaseMixin[int, Account], Model):
"""A team of users within an organization."""
Expand Down
11 changes: 5 additions & 6 deletions funnel/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,12 @@ class LoginProvider:
:meth:`do` is called when the user chooses to login with the specified provider.
:meth:`callback` is called with the response from the provider.
Both :meth:`do` and :meth:`callback` are called as part of a Flask
view and have full access to the view infrastructure. However, while
:meth:`do` is expected to return a Response to the user,
:meth:`callback` only returns information on the user back to Lastuser.
Both :meth:`do` and :meth:`callback` are called as part of a Flask view and have
full access to the view infrastructure. However, while :meth:`do` is expected to
return a Response to the user, :meth:`callback` only returns information on the user
back to Lastuser.
Implementations must take their configuration via the __init__
constructor.
Implementations must take their configuration via the __init__ constructor.
:param name: Name of the service (stored in the database)
:param title: Title (shown to user)
Expand Down
34 changes: 20 additions & 14 deletions funnel/templates/profile_layout.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -371,17 +371,19 @@
</div>
</div>
{% endif %}
<form id="follow-form-{{ profile.uuid_b58 }}" action="{{ profile.url_for('follow') }}" class="follow-form js-follow-form {% if css_class %}{{ css_class }}{% endif %}" data-account-id="{{ profile.uuid_b58 }}" method="post">
{%- if current_auth.is_anonymous %}
<a class="mui-btn mui-btn--dark mui-btn__icon mui-btn--raised" href="{{ url_for('login', next=request.path) }}" data-ga="Login to follow account" aria-label="{% trans %}Login to follow this account{% endtrans %}">{% trans %}Follow{% endtrans %}</a>
{%- elif profile != current_auth.user and not profile.features.is_private() %}
<input type="hidden" name="follow" value=""/>
{% if not hide_unfollow %}
<button type="submit" value="false" class="mui-btn mui-btn--danger mui-btn__icon zero-bottom-margin zero-top-margin {% if buttonclass %}{{ buttonclass }}{% endif %} js-unfollow-btn {% if not profile.current_roles.follower %}mui--hide{%- endif %}" href="{{ profile.url_for('follow') }}" onclick="this.form.follow.value=this.value">{{ faicon(icon='user-xmark', icon_size='subhead', baseline=false, css_class='icon_left') }} {% trans %}Unfollow{% endtrans %}</button>
{% endif %}
<button type="submit" value="true" class="mui-btn mui-btn--primary mui-btn--raised mui-btn__icon zero-bottom-margin zero-top-margin {% if buttonclass %}{{ buttonclass }}{% endif %} zero-left-margin js-follow-btn {% if profile.current_roles.follower %}mui--hide{%- endif %}" href="{{ profile.url_for('follow') }}" onclick="this.form.follow.value=this.value">{{ faicon(icon='user-plus', icon_size='subhead', baseline=false, css_class='icon-left') }} {% trans %}Follow{% endtrans %}</button>
{%- endif %}
</form>
{%- if not profile.features.is_private() %}
<form id="follow-form-{{ profile.uuid_b58 }}" action="{{ profile.url_for('follow') }}" class="follow-form js-follow-form {% if css_class %}{{ css_class }}{% endif %}" data-account-id="{{ profile.uuid_b58 }}" method="post">
{%- if current_auth.is_anonymous %}
<a class="mui-btn mui-btn--dark mui-btn__icon mui-btn--raised" href="{{ url_for('login', next=request.path) }}" data-ga="Login to follow account" aria-label="{% trans %}Login to follow this account{% endtrans %}">{% trans %}Follow{% endtrans %}</a>
{%- elif profile != current_auth.user %}
<input type="hidden" name="follow" value=""/>
{% if not hide_unfollow %}
<button type="submit" value="false" class="mui-btn mui-btn--danger mui-btn__icon zero-bottom-margin zero-top-margin {% if buttonclass %}{{ buttonclass }}{% endif %} js-unfollow-btn {% if not profile.current_roles.follower %}mui--hide{%- endif %}" href="{{ profile.url_for('follow') }}" onclick="this.form.follow.value=this.value">{{ faicon(icon='user-xmark', icon_size='subhead', baseline=false, css_class='icon_left') }} {% trans %}Unfollow{% endtrans %}</button>
{% endif %}
<button type="submit" value="true" class="mui-btn mui-btn--primary mui-btn--raised mui-btn__icon zero-bottom-margin zero-top-margin {% if buttonclass %}{{ buttonclass }}{% endif %} zero-left-margin js-follow-btn {% if profile.current_roles.follower %}mui--hide{%- endif %}" href="{{ profile.url_for('follow') }}" onclick="this.form.follow.value=this.value">{{ faicon(icon='user-plus', icon_size='subhead', baseline=false, css_class='icon-left') }} {% trans %}Follow{% endtrans %}</button>
{%- endif %}
</form>
{%- endif %}
</div>
{% endmacro %}

Expand Down Expand Up @@ -479,10 +481,14 @@
<a class="sub-navbar__item mui--text-subhead mui--text-dark {% if current_page == 'admins' %}sub-navbar__item--active{%- endif %}" href="{%- if current_page != 'admins' -%}{{ profile.urls['members'] }}{%- endif %}" data-cy-navbar="admins">{% trans %}Admins{% endtrans %} <span class="sub-navbar__item__icon mui--pull-right">{{ faicon(icon='chevron-right', icon_size='subhead') }}</span></a>
{% elif not profile.features.is_private() %}
<a class="sub-navbar__item mui--text-subhead mui--text-dark mui--hidden-xs mui--hidden-sm {% if current_page == 'profile' %}sub-navbar__item--active{%- endif %}" href="{{ profile.url_for() }}">{% trans %}Sessions{% endtrans %}</a>
<a class="sub-navbar__item mui--text-subhead mui--text-dark {% if current_page == 'projects' %}sub-navbar__item--active{%- endif %}" href="{{ profile.url_for('user_participated_projects') }}">{% trans %}Projects{% endtrans %}<span class="sub-navbar__item__icon mui--pull-right">{{ faicon(icon='chevron-right', icon_size='subhead') }}</span></a>
<a class="sub-navbar__item mui--text-subhead mui--text-dark {% if current_page == 'submissions' %}sub-navbar__item--active{%- endif %}" href="{{ profile.url_for('user_proposals') }}" data-cy="submissions">{% trans %}Submissions{% endtrans %}<span class="sub-navbar__item__icon mui--pull-right">{{ faicon(icon='chevron-right', icon_size='subhead') }}</span></a>
{%- if profile.is_user_profile %}
<a class="sub-navbar__item mui--text-subhead mui--text-dark {% if current_page == 'projects' %}sub-navbar__item--active{%- endif %}" href="{{ profile.url_for('user_participated_projects') }}">{% trans %}Projects{% endtrans %}<span class="sub-navbar__item__icon mui--pull-right">{{ faicon(icon='chevron-right', icon_size='subhead') }}</span></a>
<a class="sub-navbar__item mui--text-subhead mui--text-dark {% if current_page == 'submissions' %}sub-navbar__item--active{%- endif %}" href="{{ profile.url_for('user_proposals') }}" data-cy="submissions">{% trans %}Submissions{% endtrans %}<span class="sub-navbar__item__icon mui--pull-right">{{ faicon(icon='chevron-right', icon_size='subhead') }}</span></a>
{%- endif %}
{%- if profile.current_roles.admin %}{# TODO: Remove after consent flow #}
<a class="sub-navbar__item mui--text-subhead mui--text-dark mui--hidden-xs mui--hidden-sm {% if current_page == 'following' %}sub-navbar__item--active{%- endif %}" href="{%- if current_page != 'following' -%}{{ profile.url_for('following') }}{%- endif %}" data-cy-navbar="following">{% trans %}Following{% endtrans %} {% if profile.features.following_count() %}<span class="mui--text-caption badge badge--primary badge--tab">{{ profile.features.following_count() }}</span>{% endif %}<span class="sub-navbar__item__icon mui--pull-right">{{ faicon(icon='chevron-right', icon_size='subhead') }}</span></a>
{%- if not profile.is_placeholder_profile %}
<a class="sub-navbar__item mui--text-subhead mui--text-dark mui--hidden-xs mui--hidden-sm {% if current_page == 'following' %}sub-navbar__item--active{%- endif %}" href="{%- if current_page != 'following' -%}{{ profile.url_for('following') }}{%- endif %}" data-cy-navbar="following">{% trans %}Following{% endtrans %} {% if profile.features.following_count() %}<span class="mui--text-caption badge badge--primary badge--tab">{{ profile.features.following_count() }}</span>{% endif %}<span class="sub-navbar__item__icon mui--pull-right">{{ faicon(icon='chevron-right', icon_size='subhead') }}</span></a>
{%- endif %}
<a class="sub-navbar__item mui--text-subhead mui--text-dark mui--hidden-xs mui--hidden-sm {% if current_page == 'followers' %}sub-navbar__item--active{%- endif %}" href="{%- if current_page != 'followers' -%}{{ profile.url_for('followers') }}{%- endif %}" data-cy-navbar="followers">{% trans %}Followers{% endtrans %} {% if profile.features.followers_count() %}<span class="mui--text-caption badge badge--primary badge--tab">{{ profile.features.followers_count() }}</span>{% endif %}<span class="sub-navbar__item__icon mui--pull-right">{{ faicon(icon='chevron-right', icon_size='subhead') }}</span></a>
{%- endif %}
{% endif %}
Expand Down
21 changes: 19 additions & 2 deletions funnel/views/api/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
AuthToken,
LoginSession,
Organization,
Placeholder,
User,
db,
getuser,
Expand All @@ -47,6 +48,7 @@ def get_userinfo(
get_permissions: bool = True,
) -> ReturnResource:
"""Return userinfo for a given user, auth client and scope."""
userinfo: dict[str, Any]
if '*' in scope or 'id' in scope or 'id/*' in scope:
userinfo = {
'userid': user.buid,
Expand Down Expand Up @@ -92,6 +94,21 @@ def get_userinfo(
for org in user.organizations_as_admin
],
}
# If the user is a site editor, also include placeholder accounts.
# TODO: Remove after Imgee merger
if user.is_site_editor:
placeholders = [
{
'userid': p.buid,
'buid': p.buid,
'uuid': p.uuid,
'name': p.urlname,
'title': p.title,
}
for p in Placeholder.query.all()
]
userinfo['organizations']['owner'].extend(placeholders)
userinfo['organizations']['admin'].extend(placeholders)

if get_permissions:
uperms = AuthClientPermissions.get(auth_client=auth_client, account=user)
Expand Down Expand Up @@ -488,13 +505,13 @@ def resource_id(


@app.route('/api/1/session/verify', methods=['POST'])
@resource_registry.resource('session/verify', __("Verify user session"), scope='id')
@resource_registry.resource('session/verify', __("Verify login session"), scope='id')
def session_verify(
authtoken: AuthToken,
args: MultiDict,
files: MultiDict | None = None, # noqa: ARG001
) -> ReturnResource:
"""Verify a UserSession."""
"""Verify a :class:`LoginSession`."""
sessionid = abort_null(args['sessionid'])
login_session = LoginSession.authenticate(buid=sessionid, silent=True)
if login_session is not None and login_session.account == authtoken.effective_user:
Expand Down
4 changes: 1 addition & 3 deletions funnel/views/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def view(self) -> ReturnRenderWith:
],
}

elif self.obj.is_organization_profile:
else:
template_name = 'profile.html.jinja2'

# `order_by(None)` clears any existing order defined in relationship.
Expand Down Expand Up @@ -248,8 +248,6 @@ def view(self) -> ReturnRenderWith:
else None
),
}
else:
abort(404) # Reserved account

return ctx

Expand Down

0 comments on commit a50144a

Please sign in to comment.