Skip to content

Commit

Permalink
Waiting List (#505)
Browse files Browse the repository at this point in the history
* Add InviteBucket model and migration:
* Add model and migration
* Add invite bucket factory
* Add simple test to ensure relationships work as intended
* Update test_delete_account to include invite and invite_bucket (and external connections)
* Add route to add an email to the invite bucket
* InviteBucket -> WaitingList
* Add email_verified property to WaitingList
* Add Confirm Email mail for WaitingList
* Includes a confirm and leave action
* Include signed tokens to verify email links
* Add tests for the waiting list functions
* Downgrade eslint back to 8
* Hookup login screen to allow joining the wait list
* Add a waiting list action view with messages for confirming or leaving wait list
* 🌐 Update German translation

---------

Co-authored-by: Andreas Müller <[email protected]>
  • Loading branch information
MelissaAutumn and devmount authored Jul 2, 2024
1 parent e5eb6ef commit 72ba61e
Show file tree
Hide file tree
Showing 31 changed files with 1,054 additions and 151 deletions.
14 changes: 13 additions & 1 deletion backend/src/appointment/controller/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from io import StringIO, BytesIO
from zipfile import ZipFile

from ..database import repo
from ..database import repo, models
from ..database.schemas import Subscriber
from ..exceptions.account_api import AccountDeletionPartialFail, AccountDeletionSubscriberFail
from ..l10n import l10n
Expand Down Expand Up @@ -45,6 +45,8 @@ def download(db, subscriber: Subscriber):
external_connections = subscriber.external_connections
schedules = repo.schedule.get_by_subscriber(db, subscriber.id)
availability = [repo.schedule.get_availability(db, schedule.id) for schedule in schedules]
invite = repo.invite.get_by_subscriber(db, subscriber.id)
waiting_list = invite.waiting_list

# Convert models to csv
attendee_buffer = model_to_csv_buffer(attendees)
Expand All @@ -54,6 +56,8 @@ def download(db, subscriber: Subscriber):
slot_buffer = model_to_csv_buffer(slots)
external_connections_buffer = model_to_csv_buffer(external_connections)
schedules_buffer = model_to_csv_buffer(schedules)
invite_buffer = model_to_csv_buffer([invite])
waiting_list_buffer = model_to_csv_buffer([waiting_list])

# Unique behaviour because we can have lists of lists..too annoying to not do it this way.
availability_buffer = ''
Expand All @@ -71,6 +75,8 @@ def download(db, subscriber: Subscriber):
data_zip.writestr('external_connection.csv', external_connections_buffer.getvalue())
data_zip.writestr('schedules.csv', schedules_buffer.getvalue())
data_zip.writestr('availability.csv', availability_buffer)
data_zip.writestr('invite.csv', invite_buffer.getvalue())
data_zip.writestr('waiting_list.csv', waiting_list_buffer.getvalue())
data_zip.writestr(
'readme.txt', l10n('account-data-readme', {'download_time': datetime.datetime.now(datetime.UTC)})
)
Expand All @@ -90,12 +96,18 @@ def delete_account(db, subscriber: Subscriber):
l10n('account-delete-fail'),
)

# A list of connected account data, if any value is True then we've failed
empty_check = [
len(repo.attendee.get_by_subscriber(db, subscriber.id)),
len(repo.slot.get_by_subscriber(db, subscriber.id)),
len(repo.appointment.get_by_subscriber(db, subscriber.id)),
len(repo.calendar.get_by_subscriber(db, subscriber.id)),
len(repo.schedule.get_by_subscriber(db, subscriber.id)),
len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.fxa)),
len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.google)),
len(repo.external_connection.get_by_type(db, subscriber.id, models.ExternalConnectionType.zoom)),
repo.invite.get_by_subscriber(db, subscriber.id),
repo.invite.get_waiting_list_entry_by_email(db, subscriber.email)
]

# Check if we have any left-over subscriber data
Expand Down
23 changes: 23 additions & 0 deletions backend/src/appointment/controller/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,26 @@ def text(self):

def html(self):
return get_template('new_account.jinja2').render(homepage_url=os.getenv('FRONTEND_URL'))


class ConfirmYourEmailMail(Mailer):
def __init__(self, confirm_url, decline_url, *args, **kwargs):
default_kwargs = {'subject': l10n('confirm-email-mail-subject')}
self.confirm_url = confirm_url
self.decline_url = decline_url
super(ConfirmYourEmailMail, self).__init__(*args, **default_kwargs, **kwargs)

def text(self):
return l10n(
'confirm-email-mail-plain',
{
'confirm_email_url': self.confirm_url,
'decline_email_url': self.decline_url,
},
)

def html(self):
return get_template('confirm_email.jinja2').render(
confirm_email_url=self.confirm_url,
decline_email_url=self.decline_url
)
15 changes: 14 additions & 1 deletion backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class Subscriber(HasSoftDelete, Base):
calendars = relationship('Calendar', cascade='all,delete', back_populates='owner')
slots = relationship('Slot', cascade='all,delete', back_populates='subscriber')
external_connections = relationship('ExternalConnections', cascade='all,delete', back_populates='owner')
invite: Mapped['Invite'] = relationship('Invite', back_populates='subscriber', uselist=False)
invite: Mapped['Invite'] = relationship('Invite', cascade='all,delete', back_populates='subscriber', uselist=False)

def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConnections':
"""Retrieves the first found external connection by type or returns None if not found"""
Expand Down Expand Up @@ -341,6 +341,7 @@ class Invite(Base):
status = Column(Enum(InviteStatus), index=True)

subscriber: Mapped['Subscriber'] = relationship('Subscriber', back_populates='invite', single_parent=True)
waiting_list: Mapped['WaitingList'] = relationship('WaitingList', cascade='all,delete', back_populates='invite', uselist=False)

@property
def is_used(self) -> bool:
Expand All @@ -356,3 +357,15 @@ def is_revoked(self) -> bool:
def is_available(self) -> bool:
"""True if the invite code is not assigned nor revoked"""
return self.subscriber_id is None and self.status == InviteStatus.active


class WaitingList(Base):
"""Holds a list of hopefully future-Appointment users"""
__tablename__ = 'waiting_list'

id = Column(Integer, primary_key=True, index=True)
email = Column(encrypted_type(String), unique=True, index=True, nullable=False)
email_verified = Column(Boolean, nullable=False, index=True, default=False)
invite_id = Column(Integer, ForeignKey('invites.id'), nullable=True, index=True)

invite: Mapped['Invite'] = relationship('Invite', back_populates='waiting_list', single_parent=True)
50 changes: 49 additions & 1 deletion backend/src/appointment/database/repo/invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
from .. import models, schemas


def get_by_code(db: Session, code: str):
def get_by_subscriber(db: Session, subscriber_id: int) -> models.Invite:
return db.query(models.Invite).filter(models.Invite.subscriber_id == subscriber_id).first()


def get_by_code(db: Session, code: str) -> models.Invite:
"""retrieve invite by code"""
return db.query(models.Invite).filter(models.Invite.code == code).first()

Expand Down Expand Up @@ -69,3 +73,47 @@ def revoke_code(db: Session, code: str):
db.commit()
db.refresh(db_invite)
return True


def get_waiting_list_entry_by_email(db: Session, email: str) -> models.WaitingList:
return db.query(models.WaitingList).filter(models.WaitingList.email == email).first()


def add_to_waiting_list(db: Session, email: str):
"""Add a given email to the invite bucket"""
# Check if they're already in the invite bucket
bucket = get_waiting_list_entry_by_email(db, email)
if bucket:
# Already in waiting list
return False

bucket = models.WaitingList(email=email)
db.add(bucket)
db.commit()
db.refresh(bucket)
return True


def confirm_waiting_list_email(db: Session, email: str):
"""Flip the email_verified field to True"""
bucket = get_waiting_list_entry_by_email(db, email)
if not bucket:
return False

bucket.email_verified = True
db.add(bucket)
db.commit()
db.refresh(bucket)
return True


def remove_waiting_list_email(db: Session, email: str):
"""Remove an existing email from the waiting list"""
bucket = get_waiting_list_entry_by_email(db, email)
# Already done, lol!
if not bucket:
return True

db.delete(bucket)
db.commit()
return True
14 changes: 13 additions & 1 deletion backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from datetime import datetime, date, time
from typing import Annotated, Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, EmailStr

from .models import (
AppointmentStatus,
Expand Down Expand Up @@ -384,3 +384,15 @@ class TokenData(BaseModel):

class SendInviteEmailIn(BaseModel):
email: str = Field(title='Email', min_length=1)


class JoinTheWaitingList(BaseModel):
email: str = Field(title='Email', min_length=1)


class TokenForWaitingList(BaseModel):
token: str = Field(title='Token')


class CheckEmail(BaseModel):
email: EmailStr = Field(title='Email', min_length=1)
11 changes: 11 additions & 0 deletions backend/src/appointment/exceptions/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,14 @@ class SubscriberSelfDeleteException(APIException):

def get_msg(self):
return l10n('subscriber-self-delete')


class WaitingListActionFailed(APIException):
"""Raise if the waiting list link was valid but failed for some reason"""

id_code = 'WAITING_LIST_FAIL'
status_code = 400

def get_msg(self):
return l10n('unknown-error')

30 changes: 30 additions & 0 deletions backend/src/appointment/l10n/de/email.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ reject-mail-plain = { $owner_name } hat deine Buchungsanfrage für dieses Zeitfe
{-brand-footer}
## Pending Appointment

pending-mail-subject = deine Buchungsanfrage wartet auf Bestätigung
# Variables:
# $owner_name (String) - Name of the person who owns the schedule
Expand Down Expand Up @@ -102,3 +103,32 @@ support-mail-plain = { $requestee_name } ({ $requestee_email }) hat folgende Sup
Thema: { $topic }
Inhalt: { $details }
{-brand-footer}
## New/Invited Account Email
new-account-mail-subject = Du wurdest zu Thunderbird Appointment eingeladen
new-account-mail-action = Weiter zu Thunderbird Appointment
new-account-mail-html-heading = Du wurdest zu Thunderbird Appointment eingeladen. Logge dich mit dieser E-Mail-Adresse ein um fortzufahren.
# Variables:
# $homepage_url (String) - URL to Thunderbird Appointment
new-account-mail-plain = Du wurdest zu Thunderbird Appointment eingeladen.
Logge dich mit dieser E-Mail-Adresse ein um fortzufahren.
{ $homepage_url }
{-brand-footer}
## Confirm Email for waiting list
confirm-email-mail-subject = Bestätige deine E-Mail-Adresse um der Warteliste beizutreten!
confirm-email-mail-confirm-action = Bestätige deine E-Mail-Adresse
confirm-email-mail-decline-action = Entferne deine E-Mail-Adresse
confirm-email-mail-html-body = Danke für Dein Interesse an Thunderbird Appointment.
Bevor wir Dich auf unsere Warteliste setzen, musst Du Deine E-Mail-Adresse unten bestätigen.
confirm-email-mail-html-body-2 = Hast Du diese E-Mail irrtümlich erhalten, oder bist nicht mehr interessiert?
# Variables:
# $confirm_email_url (String) - URL to confirm your email
# $decline_email_url (String) - URL to remove the email from the waiting list
confirm-email-mail-plain = Danke für Dein Interesse an Thunderbird Appointment.
Bevor wir Dich auf unsere Warteliste setzen, musst Du Deine E-Mail-Adresse über den unten stehenden Link bestätigen.
{ $confirm_email_url }
Hast Du diese E-Mail irrtümlich erhalten, oder bist nicht mehr interessiert? Folge einfach diesem Link, um Deine E-Mail-Adresse von der Warteliste zu löschen.
{ $decline_email_url }
{-brand-footer}
18 changes: 18 additions & 0 deletions backend/src/appointment/l10n/en/email.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,21 @@ new-account-mail-plain = You've been invited to Thunderbird Appointment.
Login with this email address to continue.
{ $homepage_url }
{-brand-footer}
## Confirm Email for waiting list
confirm-email-mail-subject = Confirm your email to join the waiting list!
confirm-email-mail-confirm-action = Confirm your email
confirm-email-mail-decline-action = Remove your email
confirm-email-mail-html-body = Thank you for your interest in Thunderbird Appointment.
Before we add you to our waiting list we need you to confirm your email address below.
confirm-email-mail-html-body-2 = Did you receive this email in error, or are you no longer interested?
# Variables:
# $confirm_email_url (String) - URL to confirm your email
# $decline_email_url (String) - URL to remove the email from the waiting list
confirm-email-mail-plain = Thank you for your interest in Thunderbird Appointment.
Before we add you to our waiting list we need you to confirm your email address at the link below.
{ $confirm_email_url }
Did you receive this email in error, or are you no longer interested? Just follow this link to remove your email from our waiting list.
{ $decline_email_url }
{-brand-footer}
2 changes: 2 additions & 0 deletions backend/src/appointment/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def server():
from .routes import invite
from .routes import subscriber
from .routes import zoom
from .routes import waiting_list
from .routes import webhooks

# Hide openapi url (which will also hide docs/redoc) if we're not dev
Expand Down Expand Up @@ -180,6 +181,7 @@ async def catch_google_refresh_errors(request, exc):
app.include_router(schedule.router, prefix='/schedule')
app.include_router(invite.router, prefix='/invite')
app.include_router(subscriber.router, prefix='/subscriber')
app.include_router(waiting_list.router, prefix='/waiting-list')
app.include_router(webhooks.router, prefix='/webhooks')
if os.getenv('ZOOM_API_ENABLED'):
app.include_router(zoom.router, prefix='/zoom')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""create waiting list table
Revision ID: a9ca5a4325ec
Revises: f732d6e597fe
Create Date: 2024-06-26 22:02:19.851617
"""

from alembic import op
import sqlalchemy as sa
from sqlalchemy import ForeignKey, func

from appointment.database.models import encrypted_type

# revision identifiers, used by Alembic.
revision = 'a9ca5a4325ec'
down_revision = 'f732d6e597fe'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
'waiting_list',
sa.Column('id', sa.Integer, primary_key=True, index=True),
sa.Column('email', encrypted_type(sa.String), unique=True, index=True, nullable=False),
sa.Column('email_verified', sa.Boolean, nullable=False, index=True, default=False),
sa.Column('invite_id', sa.Integer, ForeignKey('invites.id'), nullable=True, index=True),
sa.Column('time_created', sa.DateTime, server_default=func.now()),
sa.Column('time_updated', sa.DateTime, server_default=func.now()),
)


def downgrade() -> None:
op.drop_table('waiting_list')
3 changes: 1 addition & 2 deletions backend/src/appointment/routes/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import enum
import logging
import os
import secrets
from enum import Enum

import requests.exceptions
import sentry_sdk
Expand Down
18 changes: 17 additions & 1 deletion backend/src/appointment/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sentry_sdk import capture_exception
from sqlalchemy.orm import Session

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks
from fastapi.responses import RedirectResponse

from .. import utils
Expand All @@ -26,6 +26,7 @@
from ..exceptions import validation
from ..exceptions.fxa_api import NotInAllowListException
from ..l10n import l10n
from ..tasks.emails import send_confirm_email

router = APIRouter()

Expand All @@ -41,6 +42,21 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
return encoded_jwt


@router.post('/can-login')
def can_login(
data: schemas.CheckEmail,
db: Session = Depends(get_db),
fxa_client: FxaClient = Depends(get_fxa_client)
):
"""Determines if a user can go through the login flow"""
if os.getenv('AUTH_SCHEME') == 'fxa':
# This checks if a subscriber exists, or is in allowed list
return fxa_client.is_in_allow_list(db, data.email)

# There's no waiting list setting on password login
return True


@router.get('/fxa_login')
def fxa_login(
request: Request,
Expand Down
Loading

0 comments on commit 72ba61e

Please sign in to comment.