From 5d95cbb28517489715eba55381c3e96e92ef35a8 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Tue, 21 Nov 2023 00:34:32 +0530 Subject: [PATCH 01/10] Add members-only registrations (#1929) --- funnel/forms/helpers.py | 2 + funnel/forms/project.py | 5 +- funnel/forms/sync_ticket.py | 8 +- funnel/models/membership_mixin.py | 2 +- funnel/models/project.py | 53 ++++++++--- funnel/templates/project_layout.html.jinja2 | 22 ++--- funnel/views/project.py | 71 ++++++++++----- .../f0ed25eed4bc_replace_rsvp_flag.py | 87 +++++++++++++++++++ tests/e2e/account/register_test.py | 1 + tests/unit/views/rsvp_test.py | 2 +- 10 files changed, 201 insertions(+), 52 deletions(-) create mode 100644 migrations/versions/f0ed25eed4bc_replace_rsvp_flag.py diff --git a/funnel/forms/helpers.py b/funnel/forms/helpers.py index 1a696c4f2..cbebf8c30 100644 --- a/funnel/forms/helpers.py +++ b/funnel/forms/helpers.py @@ -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: diff --git a/funnel/forms/project.py b/funnel/forms/project.py index 546bd969a..e015947f5 100644 --- a/funnel/forms/project.py +++ b/funnel/forms/project.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from typing import cast from baseframe import _, __, forms from baseframe.forms.sqlalchemy import AvailableName @@ -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) diff --git a/funnel/forms/sync_ticket.py b/funnel/forms/sync_ticket.py index 1682820a5..4a7445ffa 100644 --- a/funnel/forms/sync_ticket.py +++ b/funnel/forms/sync_ticket.py @@ -9,6 +9,7 @@ from baseframe import __, forms from ..models import ( + PROJECT_RSVP_STATE, Account, AccountEmail, Project, @@ -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"), diff --git a/funnel/models/membership_mixin.py b/funnel/models/membership_mixin.py index eb918dd8a..c42cbc83e 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -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) diff --git a/funnel/models/project.py b/funnel/models/project.py index a889c4369..a1a7f92ac 100644 --- a/funnel/models/project.py +++ b/funnel/models/project.py @@ -28,6 +28,7 @@ UuidMixin, backref, db, + hybrid_property, relationship, sa, types, @@ -44,7 +45,7 @@ visual_field_delimiter, ) -__all__ = ['Project', 'ProjectLocation', 'ProjectRedirect'] +__all__ = ['PROJECT_RSVP_STATE', 'Project', 'ProjectLocation', 'ProjectRedirect'] # --- Constants --------------------------------------------------------------- @@ -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 ------------------------------------------------------------------ @@ -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 @@ -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'}, @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/funnel/templates/project_layout.html.jinja2 b/funnel/templates/project_layout.html.jinja2 index 0390bc468..b68ab3191 100644 --- a/funnel/templates/project_layout.html.jinja2 +++ b/funnel/templates/project_layout.html.jinja2 @@ -140,11 +140,11 @@ {% macro registerblock(project) %}
- {%- if project.features.rsvp_registered() %} + {%- if project.features.rsvp_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") }} - {% if project.features.follow_mode() %}{% trans %}Unfollow{% endtrans %}{% else %}{% trans %}Cancel Registration{% endtrans %}{% endif %} + {% 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") }} + {% if project.features.follow_mode %}{% trans %}Unfollow{% endtrans %}{% else %}{% trans %}Cancel Registration{% endtrans %}{% endif %} {{ project.views.registration_text() }}
- {% elif project.features.rsvp() %} + {% elif project.features.rsvp %} {%- if current_auth.is_anonymous %} {{ project.views.register_button_text() }} - {% elif project.features.rsvp_unregistered() -%} - {% if not project.features.follow_mode() %}{% trans %}This is a free event{% endtrans %}{% endif %} + {% elif project.features.rsvp_unregistered -%} + {% if not project.features.follow_mode %}{% trans %}This is a free event{% endtrans %}{% endif %} {{ project.views.register_button_text() }} {{ project.views.registration_text() }} @@ -173,12 +173,14 @@ {%- endif %} {% elif project.buy_tickets_url.url -%} {{ faicon(icon='arrow-up-right-from-square', baseline=true, css_class="mui--text-white fa-icon--right-margin") }}{{ project.views.register_button_text() }} + {% elif project.features.rsvp_for_members -%} +
{% endif %}
{% if project.current_roles.account_member %} -
+
{% elif project.features.show_tickets %} -
+
-
- -
+
+ + + {% 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") }} + {% if project.features.follow_mode %}{% trans %}Unfollow{% endtrans %}{% else %}{% trans %}Cancel Registration{% endtrans %}{% endif %} + {{ project.views.registration_text() }} + + {% elif project.features.rsvp %} - {%- if current_auth.is_anonymous %} - {{ project.views.register_button_text() }} - {% elif project.features.rsvp_unregistered -%} - {% if not project.features.follow_mode %}{% trans %}This is a free event{% endtrans %}{% endif %} - - {{ project.views.register_button_text() }} - {{ project.views.registration_text() }} - - {%- endif %} +
+ {%- if current_auth.is_anonymous %} + {{ project.views.register_button_text() }} + {% elif project.features.rsvp_unregistered -%} + {% if not project.features.follow_mode %}{% trans %}This is a free event{% endtrans %}{% endif %} + + {{ project.views.register_button_text() }} + {{ project.views.registration_text() }} + + {%- endif %} +
{% elif project.buy_tickets_url.url -%} - {{ faicon(icon='arrow-up-right-from-square', baseline=true, css_class="mui--text-white fa-icon--right-margin") }}{{ project.views.register_button_text() }} + {% elif project.features.rsvp_for_members -%} -
+
+ +
{% endif %} -
{% if project.current_roles.account_member %} -
+
{% elif project.features.show_tickets %} -
+
+ {% elif project.features.rsvp %}
{%- if current_auth.is_anonymous %} {{ project.views.register_button_text() }} {% elif project.features.rsvp_unregistered -%} - {% if not project.features.follow_mode %}{% trans %}This is a free event{% endtrans %}{% endif %} + {% if not project.features.follow_mode and not project.features.rsvp_for_members %}{% trans %}This is a free event{% endtrans %}{% endif %} {{ project.views.register_button_text() }} {{ project.views.registration_text() }} From 89f2645edfdec01ae7b12cea2ec6163405803d73 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Fri, 8 Dec 2023 10:51:49 +0530 Subject: [PATCH 07/10] Fix incorrect attr for user in LoginSession (missed in #1697) (#1938) Also fix/update imports and typing. --- funnel/models/login_session.py | 4 ++++ funnel/views/login_session.py | 2 +- tests/conftest.py | 2 +- tests/unit/utils/markdown/conftest.py | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/funnel/models/login_session.py b/funnel/models/login_session.py index 73c8bee53..384fbd872 100644 --- a/funnel/models/login_session.py +++ b/funnel/models/login_session.py @@ -34,6 +34,10 @@ class LoginSessionError(Exception): """Base exception for user session errors.""" + def __init__(self, login_session: LoginSession, *args) -> None: + self.login_session = login_session + super().__init__(login_session, *args) + class LoginSessionExpiredError(LoginSessionError): """This user session has expired and cannot be marked as currently active.""" diff --git a/funnel/views/login_session.py b/funnel/views/login_session.py index 7ab5f3086..294c1be59 100644 --- a/funnel/views/login_session.py +++ b/funnel/views/login_session.py @@ -174,7 +174,7 @@ def _load_user(): # TODO: Force render of logout page to clear client-side data logout_internal() except LoginSessionInactiveUserError as exc: - inactive_user = exc.args[0].user + inactive_user = exc.login_session.account if inactive_user.state.SUSPENDED: flash(_("Your account has been suspended")) elif inactive_user.state.DELETED: diff --git a/tests/conftest.py b/tests/conftest.py index 2c4948f5d..2444960a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,7 +90,7 @@ def sort_key(item: pytest.Function) -> tuple[int, str]: # as item.location == (file_path, line_no, function_name). However, pytest-bdd # reports itself for file_path, so we can't use that and must extract the path # from the test module instead - module_file = item.module.__file__ + module_file = item.module.__file__ if item.module is not None else '' for counter, path in enumerate(test_order): if path in module_file: return (counter, module_file) diff --git a/tests/unit/utils/markdown/conftest.py b/tests/unit/utils/markdown/conftest.py index c3dda32bb..feb8569e8 100644 --- a/tests/unit/utils/markdown/conftest.py +++ b/tests/unit/utils/markdown/conftest.py @@ -107,7 +107,7 @@ def dump(cls) -> None: if cls.test_map is not None: for md_testname, data in cls.test_files.items(): data['expected_output'] = { - md_configname: tomlkit.api.string(case.output, multiline=True) + md_configname: tomlkit.string(case.output, multiline=True) for md_configname, case in cls.test_map[md_testname].items() } (md_tests_data_root / md_testname).write_text(tomlkit.dumps(data)) From f0049837d86ca884358f197988e6514f4f7461a3 Mon Sep 17 00:00:00 2001 From: Vidya Ramakrishnan Date: Tue, 12 Dec 2023 12:20:20 +0530 Subject: [PATCH 08/10] To the account menu, list org accounts that user has membership in (#1939) * To the account menu, list org accounts that user has memberhsip in * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Check for org.current_roles.member * Merge branch 'account-menu-membership' of https://github.com/hasgeek/funnel into account-menu-membership * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add membership badge to profile card * Update to get user access * Simplify view * Specify datasets --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Kiran Jonnalagadda --- funnel/templates/account_menu.html.jinja2 | 36 ++++++++++++++------- funnel/templates/index.html.jinja2 | 6 +++- funnel/templates/macros.html.jinja2 | 5 ++- funnel/templates/project_layout.html.jinja2 | 4 +-- funnel/views/account.py | 16 +++++++-- funnel/views/index.py | 2 +- 6 files changed, 50 insertions(+), 19 deletions(-) diff --git a/funnel/templates/account_menu.html.jinja2 b/funnel/templates/account_menu.html.jinja2 index fcaf35c39..039d88321 100644 --- a/funnel/templates/account_menu.html.jinja2 +++ b/funnel/templates/account_menu.html.jinja2 @@ -27,13 +27,32 @@ class="header__dropdown__item header__dropdown__item--flex mui--text-dark nounderline mui--text-subhead mui--text-light">{{ faicon(icon='plus', icon_size='title', baseline=false, css_class="mui--text-light") }}{% trans %}Add username{% endtrans %} {%- endif %} - {%- with orgmemlist = current_auth.user.views.recent_organization_memberships() %} - {%- if orgmemlist.recent|length -%} +
  • + {{ faicon(icon='sitemap', icon_size='title', baseline=false, css_class="mui--text-light") }}{% trans %}Organizations{% endtrans %} +
  • + {%- with orglist = current_auth.user.views.organizations_as_member %} + {%- for org in orglist %}
  • - {{ faicon(icon='sitemap', icon_size='title', baseline=false, css_class="mui--text-light") }}{% trans %}Organizations{% endtrans %} + + + {%- if org.logo_url.url %} + {{ org.title }} + {% else %} + {{ org.title }} + {% endif %} + + {{ org.title }}{{ faicon(icon='crown-solid', baseline=true, icon_size='body2', css_class="mui--text-success fa-icon--right-margin") }}{% trans %}Member{% endtrans %} +
  • + {%- endfor %} + {%- endwith %} + {%- with orgmemlist = current_auth.user.views.recent_organization_memberships() %} + {%- if orgmemlist.recent|length -%} {%- for orgmem in orgmemlist.recent %}
  • {{ faicon(icon='bell', icon_size='subhead', baseline=false, css_class="mui--text-light") }}{% trans %}Notification settings{% endtrans %}
  • - {%- if not orgmemlist.recent|length -%} -
  • - {{ faicon(icon='sitemap', icon_size='subhead', baseline=false, css_class="mui--text-light") }}{% trans %}Organizations{% endtrans %} -
  • - {%- endif %} {%- endwith %}
  • {% for account in featured_accounts %}
  • - {{ profilecard(account) }} + {%- if account.current_roles.member %} + {{ profilecard(account, snippet_html=false, is_member=true) }} + {%- else %} + {{ profilecard(account) }} + {% endif %}
  • {%- endfor -%} diff --git a/funnel/templates/macros.html.jinja2 b/funnel/templates/macros.html.jinja2 index a2ef2faa6..4191a8af4 100644 --- a/funnel/templates/macros.html.jinja2 +++ b/funnel/templates/macros.html.jinja2 @@ -446,7 +446,7 @@ {%- endif %} {%- endmacro %} -{% macro profilecard(account, snippet_html) %} +{% macro profilecard(account, snippet_html, is_member) %}
    @@ -465,6 +465,9 @@ {%- if snippet_html %}

    {{ faicon(icon='search', css_class="search-icon", baseline=false) }} {{ snippet_html }}

    {% endif %} + {%- if is_member %} +
    {{ faicon(icon='crown-solid', baseline=true, css_class="mui--text-success fa-icon--right-margin") }}{% trans %}Member{% endtrans %}
    + {% endif %}

    {% trans tcount=account.published_project_count, count=account.published_project_count|numberformat %}One project{% pluralize tcount %}{{ count }} projects{% endtrans %}

    {% trans %}Explore{% endtrans %} diff --git a/funnel/templates/project_layout.html.jinja2 b/funnel/templates/project_layout.html.jinja2 index 3c7aedaec..b2992d663 100644 --- a/funnel/templates/project_layout.html.jinja2 +++ b/funnel/templates/project_layout.html.jinja2 @@ -51,7 +51,7 @@ {% macro project_header(project) %} {%- if project.livestream_urls %} - {% if (project.is_restricted_video and project.current_roles.participant) or not project.is_restricted_video %} + {% if (project.is_restricted_video and project.current_roles.account_member) or not project.is_restricted_video %}
    {% if project.livestream_urls|length >= 2 %}
      @@ -125,7 +125,7 @@ {% endif %} {{ project.account.title }} - {% if project.features.subscription and project.current_roles.participant %} + {% if project.features.subscription and project.current_roles.account_member %} {{ faicon(icon='crown-solid', baseline=true, css_class="mui--text-success fa-icon--right-margin") }}{% trans %}Member{% endtrans %} {% elif project.features.subscription %} {{ faicon(icon='lock-alt', baseline=true, css_class="fa-icon--right-margin") }}{% trans %}For members{% endtrans %} diff --git a/funnel/views/account.py b/funnel/views/account.py index a28a7f8bc..283534470 100644 --- a/funnel/views/account.py +++ b/funnel/views/account.py @@ -117,7 +117,7 @@ def organizations_as_admin( owner: bool = False, limit: int | None = None, order_by_grant: bool = False, -) -> list[RoleAccessProxy]: +) -> list[RoleAccessProxy[Account]]: """Return organizations that the user is an admin of.""" if owner: orgmems = obj.active_organization_owner_memberships @@ -139,7 +139,7 @@ def organizations_as_admin( @Account.views() def organizations_as_owner( obj: Account, limit: int | None = None, order_by_grant: bool = False -) -> list[RoleAccessProxy]: +) -> list[RoleAccessProxy[Account]]: """Return organizations that the user is an owner of.""" return obj.views.organizations_as_admin( owner=True, limit=limit, order_by_grant=order_by_grant @@ -173,6 +173,18 @@ def recent_organization_memberships( ) +@Account.views(cached_property=True) +def organizations_as_member(obj: Account) -> list[RoleAccessProxy[Account]]: + """Return organizations that the user has a membership in.""" + return [ + acc.access_for(actor=obj, datasets=('primary', 'related')) + for acc in Account.query.filter( + Account.name_in(app.config['FEATURED_ACCOUNTS']) + ).all() + if 'member' in acc.roles_for(obj) + ] + + @Account.views('avatar_color_code', cached_property=True) def avatar_color_code(obj: Account) -> int: """Return a colour code for the user's autogenerated avatar image.""" diff --git a/funnel/views/index.py b/funnel/views/index.py index 31d789c7f..070e6f3f3 100644 --- a/funnel/views/index.py +++ b/funnel/views/index.py @@ -151,7 +151,7 @@ def home(self) -> ReturnRenderWith: 'featured_project_sessions': scheduled_sessions_list, 'featured_project_schedule': featured_project_schedule, 'featured_accounts': [ - p.access_for(roles={'all'}, datasets=('primary', 'related')) + p.current_access(datasets=('primary', 'related')) for p in featured_accounts ], } From c3e0ddab721b6153c99de40b9c0825b1d28c80ba Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 Dec 2023 17:10:56 +0530 Subject: [PATCH 09/10] [pre-commit.ci] pre-commit autoupdate (#1940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.6 → v0.1.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.6...v0.1.7) - [github.com/PyCQA/isort: 5.12.0 → 5.13.0](https://github.com/PyCQA/isort/compare/5.12.0...5.13.0) - [github.com/PyCQA/bandit: 1.7.5 → 1.7.6](https://github.com/PyCQA/bandit/compare/1.7.5...1.7.6) - [github.com/pre-commit/mirrors-prettier: v3.1.0 → v4.0.0-alpha.4](https://github.com/pre-commit/mirrors-prettier/compare/v3.1.0...v4.0.0-alpha.4) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Vidya Ramakrishnan --- .pre-commit-config.yaml | 11 +++++------ .prettierrc.js | 1 + 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 126c39fb8..5a3d5406d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - id: pyupgrade args: ['--keep-runtime-typing', '--py311-plus'] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.6 + rev: v0.1.7 hooks: - id: ruff args: ['--fix', '--exit-non-zero-on-fix'] @@ -91,7 +91,7 @@ repos: - toml - tomli - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.0 hooks: - id: isort additional_dependencies: @@ -138,7 +138,7 @@ repos: additional_dependencies: - tomli - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 + rev: 1.7.6 hooks: - id: bandit language_version: python3 @@ -208,11 +208,10 @@ repos: - id: forbid-tabs - id: remove-tabs - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 + rev: v4.0.0-alpha.4 hooks: - id: prettier - args: - ['--single-quote', '--trailing-comma', 'es5', '--end-of-line', 'lf'] + args: ['--single-quote', '--trailing-comma', 'es5', '--end-of-line', 'lf'] exclude: funnel/templates/js/ - repo: https://github.com/ducminh-phan/reformat-gherkin rev: v3.0.1 diff --git a/.prettierrc.js b/.prettierrc.js index c90450753..1aed56ad4 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -2,4 +2,5 @@ module.exports = { endOfLine: 'lf', singleQuote: true, trailingComma: 'es5', + printWidth: 88, }; From 1a1b2e8db1fabb0c8aeadf7f9b205acd080479c9 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Tue, 12 Dec 2023 23:23:13 +0530 Subject: [PATCH 10/10] Refactor views to use generic arg support in ModelView (#1941) With related fixes discovered during refactoring. --- funnel/forms/account.py | 15 +-- funnel/forms/auth_client.py | 6 +- funnel/forms/organization.py | 4 +- funnel/models/auth_client.py | 4 +- funnel/models/helpers.py | 12 +- funnel/models/label.py | 4 +- funnel/models/membership_mixin.py | 23 +++- funnel/proxies/request.py | 13 +- funnel/transports/sms/template.py | 5 +- funnel/views/account.py | 10 +- funnel/views/auth_client.py | 44 ++----- funnel/views/comment.py | 50 +++----- funnel/views/contact.py | 5 +- funnel/views/index.py | 58 +++++---- funnel/views/label.py | 21 +--- funnel/views/membership.py | 80 +++++------- funnel/views/mixins.py | 151 +++++------------------ funnel/views/notification_feed.py | 5 +- funnel/views/notification_preferences.py | 5 +- funnel/views/notifications/mixins.py | 6 +- funnel/views/organization.py | 23 +--- funnel/views/profile.py | 11 +- funnel/views/project.py | 32 ++--- funnel/views/project_sponsor.py | 28 ++--- funnel/views/proposal.py | 90 +++++--------- funnel/views/schedule.py | 23 +--- funnel/views/search.py | 147 ++++++++-------------- funnel/views/session.py | 40 ++++-- funnel/views/siteadmin.py | 5 +- funnel/views/sitemap.py | 5 +- funnel/views/ticket_event.py | 50 +++----- funnel/views/ticket_participant.py | 49 +++----- funnel/views/update.py | 27 ++-- funnel/views/venue.py | 61 +++++++-- pyproject.toml | 33 ++--- tests/unit/transports/sms_send_test.py | 15 +-- 36 files changed, 450 insertions(+), 710 deletions(-) diff --git a/funnel/forms/account.py b/funnel/forms/account.py index 0060c8be5..1182ffd3e 100644 --- a/funnel/forms/account.py +++ b/funnel/forms/account.py @@ -19,6 +19,7 @@ PASSWORD_MIN_LENGTH, Account, Anchor, + User, check_password_strength, getuser, ) @@ -158,7 +159,7 @@ class PasswordForm(forms.Form): """Form to validate a user's password, for password-gated sudo actions.""" __expects__ = ('edit_user',) - edit_user: Account + edit_user: User password = forms.PasswordField( __("Password"), @@ -181,7 +182,7 @@ class PasswordPolicyForm(forms.Form): __expects__ = ('edit_user',) __returns__ = ('password_strength', 'is_weak', 'warning', 'suggestions') - edit_user: Account + edit_user: User password_strength: int | None = None is_weak: bool | None = None warning: str | None = None @@ -252,7 +253,7 @@ class PasswordCreateForm(forms.Form): __returns__ = ('password_strength',) __expects__ = ('edit_user',) - edit_user: Account + edit_user: User password_strength: int | None = None password = forms.PasswordField( @@ -334,7 +335,7 @@ class PasswordChangeForm(forms.Form): __returns__ = ('password_strength',) __expects__ = ('edit_user',) - edit_user: Account + edit_user: User password_strength: int | None = None old_password = forms.PasswordField( @@ -473,7 +474,7 @@ class UsernameAvailableForm(forms.Form): """Form to check for whether a username is available to use.""" __expects__ = ('edit_user',) - edit_user: Account + edit_user: User username = forms.StringField( __("Username"), @@ -519,7 +520,7 @@ class NewEmailAddressForm( """Form to add a new email address to an account.""" __expects__ = ('edit_user',) - edit_user: Account + edit_user: User email = forms.EmailField( __("Email address"), @@ -566,7 +567,7 @@ class NewPhoneForm(EnableNotificationsDescriptionProtoMixin, forms.RecaptchaForm """Form to add a new mobile number (SMS-capable) to an account.""" __expects__ = ('edit_user',) - edit_user: Account + edit_user: User phone = forms.TelField( __("Phone number"), diff --git a/funnel/forms/auth_client.py b/funnel/forms/auth_client.py index bc2b5db87..7a5597ae3 100644 --- a/funnel/forms/auth_client.py +++ b/funnel/forms/auth_client.py @@ -12,6 +12,7 @@ AuthClient, AuthClientCredential, AuthClientPermissions, + User, valid_name, ) from .helpers import strip_filters @@ -29,7 +30,8 @@ class AuthClientForm(forms.Form): """Register a new OAuth client application.""" __returns__ = ('account',) - account: Account | None = None + edit_user: User + account: Account title = forms.StringField( __("Application title"), @@ -127,7 +129,7 @@ def _urls_match(self, url1: str, url2: str) -> bool: def validate_redirect_uri(self, field: forms.Field) -> None: """Validate redirect URI points to the website for confidential clients.""" if self.confidential.data and not self._urls_match( - self.website.data, field.data + self.website.data or '', field.data ): raise forms.validators.ValidationError( _("The scheme, domain and port must match that of the website URL") diff --git a/funnel/forms/organization.py b/funnel/forms/organization.py index 077b96454..ee6f712ae 100644 --- a/funnel/forms/organization.py +++ b/funnel/forms/organization.py @@ -9,7 +9,7 @@ from baseframe import _, __, forms -from ..models import Account, Team +from ..models import Account, Team, User __all__ = ['OrganizationForm', 'TeamForm'] @@ -19,7 +19,7 @@ class OrganizationForm(forms.Form): """Form for an organization's name and title.""" __expects__: Iterable[str] = ('edit_user',) - edit_user: Account + edit_user: User edit_obj: Account | None title = forms.StringField( diff --git a/funnel/models/auth_client.py b/funnel/models/auth_client.py index 8c1dc8b40..e4b82e72e 100644 --- a/funnel/models/auth_client.py +++ b/funnel/models/auth_client.py @@ -92,9 +92,9 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): __scope_null_allowed__ = True #: Account that owns this client account_id: Mapped[int] = sa.orm.mapped_column( - sa.ForeignKey('account.id'), nullable=True + sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account | None] = with_roles( + account: Mapped[Account] = with_roles( relationship( Account, foreign_keys=[account_id], diff --git a/funnel/models/helpers.py b/funnel/models/helpers.py index 9a38014e5..47d9b9ac4 100644 --- a/funnel/models/helpers.py +++ b/funnel/models/helpers.py @@ -222,7 +222,11 @@ def decorator(attr: T) -> T: # None or '' not allowed raise ValueError(f"Could not determine name for {attr!r}") if use_name in cls.__dict__: - raise AttributeError(f"{cls.__name__} already has attribute {use_name}") + raise AttributeError( + f"{cls.__name__} already has attribute {use_name}", + name=use_name, + obj=cls, + ) setattr(cls, use_name, attr) return attr @@ -291,7 +295,11 @@ def decorator(temp_cls: TempType) -> ReopenedType: ): # Refuse to overwrite existing attributes if hasattr(cls, attr): - raise AttributeError(f"{cls.__name__} already has attribute {attr}") + raise AttributeError( + f"{cls.__name__} already has attribute {attr}", + name=attr, + obj=cls, + ) # All good? Copy the attribute over... setattr(cls, attr, value) # ...And remove it from the temporary class diff --git a/funnel/models/label.py b/funnel/models/label.py index 16cfca13b..2a00a7830 100644 --- a/funnel/models/label.py +++ b/funnel/models/label.py @@ -339,7 +339,7 @@ def __getattr__(self, name: str) -> bool | str | None: Label.name == name, Label.project == self._obj.project ).one_or_none() if label is None: - raise AttributeError + raise AttributeError(f"No label {name} in {self._obj.project}") if not label.has_options: return label in self._obj.labels @@ -357,7 +357,7 @@ def __setattr__(self, name: str, value: bool) -> None: Label._archived.is_(False), ).one_or_none() if label is None: - raise AttributeError + raise AttributeError(f"No label {name} in {self._obj.project}") if not label.has_options: if value is True: diff --git a/funnel/models/membership_mixin.py b/funnel/models/membership_mixin.py index c42cbc83e..5086ad431 100644 --- a/funnel/models/membership_mixin.py +++ b/funnel/models/membership_mixin.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable from datetime import datetime as datetime_type +from types import SimpleNamespace from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar from uuid import UUID @@ -643,6 +644,10 @@ class AmendMembership(Generic[MembershipType]): to any attribute listed as a data column. """ + membership: MembershipType + _actor: Account + _new: dict[str, Any] + def __init__(self, membership: MembershipType, actor: Account) -> None: """Create an amendment placeholder.""" if membership.revoked_at is not None: @@ -650,8 +655,8 @@ def __init__(self, membership: MembershipType, actor: Account) -> None: "This membership record has already been revoked" ) object.__setattr__(self, 'membership', membership) - object.__setattr__(self, '_new', {}) object.__setattr__(self, '_actor', actor) + object.__setattr__(self, '_new', {}) def __getattr__(self, attr: str) -> Any: """Get an attribute from the underlying record.""" @@ -662,7 +667,13 @@ def __getattr__(self, attr: str) -> Any: def __setattr__(self, attr: str, value: Any) -> None: """Set an amended value.""" if attr not in self.membership.__data_columns__: - raise AttributeError(f"{attr} cannot be set") + raise AttributeError( + f"{attr} cannot be set", + name=attr, + obj=SimpleNamespace( + **{_: None for _ in self.membership.__data_columns__} + ), + ) self._new[attr] = value def __enter__(self) -> AmendMembership: @@ -697,10 +708,14 @@ def _confirm_enumerated_mixins(_mapper: Any, cls: type[Account]) -> None: if attr_relationship is None: raise AttributeError( f'{cls.__name__} does not have a relationship named' - f' {attr_name!r} targeting a subclass of {expected_class.__name__}' + f' {attr_name!r} targeting a subclass of {expected_class.__name__}', + name=attr_name, + obj=cls, ) if not issubclass(attr_relationship.property.mapper.class_, expected_class): raise AttributeError( f'{cls.__name__}.{attr_name} should be a relationship to a' - f' subclass of {expected_class.__name__}' + f' subclass of {expected_class.__name__}', + name=attr_name, + obj=cls, ) diff --git a/funnel/proxies/request.py b/funnel/proxies/request.py index c709ebb7a..38c66548d 100644 --- a/funnel/proxies/request.py +++ b/funnel/proxies/request.py @@ -4,9 +4,10 @@ from collections.abc import Callable from functools import wraps -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from flask import has_request_context, request +from flask.globals import request_ctx from werkzeug.local import LocalProxy from werkzeug.utils import cached_property @@ -121,25 +122,23 @@ def hx_prompt(self) -> str | None: def _get_current_object(self) -> RequestWants: """Type hint for the LocalProxy wrapper method.""" + return self def _get_request_wants() -> RequestWants: """Get request_wants from the request.""" - # Flask 2.0 deprecated use of _request_ctx_stack.top and recommends using `g`. - # However, `g` is not suitable for us as we must cache results for a request only. - # Therefore we stick it in the request object itself. if has_request_context(): # pylint: disable=protected-access - wants = getattr(request, '_request_wants', None) + wants = getattr(request_ctx, 'request_wants', None) if wants is None: wants = RequestWants() - request._request_wants = wants # type: ignore[attr-defined] + request_ctx.request_wants = wants # type: ignore[attr-defined] return wants # Return an empty handler return RequestWants() -request_wants: RequestWants = LocalProxy(_get_request_wants) # type: ignore[assignment] +request_wants: RequestWants = cast(RequestWants, LocalProxy(_get_request_wants)) def response_varies(response: ResponseType) -> ResponseType: diff --git a/funnel/transports/sms/template.py b/funnel/transports/sms/template.py index 65b0d55bf..a9ae13af5 100644 --- a/funnel/transports/sms/template.py +++ b/funnel/transports/sms/template.py @@ -6,6 +6,7 @@ from enum import Enum from re import Pattern from string import Formatter +from types import SimpleNamespace from typing import Any, ClassVar, cast from flask import Flask @@ -249,7 +250,9 @@ def __getattr__(self, attr: str) -> Any: try: return self._format_kwargs[attr] except KeyError as exc: - raise AttributeError(attr) from exc + raise AttributeError( + attr, name=attr, obj=SimpleNamespace(**self._format_kwargs) + ) from exc def __getitem__(self, key: str) -> Any: """Get a format variable via dictionary access, defaulting to ''.""" diff --git a/funnel/views/account.py b/funnel/views/account.py index 283534470..3890faf5d 100644 --- a/funnel/views/account.py +++ b/funnel/views/account.py @@ -290,20 +290,15 @@ def login_session_service(obj: LoginSession) -> str | None: return None -@route('/account') +@route('/account', init_app=app) class AccountView(ClassView): """Account management views.""" __decorators__ = [requires_login] - obj: Account current_section = 'account' # needed for showing active tab SavedProjectForm = SavedProjectForm - def loader(self, **kwargs) -> Account: - """Return current user.""" - return current_auth.user - @route('', endpoint='account') @render_with('account.html.jinja2') def account(self) -> ReturnRenderWith: @@ -882,9 +877,6 @@ def delete(self): ) -AccountView.init_app(app) - - # --- Compatibility routes ------------------------------------------------------------- diff --git a/funnel/views/auth_client.py b/funnel/views/auth_client.py index 558d526c2..6eb7e2e36 100644 --- a/funnel/views/auth_client.py +++ b/funnel/views/auth_client.py @@ -64,7 +64,7 @@ def available_client_owners() -> list[tuple[str, str]]: return choices -@route('/apps/new', methods=['GET', 'POST']) +@route('/apps/new', methods=['GET', 'POST'], init_app=app) class AuthClientCreateView(ClassView): @route('', endpoint='authclient_new') @requires_login @@ -93,15 +93,10 @@ def new(self) -> ReturnView: ) -AuthClientCreateView.init_app(app) - - @AuthClient.views('main') -@route('/apps/info/') -class AuthClientView(UrlForView, ModelView): - model = AuthClient +@route('/apps/info/', init_app=app) +class AuthClientView(UrlForView, ModelView[AuthClient]): route_model_map = {'client': 'buid'} - obj: AuthClient def loader(self, client: str) -> AuthClient: return AuthClient.query.filter(AuthClient.buid == client).one_or_404() @@ -255,17 +250,13 @@ def permission_user_new(self) -> ReturnView: ) -AuthClientView.init_app(app) - # --- Routes: client credentials ---------------------------------------------- @AuthClientCredential.views('main') -@route('/apps/info//cred/') -class AuthClientCredentialView(UrlForView, ModelView): - model = AuthClientCredential +@route('/apps/info//cred/', init_app=app) +class AuthClientCredentialView(UrlForView, ModelView[AuthClientCredential]): route_model_map = {'client': 'auth_client.buid', 'name': 'name'} - obj: AuthClientCredential def loader(self, client: str, name: str) -> AuthClientCredential: return ( @@ -290,18 +281,13 @@ def delete(self) -> ReturnView: ) -AuthClientCredentialView.init_app(app) - - # --- Routes: client app permissions ------------------------------------------ @AuthClientPermissions.views('main') -@route('/apps/info//perms/u/') -class AuthClientPermissionsView(UrlForView, ModelView): - model = AuthClientPermissions +@route('/apps/info//perms/u/', init_app=app) +class AuthClientPermissionsView(UrlForView, ModelView[AuthClientPermissions]): route_model_map = {'client': 'auth_client.buid', 'account': 'account.buid'} - obj: AuthClientPermissions def loader(self, client: str, account: str) -> AuthClientPermissions: return ( @@ -363,22 +349,17 @@ def delete(self) -> ReturnView: ).format( pname=self.obj.account.pickername, title=self.obj.auth_client.title ), - success=_("You have revoked permisions for user {pname}").format( + success=_("You have revoked permissions for user {pname}").format( pname=self.obj.account.pickername ), next=self.obj.auth_client.url_for(), ) -AuthClientPermissionsView.init_app(app) - - @AuthClientTeamPermissions.views('main') -@route('/apps/info//perms/t/') -class AuthClientTeamPermissionsView(UrlForView, ModelView): - model = AuthClientTeamPermissions +@route('/apps/info//perms/t/', init_app=app) +class AuthClientTeamPermissionsView(UrlForView, ModelView[AuthClientTeamPermissions]): route_model_map = {'client': 'auth_client.buid', 'team': 'team.buid'} - obj: AuthClientTeamPermissions def loader(self, client: str, team: str) -> AuthClientTeamPermissions: return ( @@ -438,11 +419,8 @@ def delete(self) -> ReturnView: message=_( "Remove all permissions assigned to team ‘{pname}’ for app ‘{title}’?" ).format(pname=self.obj.team.title, title=self.obj.auth_client.title), - success=_("You have revoked permisions for team {title}").format( + success=_("You have revoked permissions for team {title}").format( title=self.obj.team.title ), next=self.obj.auth_client.url_for(), ) - - -AuthClientTeamPermissionsView.init_app(app) diff --git a/funnel/views/comment.py b/funnel/views/comment.py index ccbebf2cc..8f5056e95 100644 --- a/funnel/views/comment.py +++ b/funnel/views/comment.py @@ -99,7 +99,7 @@ def last_comment(obj: Commentset) -> Comment | None: return None -@route('/comments') +@route('/comments', init_app=app) class AllCommentsView(ClassView): """View for index of commentsets.""" @@ -143,9 +143,6 @@ def view(self, page: int = 1, per_page: int = 20) -> ReturnRenderWith: return result -AllCommentsView.init_app(app) - - def do_post_comment( commentset: Commentset, actor: Account, @@ -164,13 +161,11 @@ def do_post_comment( return comment -@route('/comments/') -class CommentsetView(UrlForView, ModelView): +@route('/comments/', init_app=app) +class CommentsetView(UrlForView, ModelView[Commentset]): """Views for commentset display within a host document.""" - model = Commentset route_model_map = {'commentset': 'uuid_b58'} - obj: Commentset def loader(self, commentset: str) -> Commentset: return Commentset.query.filter(Commentset.uuid_b58 == commentset).one_or_404() @@ -269,38 +264,26 @@ def update_last_seen_at(self) -> ReturnRenderWith: }, 422 -CommentsetView.init_app(app) - - -@route('/comments//') -class CommentView(UrlForView, ModelView): +@route('/comments//', init_app=app) +class CommentView(UrlForView, ModelView[Comment]): """Views for a single comment.""" - model = Comment route_model_map = {'commentset': 'commentset.uuid_b58', 'comment': 'uuid_b58'} - obj: Comment - def loader(self, commentset: str, comment: str) -> Comment | Commentset: - comment = ( + def load(self, commentset: str, comment: str) -> ReturnView | None: + obj = ( Comment.query.join(Commentset) .filter(Commentset.uuid_b58 == commentset, Comment.uuid_b58 == comment) .one_or_none() ) - if comment is None: - # if the comment doesn't exist or deleted, return the commentset, - # `after_loader()` will redirect to the commentset instead. - return Commentset.query.filter( - Commentset.uuid_b58 == commentset - ).one_or_404() - return comment - - def after_loader(self) -> ReturnView | None: - if isinstance(self.obj, Commentset): - flash( - _("That comment could not be found. It may have been deleted"), 'error' - ) - return render_redirect(self.obj.url_for()) - return super().after_loader() + if obj is not None: + self.obj = obj + return None + commentset_obj = Commentset.query.filter( + Commentset.uuid_b58 == commentset + ).one_or_404() + flash(_("That comment could not be found. It may have been deleted"), 'error') + return render_redirect(commentset_obj.url_for()) @route('') @requires_roles({'reader'}) @@ -446,6 +429,3 @@ def report_spam(self) -> ReturnView: with_chrome=False, ).get_data(as_text=True) return {'status': 'ok', 'form': reportspamform_html} - - -CommentView.init_app(app) diff --git a/funnel/views/contact.py b/funnel/views/contact.py index 43064c7ac..89b8d97f6 100644 --- a/funnel/views/contact.py +++ b/funnel/views/contact.py @@ -31,7 +31,7 @@ def contact_details(ticket_participant: TicketParticipant) -> dict[str, str | No } -@route('/account/contacts') +@route('/account/contacts', init_app=app) class ContactView(ClassView): current_section = 'account' @@ -182,6 +182,3 @@ def connect(self, puk: str, key: str) -> ReturnView: 'error': '403', 'message': _("Unauthorized contact exchange"), }, 403 - - -ContactView.init_app(app) diff --git a/funnel/views/index.py b/funnel/views/index.py index 070e6f3f3..da5b5b6ea 100644 --- a/funnel/views/index.py +++ b/funnel/views/index.py @@ -38,7 +38,7 @@ class PolicyPage: ] -@route('/') +@route('/', init_app=app) class IndexView(ClassView): current_section = 'home' SavedProjectForm = SavedProjectForm @@ -156,35 +156,33 @@ def home(self) -> ReturnRenderWith: ], } - -IndexView.init_app(app) - - -@app.route('/past.projects', endpoint='past_projects') -@requestargs(('page', int), ('per_page', int)) -@render_with('past_projects_section.html.jinja2') -def past_projects(page: int = 1, per_page: int = 10) -> ReturnView: - g.account = None - projects = Project.all_unsorted() - pagination = ( - projects.filter(Project.state.PAST) - .order_by(Project.start_at.desc()) - .paginate(page=page, per_page=per_page) - ) - return { - 'status': 'ok', - 'next_page': pagination.page + 1 if pagination.page < pagination.pages else '', - 'total_pages': pagination.pages, - 'past_projects': [ - { - 'title': p.title, - 'datetime': date_filter(p.end_at_localized, format='dd MMM yyyy'), - 'venue': p.primary_venue.city if p.primary_venue else p.location, - 'url': p.url_for(), - } - for p in pagination.items - ], - } + @route('past.projects', endpoint='past_projects') + @render_with('past_projects_section.html.jinja2') + @requestargs(('page', int), ('per_page', int)) + def past_projects(self, page: int = 1, per_page: int = 10) -> ReturnRenderWith: + g.account = None + projects = Project.all_unsorted() + pagination = ( + projects.filter(Project.state.PAST) + .order_by(Project.start_at.desc()) + .paginate(page=page, per_page=per_page) + ) + return { + 'status': 'ok', + 'next_page': pagination.page + 1 + if pagination.page < pagination.pages + else '', + 'total_pages': pagination.pages, + 'past_projects': [ + { + 'title': p.title, + 'datetime': date_filter(p.end_at_localized, format='dd MMM yyyy'), + 'venue': p.primary_venue.city if p.primary_venue else p.location, + 'url': p.url_for(), + } + for p in pagination.items + ], + } @app.route('/about') diff --git a/funnel/views/label.py b/funnel/views/label.py index 962c468fe..6aad55b28 100644 --- a/funnel/views/label.py +++ b/funnel/views/label.py @@ -15,12 +15,12 @@ from ..typing import ReturnRenderWith, ReturnView from .helpers import render_redirect from .login_session import requires_login, requires_sudo -from .mixins import AccountCheckMixin, ProjectViewMixin +from .mixins import AccountCheckMixin, ProjectViewBase @Project.views('label') -@route('///labels') -class ProjectLabelView(ProjectViewMixin, UrlForView, ModelView): +@route('///labels', init_app=app) +class ProjectLabelView(ProjectViewBase): @route('', methods=['GET', 'POST']) @render_with('labels.html.jinja2') @requires_login @@ -96,20 +96,15 @@ def new_label(self) -> ReturnRenderWith: } -ProjectLabelView.init_app(app) - - @Label.views('main') -@route('///labels/