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

Track count of visits to external registration URL #1880

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
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
61 changes: 38 additions & 23 deletions funnel/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'},
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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'},
Expand All @@ -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'},
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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[]"),
Expand All @@ -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'}
)

Expand Down
11 changes: 11 additions & 0 deletions funnel/models/rsvp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
5 changes: 4 additions & 1 deletion funnel/templates/project_layout.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@
</div>
{%- 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>
<form action="{{ project.url_for('register_external') }}" method="post" class="form-inline">
{{ csrf_tag() }}
<button class="register-block__btn full-width-btn mui-btn mui-btn--primary" type="submit" name="submit" value="no" data-cy="cancel-rsvp"><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></button>
</form>
{% endif %}
</div>
{% if project.current_roles.ticket_participant %}
Expand Down
36 changes: 36 additions & 0 deletions funnel/views/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -791,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'})
Expand Down
6 changes: 2 additions & 4 deletions migrations/script.py.mako
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,15 @@ Create Date: ${create_date}

"""

from typing import Optional, Tuple, Union

from alembic import op
import sqlalchemy as sa
${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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Add external registration visit counts.

Revision ID: 60bc4469ec36
Revises: 017c60414c03
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 = '017c60414c03'
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')