Skip to content

Commit

Permalink
Add members-only registrations (#1929)
Browse files Browse the repository at this point in the history
  • Loading branch information
jace authored Nov 20, 2023
1 parent f3e10d6 commit 5d95cbb
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 52 deletions.
2 changes: 2 additions & 0 deletions funnel/forms/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ def format_json(data: dict | str | None) -> str:

def validate_and_convert_json(form: forms.Form, field: forms.Field) -> None:
"""Confirm form data is valid JSON, and store it back as a parsed dict."""
if field.data is None:
return
try:
field.data = json.loads(field.data)
except ValueError:
Expand Down
5 changes: 4 additions & 1 deletion funnel/forms/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import re
from typing import cast

from baseframe import _, __, forms
from baseframe.forms.sqlalchemy import AvailableName
Expand Down Expand Up @@ -371,12 +372,14 @@ class ProjectRegisterForm(forms.Form):
)

def validate_form(self, field: forms.Field) -> None:
if not self.form.data:
return
if self.form.data and not self.schema:
raise forms.validators.StopValidation(
_("This registration is not expecting any form fields")
)
if self.schema:
form_keys = set(self.form.data.keys())
form_keys = set(cast(dict, self.form.data).keys())
schema_keys = {i['name'] for i in self.schema['fields']}
if not form_keys.issubset(schema_keys):
invalid_keys = form_keys.difference(schema_keys)
Expand Down
8 changes: 5 additions & 3 deletions funnel/forms/sync_ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from baseframe import __, forms

from ..models import (
PROJECT_RSVP_STATE,
Account,
AccountEmail,
Project,
Expand Down Expand Up @@ -68,9 +69,10 @@ class ProjectBoxofficeForm(forms.Form):
validators=[forms.validators.AllowedIf('org')],
filters=[forms.filters.strip()],
)
allow_rsvp = forms.BooleanField(
__("Allow free registrations"),
default=False,
rsvp_state = forms.RadioField(
__("Registrations"),
choices=PROJECT_RSVP_STATE.items(),
default=PROJECT_RSVP_STATE.NONE,
)
is_subscription = forms.BooleanField(
__("Paid tickets are for a subscription"),
Expand Down
2 changes: 1 addition & 1 deletion funnel/models/membership_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ def replace(
return new

@with_roles(call={'editor'})
def amend_by(self: MembershipType, actor: Account):
def amend_by(self, actor: Account):
"""Amend a membership in a `with` context."""
return AmendMembership(self, actor)

Expand Down
53 changes: 41 additions & 12 deletions funnel/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
UuidMixin,
backref,
db,
hybrid_property,
relationship,
sa,
types,
Expand All @@ -44,7 +45,7 @@
visual_field_delimiter,
)

__all__ = ['Project', 'ProjectLocation', 'ProjectRedirect']
__all__ = ['PROJECT_RSVP_STATE', 'Project', 'ProjectLocation', 'ProjectRedirect']


# --- Constants ---------------------------------------------------------------
Expand All @@ -66,6 +67,12 @@ class CFP_STATE(LabeledEnum): # noqa: N801
ANY = {NONE, PUBLIC, CLOSED}


class PROJECT_RSVP_STATE(LabeledEnum): # noqa: N801
NONE = (1, __("Not accepting registrations"))
ALL = (2, __("Anyone can register"))
MEMBERS = (3, __("Only members can register"))


# --- Models ------------------------------------------------------------------


Expand Down Expand Up @@ -160,6 +167,19 @@ class Project(UuidMixin, BaseScopedNameMixin, Model):
StateManager('_cfp_state', CFP_STATE, doc="CfP state"), call={'all'}
)

#: State of RSVPs
rsvp_state: Mapped[int] = with_roles(
sa.orm.mapped_column(
sa.SmallInteger,
StateManager.check_constraint('rsvp_state', PROJECT_RSVP_STATE),
default=PROJECT_RSVP_STATE.NONE,
nullable=False,
),
read={'all'},
write={'editor', 'promoter'},
datasets={'primary', 'without_parent', 'related'},
)

#: Audit timestamp to detect re-publishing to re-surface a project
first_published_at: Mapped[datetime | None] = sa.orm.mapped_column(
sa.TIMESTAMP(timezone=True), nullable=True
Expand Down Expand Up @@ -204,11 +224,6 @@ class Project(UuidMixin, BaseScopedNameMixin, Model):
sa.LargeBinary, nullable=True, deferred=True
)

allow_rsvp: Mapped[bool] = with_roles(
sa.orm.mapped_column(sa.Boolean, default=True, nullable=False),
read={'all'},
datasets={'primary', 'without_parent', 'related'},
)
buy_tickets_url: Mapped[furl | None] = with_roles(
sa.orm.mapped_column(UrlType, nullable=True),
read={'all'},
Expand Down Expand Up @@ -628,10 +643,11 @@ def datelocation(self) -> str:
> 30 Dec 2018–02 Jan 2019, Bangalore
"""
# FIXME: Replace strftime with Babel formatting
daterange = ''
if self.start_at is not None and self.end_at is not None:
schedule_start_at_date = self.start_at_localized.date()
schedule_end_at_date = self.end_at_localized.date()
start_at = self.start_at_localized
end_at = self.end_at_localized
if start_at is not None and end_at is not None:
schedule_start_at_date = start_at.date()
schedule_end_at_date = end_at.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
Expand All @@ -646,11 +662,18 @@ def datelocation(self) -> str:
elif schedule_start_at_date.month == schedule_end_at_date.month:
# If multi-day event in same month
strf_date = '%d'
else:
raise ValueError(
"This should not happen: unknown date range"
f" {schedule_start_at_date}{schedule_end_at_date}"
)
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,
)
else:
daterange = ''
return ', '.join([_f for _f in [daterange, self.location] if _f])

# TODO: Removing Delete feature till we figure out siteadmin feature
Expand All @@ -662,7 +685,7 @@ def datelocation(self) -> str:
# pass

@sa.orm.validates('name', 'account')
def _validate_and_create_redirect(self, key, value):
def _validate_and_create_redirect(self, key: str, value: str | None) -> str:
# TODO: When labels, venues and other resources are relocated from project to
# account, this validator can no longer watch for `account` change. We'll need a
# more elaborate transfer mechanism that remaps resources to equivalent ones in
Expand Down Expand Up @@ -710,7 +733,13 @@ def end_at_localized(self):
"""Return localized end_at timestamp."""
return localize_timezone(self.end_at, tz=self.timezone) if self.end_at else None

def update_schedule_timestamps(self):
@with_roles(read={'all'}, datasets={'primary', 'without_parent', 'related'})
@hybrid_property
def allow_rsvp(self) -> bool:
"""RSVP state as a boolean value (allowed for all or not)."""
return self.rsvp_state == PROJECT_RSVP_STATE.ALL

def update_schedule_timestamps(self) -> None:
"""Update cached timestamps from sessions."""
self.start_at = self.schedule_start_at
self.end_at = self.schedule_end_at
Expand Down
22 changes: 12 additions & 10 deletions funnel/templates/project_layout.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -140,45 +140,47 @@
{% macro registerblock(project) %}
<div class="register-block">
<div class="register-block__content {% if project.features.show_tickets %}register-block__content--half {%- endif %}">
{%- if project.features.rsvp_registered() %}
{%- if project.features.rsvp_registered %}
<span class="register-block__content__txt mui--text-light"></span>
<a id="cancel-rsvp-btn" class="mui-btn mui-btn--accent mui-btn--raised register-block__btn js-register-btn" rel="modal:open" href="#register-modal" aria-haspopup="true">
<span class="register-block__btn__txt register-block__btn__txt--hover" data-cy="registered">{% if project.features.follow_mode() %}{% trans %}Following{% endtrans %}{% else %}{% trans %}Registered{% endtrans %}{% endif %}{{ faicon(icon='check-circle-solid', icon_size='caption', baseline=true, css_class="mui--text-success fa-icon--left-margin") }}</span>
<span class="register-block__btn__txt register-block__btn__txt--hover--show">{% if project.features.follow_mode() %}{% trans %}Unfollow{% endtrans %}{% else %}{% trans %}Cancel Registration{% endtrans %}{% endif %}</span>
<span class="register-block__btn__txt register-block__btn__txt--hover" data-cy="registered">{% if project.features.follow_mode %}{% trans %}Following{% endtrans %}{% else %}{% trans %}Registered{% endtrans %}{% endif %}{{ faicon(icon='check-circle-solid', icon_size='caption', baseline=true, css_class="mui--text-success fa-icon--left-margin") }}</span>
<span class="register-block__btn__txt register-block__btn__txt--hover--show">{% if project.features.follow_mode %}{% trans %}Unfollow{% endtrans %}{% else %}{% trans %}Cancel Registration{% endtrans %}{% endif %}</span>
<span class="register-block__btn__txt register-block__btn__txt--smaller mui--text-light register-block__btn__txt--mobile">{{ project.views.registration_text() }}</span>
</a>
<div class="modal" id="register-modal" role="dialog" aria-labelledby="cancel-rsvp" aria-modal="true" tabindex="-1">
<div class="modal__header">
<a class="modal__close mui--text-dark" data-target="close cancel register modal" aria-label="{% trans %}Close{% endtrans %}" rel="modal:close" href="#" onclick="return false;" role="button" tabindex="0">{{ faicon(icon='times', baseline=false, icon_size='title') }}</a>
</div>
<div class="modal__body">
<p class="mui--text-subhead" id="cancel-rsvp">{% if project.features.follow_mode() %}{% trans %}No longer interested?{% endtrans %}{% else %}{% trans %}Can’t make it?{% endtrans %}{% endif %}</p>
<p class="mui--text-subhead" id="cancel-rsvp">{% if project.features.follow_mode %}{% trans %}No longer interested?{% endtrans %}{% else %}{% trans %}Can’t make it?{% endtrans %}{% endif %}</p>
<form action="{{ project.url_for('deregister') }}" method="post" class="form-inline">
{{ csrf_tag() }}
<div class="mui--text-right">
<button class="mui-btn mui-btn--raised" type="submit" name="submit" value="no" data-cy="cancel-rsvp">{% if project.features.follow_mode() %}{% trans %}Stop following{% endtrans %}{% else %}{% trans %}Confirm cancellation{% endtrans %}{% endif %}</button>
<button class="mui-btn mui-btn--raised" type="submit" name="submit" value="no" data-cy="cancel-rsvp">{% if project.features.follow_mode %}{% trans %}Stop following{% endtrans %}{% else %}{% trans %}Confirm cancellation{% endtrans %}{% endif %}</button>
</div>
</form>
</div>
</div>
{% elif project.features.rsvp() %}
{% elif project.features.rsvp %}
{%- if current_auth.is_anonymous %}
<a class="mui-btn mui-btn--raised {% if project.features.show_tickets %} mui-btn--dark {%- else %} mui-btn--primary {%- endif %} register-block__btn" id="register-nav" href="{{ url_for('login', next=request.path + '#register-modal', modal='register-modal') }}" rel="modal:open" aria-haspopup="true" data-register-modal="register-modal">{{ project.views.register_button_text() }}</a>
{% elif project.features.rsvp_unregistered() -%}
{% if not project.features.follow_mode() %}<span class="register-block__content__txt mui--text-light">{% trans %}This is a free event{% endtrans %}</span>{% endif %}
{% elif project.features.rsvp_unregistered -%}
{% if not project.features.follow_mode %}<span class="register-block__content__txt mui--text-light">{% trans %}This is a free event{% endtrans %}</span>{% endif %}
<a id="rsvp-btn" class="mui-btn mui-btn--raised mui-btn--dark register-block__btn js-register-btn" href="{{ project.url_for('rsvp_modal') }}" rel="modal:open" role="button" aria-haspopup="true">
<span class="register-block__btn__txt" data-cy="unregistered">{{ project.views.register_button_text() }}</span>
<span class="register-block__btn__txt register-block__btn__txt--smaller primary-color-lighter-txt" data-cy="unregistered">{{ project.views.registration_text() }}</span>
</a>
{%- endif %}
{% elif project.buy_tickets_url.url -%}
<a class="register-block__btn full-width-btn mui-btn mui-btn--primary" href="{{ project.buy_tickets_url.url }}" data-action="external register url" target="_blank" rel="noopener"><span>{{ faicon(icon='arrow-up-right-from-square', baseline=true, css_class="mui--text-white fa-icon--right-margin") }}{{ project.views.register_button_text() }}</span></a>
{% elif project.features.rsvp_for_members -%}
<div class="register-block__content"><button class="mui-btn mui-btn--accent register-block__btn mui--is-disabled">{% trans %}Registration for members only{% endtrans %}</button></div>
{% endif %}
</div>
{% if project.current_roles.account_member %}
<div class="register-block__content {% if project.features.rsvp() or project.buy_tickets_url.url %} register-block__content--half {%- endif %}"><button class="mui-btn mui-btn--accent register-block__btn mui--is-disabled">{% trans %}You are a member{% endtrans %}</button></div>
<div class="register-block__content {% if project.features.rsvp or project.buy_tickets_url.url %} register-block__content--half {%- endif %}"><button class="mui-btn mui-btn--accent register-block__btn mui--is-disabled">{% trans %}You are a member{% endtrans %}</button></div>
{% elif project.features.show_tickets %}
<div class="register-block__content {% if project.features.rsvp() or project.buy_tickets_url.url %} register-block__content--half {%- endif %}">
<div class="register-block__content {% if project.features.rsvp or project.buy_tickets_url.url %} register-block__content--half {%- endif %}">
<button class="js-open-ticket-widget register-block__btn mui-btn mui-btn--primary">
{% if project.features.subscription %}
<span class="register-block__btn__txt" data-cy="unregistered">{% trans %}Join{% endtrans %}</span>
Expand Down
Loading

0 comments on commit 5d95cbb

Please sign in to comment.