From 972450da02645a2974d47653c77e3994b3727dd9 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 20 Sep 2023 15:42:13 +0200 Subject: [PATCH 1/3] Track count of visits to external registration URL --- funnel/models/project.py | 61 ++++++++++++------- funnel/models/rsvp.py | 11 ++++ funnel/views/project.py | 29 +++++++++ migrations/script.py.mako | 6 +- ..._add_external_registration_visit_counts.py | 47 ++++++++++++++ 5 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 migrations/versions/60bc4469ec36_add_external_registration_visit_counts.py diff --git a/funnel/models/project.py b/funnel/models/project.py index 5bf0787bc..dab0c2024 100644 --- a/funnel/models/project.py +++ b/funnel/models/project.py @@ -3,7 +3,9 @@ from __future__ import annotations from collections.abc import Sequence +from datetime import datetime +from furl import furl from pytz import utc from sqlalchemy.orm import attribute_keyed_dict from werkzeug.utils import cached_property @@ -71,12 +73,16 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): __tablename__ = 'project' reserved_names = RESERVED_NAMES - created_by_id = sa.orm.mapped_column(sa.ForeignKey('account.id'), nullable=False) + created_by_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) created_by: Mapped[Account] = relationship( Account, foreign_keys=[created_by_id], ) - account_id = sa.orm.mapped_column(sa.ForeignKey('account.id'), nullable=False) + account_id: Mapped[int] = sa.orm.mapped_column( + sa.ForeignKey('account.id'), nullable=False + ) account: Mapped[Account] = with_roles( relationship( Account, @@ -112,14 +118,14 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): ) = MarkdownCompositeDocument.create('instructions', default='', nullable=True) with_roles(instructions, read={'all'}) - location = with_roles( + location: Mapped[str | None] = with_roles( sa.orm.mapped_column(sa.Unicode(50), default='', nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) parsed_location: Mapped[types.jsonb_dict] - website = with_roles( + website: Mapped[furl | None] = with_roles( sa.orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, @@ -130,7 +136,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): datasets={'primary', 'without_parent', 'related'}, ) - _state = sa.orm.mapped_column( + _state: Mapped[int] = sa.orm.mapped_column( 'state', sa.Integer, StateManager.check_constraint('state', PROJECT_STATE), @@ -141,7 +147,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): state = with_roles( StateManager('_state', PROJECT_STATE, doc="Project state"), call={'all'} ) - _cfp_state = sa.orm.mapped_column( + _cfp_state: Mapped[int] = sa.orm.mapped_column( 'cfp_state', sa.Integer, StateManager.check_constraint('cfp_state', CFP_STATE), @@ -154,39 +160,39 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): ) #: Audit timestamp to detect re-publishing to re-surface a project - first_published_at = sa.orm.mapped_column( + first_published_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of when this project was most recently published - published_at = with_roles( + published_at: Mapped[datetime | None] = with_roles( sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'promoter'}, datasets={'primary', 'without_parent', 'related'}, ) #: Optional start time for schedule, cached from column property schedule_start_at - start_at = with_roles( + start_at: Mapped[datetime | None] = with_roles( sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'editor'}, datasets={'primary', 'without_parent', 'related'}, ) #: Optional end time for schedule, cached from column property schedule_end_at - end_at = with_roles( + end_at: Mapped[datetime | None] = with_roles( sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True, index=True), read={'all'}, write={'editor'}, datasets={'primary', 'without_parent', 'related'}, ) - cfp_start_at = sa.orm.mapped_column( + cfp_start_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True ) - cfp_end_at = sa.orm.mapped_column( + cfp_end_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True, index=True ) - bg_image = with_roles( + bg_image: Mapped[furl | None] = with_roles( sa.orm.mapped_column(ImgeeType, nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, @@ -196,13 +202,22 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - buy_tickets_url: Mapped[str | None] = with_roles( + buy_tickets_url: Mapped[furl | None] = with_roles( sa.orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent', 'related'}, ) - - banner_video_url = with_roles( + buy_tickets_visits_anon: Mapped[int | None] = with_roles( + sa.orm.mapped_column(sa.Integer(), nullable=True), + read={'promoter'}, + datasets={'primary', 'without_parent', 'related'}, + ) + buy_tickets_visits_auth: Mapped[int | None] = with_roles( + sa.orm.mapped_column(sa.Integer(), nullable=True), + read={'promoter'}, + datasets={'primary', 'without_parent', 'related'}, + ) + banner_video_url: Mapped[furl | None] = with_roles( sa.orm.mapped_column(UrlType, nullable=True), read={'all'}, datasets={'primary', 'without_parent'}, @@ -216,14 +231,14 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): call={'all'}, ) - hasjob_embed_url = with_roles( + hasjob_embed_url: Mapped[furl | None] = with_roles( sa.orm.mapped_column(UrlType, nullable=True), read={'all'} ) - hasjob_embed_limit = with_roles( + hasjob_embed_limit: Mapped[int | None] = with_roles( sa.orm.mapped_column(sa.Integer, default=8), read={'all'} ) - commentset_id = sa.orm.mapped_column( + commentset_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('commentset.id'), nullable=False ) commentset: Mapped[Commentset] = relationship( @@ -234,7 +249,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): back_populates='project', ) - parent_id = sa.orm.mapped_column( + parent_id: Mapped[int] = sa.orm.mapped_column( sa.Integer, sa.ForeignKey('project.id', ondelete='SET NULL'), nullable=True ) parent_project: Mapped[Project | None] = relationship( @@ -243,14 +258,14 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): #: Featured project flag. This can only be set by website editors, not #: project editors or account admins. - site_featured = with_roles( + site_featured: Mapped[bool] = with_roles( sa.orm.mapped_column(sa.Boolean, default=False, nullable=False), read={'all'}, write={'site_editor'}, datasets={'primary', 'without_parent'}, ) - livestream_urls = with_roles( + livestream_urls: Mapped[list[str] | None] = with_roles( sa.orm.mapped_column( sa.ARRAY(sa.UnicodeText, dimensions=1), server_default=sa.text("'{}'::text[]"), @@ -266,7 +281,7 @@ class Project(UuidMixin, BaseScopedNameMixin, Model): ) #: Revision number maintained by SQLAlchemy, used for vCal files, starting at 1 - revisionid = with_roles( + revisionid: Mapped[int] = with_roles( sa.orm.mapped_column(sa.Integer, nullable=False), read={'all'} ) diff --git a/funnel/models/rsvp.py b/funnel/models/rsvp.py index a5bd82361..462a37559 100644 --- a/funnel/models/rsvp.py +++ b/funnel/models/rsvp.py @@ -128,6 +128,17 @@ def rsvp_no(self): def rsvp_maybe(self): pass + @with_roles(call={'owner'}) + @state.transition( + None, + state.AWAITING, + title=__("Awaiting"), + message=__("Your response has been saved"), + type='accent', + ) + def rsvp_awaiting(self): + pass + @with_roles(call={'owner', 'project_promoter'}) def participant_email(self) -> AccountEmail | None: """Participant's preferred email address for this registration.""" diff --git a/funnel/views/project.py b/funnel/views/project.py index 94a1e10f7..91915037d 100644 --- a/funnel/views/project.py +++ b/funnel/views/project.py @@ -727,6 +727,35 @@ def deregister(self) -> ReturnView: ) return render_redirect(self.obj.url_for()) + @route('register_external', methods=['POST']) + def register_external(self) -> ReturnView: + """Register on an external website.""" + if not self.obj.buy_tickets_url: + flash( + _("This project does not have an external registration link"), 'error' + ) + return render_redirect(self.obj.url_for()) + form = forms.Form() + if form.validate_on_submit(): + if current_auth: + rsvp = Rsvp.get_for(self.obj, current_auth.user, create=True) + rsvp.rsvp_awaiting() + self.obj.buy_tickets_visits_auth = ( + sa.func.coalesce(Project.buy_tickets_visits_auth, 0) + 1 + ) + else: + self.obj.buy_tickets_visits_anon = ( + sa.func.coalesce(Project.buy_tickets_visits_anon, 0) + 1 + ) + db.session.commit() + return render_redirect(str(self.obj.buy_tickets_url)) + # CSRF failure response: + flash( + _("Were you trying to visit the registration link? Try again to confirm"), + 'error', + ) + return render_redirect(self.obj.url_for()) + @route('rsvp_list') @render_with('project_rsvp_list.html.jinja2') @requires_login diff --git a/migrations/script.py.mako b/migrations/script.py.mako index af188f4d1..f132556e3 100644 --- a/migrations/script.py.mako +++ b/migrations/script.py.mako @@ -9,8 +9,6 @@ Create Date: ${create_date} """ -from typing import Optional, Tuple, Union - from alembic import op import sqlalchemy as sa ${imports if imports else ""} @@ -18,8 +16,8 @@ ${imports if imports else ""} # revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} down_revision: str = ${repr(down_revision)} -branch_labels: Optional[Union[str, Tuple[str, ...]]] = ${repr(branch_labels)} -depends_on: Optional[Union[str, Tuple[str, ...]]] = ${repr(depends_on)} +branch_labels: str | tuple[str, ...] | None = ${repr(branch_labels)} +depends_on: str | tuple[str, ...] | None = ${repr(depends_on)} def upgrade(engine_name: str = '') -> None: diff --git a/migrations/versions/60bc4469ec36_add_external_registration_visit_counts.py b/migrations/versions/60bc4469ec36_add_external_registration_visit_counts.py new file mode 100644 index 000000000..2efe0797a --- /dev/null +++ b/migrations/versions/60bc4469ec36_add_external_registration_visit_counts.py @@ -0,0 +1,47 @@ +"""Add external registration visit counts. + +Revision ID: 60bc4469ec36 +Revises: 4f9ca10b7b9d +Create Date: 2023-09-20 15:35:34.613245 + +""" + + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '60bc4469ec36' +down_revision: str = '4f9ca10b7b9d' +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade(engine_name: str = '') -> None: + """Upgrade all databases.""" + # Do not modify. Edit `upgrade_` instead + globals().get(f'upgrade_{engine_name}', lambda: None)() + + +def downgrade(engine_name: str = '') -> None: + """Downgrade all databases.""" + # Do not modify. Edit `downgrade_` instead + globals().get(f'downgrade_{engine_name}', lambda: None)() + + +def upgrade_() -> None: + """Upgrade default database.""" + with op.batch_alter_table('project', schema=None) as batch_op: + batch_op.add_column( + sa.Column('buy_tickets_visits_anon', sa.Integer(), nullable=True) + ) + batch_op.add_column( + sa.Column('buy_tickets_visits_auth', sa.Integer(), nullable=True) + ) + + +def downgrade_() -> None: + """Downgrade default database.""" + with op.batch_alter_table('project', schema=None) as batch_op: + batch_op.drop_column('buy_tickets_visits_auth') + batch_op.drop_column('buy_tickets_visits_anon') From 7894465cd27ae338f4177d7f7930f66c1237330d Mon Sep 17 00:00:00 2001 From: Vidya Ramakrishnan Date: Wed, 20 Sep 2023 23:08:41 +0530 Subject: [PATCH 2/3] Make the external register link a POST request --- funnel/templates/project_layout.html.jinja2 | 5 ++++- funnel/views/project.py | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/funnel/templates/project_layout.html.jinja2 b/funnel/templates/project_layout.html.jinja2 index 6f5329543..2a7b733d1 100644 --- a/funnel/templates/project_layout.html.jinja2 +++ b/funnel/templates/project_layout.html.jinja2 @@ -172,7 +172,10 @@ {%- 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() }} +
+ {{ csrf_tag() }} + +
{% endif %} {% if project.current_roles.ticket_participant %} diff --git a/funnel/views/project.py b/funnel/views/project.py index 91915037d..79966d032 100644 --- a/funnel/views/project.py +++ b/funnel/views/project.py @@ -820,6 +820,13 @@ def rsvp_list_maybe_csv(self) -> ReturnView: """Return a CSV of RSVP participants who answered Maybe.""" return self.get_rsvp_state_csv(state=RSVP_STATUS.MAYBE) + @route('rsvp_list/awaiting.csv') + @requires_login + @requires_roles({'promoter'}) + def rsvp_list_awaiting_csv(self) -> ReturnView: + """Return a CSV of RSVP participants with response status Awaiting.""" + return self.get_rsvp_state_csv(state=RSVP_STATUS.AWAITING) + @route('save', methods=['POST']) @requires_login @requires_roles({'reader'}) From 53f70d980548ef80ce22db5b592c81de27e53124 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Sat, 7 Oct 2023 13:49:58 +0530 Subject: [PATCH 3/3] Rebase db migration --- .../60bc4469ec36_add_external_registration_visit_counts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/versions/60bc4469ec36_add_external_registration_visit_counts.py b/migrations/versions/60bc4469ec36_add_external_registration_visit_counts.py index 2efe0797a..476e1f1b9 100644 --- a/migrations/versions/60bc4469ec36_add_external_registration_visit_counts.py +++ b/migrations/versions/60bc4469ec36_add_external_registration_visit_counts.py @@ -1,7 +1,7 @@ """Add external registration visit counts. Revision ID: 60bc4469ec36 -Revises: 4f9ca10b7b9d +Revises: 017c60414c03 Create Date: 2023-09-20 15:35:34.613245 """ @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision: str = '60bc4469ec36' -down_revision: str = '4f9ca10b7b9d' +down_revision: str = '017c60414c03' branch_labels: str | tuple[str, ...] | None = None depends_on: str | tuple[str, ...] | None = None