Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Let site editors manage placeholder accounts #2086

Merged
merged 3 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 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,21 @@ 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
1 change: 1 addition & 0 deletions funnel/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Provide configuration for models and import all into a common `models` namespace."""

# pyright: reportUnsupportedDunderAll=false
# ruff: noqa: F811

# The second half of this file is dynamically generated. Run `make initpy` to
# regenerate. Since lazy_loader is opaque to static type checkers, there's an overriding
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
4 changes: 1 addition & 3 deletions funnel/models/auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,9 +531,7 @@ def is_valid(self) -> bool:
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
return not self.created_at < now - timedelta(seconds=self.validity)

@classmethod
def revoke_all_for(cls, account: Account) -> None:
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
4 changes: 1 addition & 3 deletions funnel/transports/sms/send.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ def validate_exotel_token(token: str, to: str) -> bool:
def okay_to_message_in_india_right_now() -> bool:
"""Report if it's currently within messaging hours in India (9 AM to 7PM IST)."""
now = utcnow().astimezone(indian_timezone)
if now.hour >= DND_END_HOUR and now.hour < DND_START_HOUR:
return True
return False
return bool(now.hour >= DND_END_HOUR and now.hour < DND_START_HOUR)


def send_via_exotel(
Expand Down
8 changes: 2 additions & 6 deletions funnel/views/account_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ def decorator(func: ValidatorFunc) -> ValidatorFunc:
)
def profile_is_protected(user: Account) -> bool:
"""Block deletion if the user has a protected account."""
if user.is_protected:
return False
return True
return not user.is_protected


@delete_validator(
Expand Down Expand Up @@ -91,9 +89,7 @@ def profile_has_projects(user: Account) -> bool:
)
def user_owns_apps(user: Account) -> bool:
"""Fail if user is the owner of client apps."""
if user.clients:
return False
return True
return not user.clients


# MARK: Delete validator view helper ---------------------------------------------------
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
Loading