Skip to content

Commit

Permalink
Add the ability to invite users on the waiting list.
Browse files Browse the repository at this point in the history
  • Loading branch information
MelissaAutumn committed Aug 16, 2024
1 parent 08d757c commit fc900c2
Show file tree
Hide file tree
Showing 11 changed files with 338 additions and 16 deletions.
9 changes: 9 additions & 0 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,15 @@ class CheckEmail(BaseModel):
email: EmailStr = Field(title='Email', min_length=1)


class WaitingListInviteAdminIn(BaseModel):
id_list: list[int]


class WaitingListInviteAdminOut(BaseModel):
accepted: list[int]
errors: list[str]


class WaitingListAdminOut(BaseModel):
id: int
email: str
Expand Down
2 changes: 1 addition & 1 deletion backend/src/appointment/l10n/en/email.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ support-mail-plain = { $requestee_name } ({ $requestee_email }) sent the followi
## New/Invited Account Email
new-account-mail-subject = You've been invited to Thunderbird Appointment
new-account-mail-action = Continue to Thunderbird Appointment
new-account-mail-action = Log In
new-account-mail-html-heading = You've been invited to Thunderbird Appointment.
new-account-mail-html-body = Login with this email address to continue.
# Variables:
Expand Down
4 changes: 4 additions & 0 deletions backend/src/appointment/l10n/en/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ join-online = Join online at: { $url }
# $phone (String) - An unformatted phone number for the meeting
join-phone = Join by phone: { $phone }
# Waiting List Errors
wl-subscriber-already-exists = { $email } is already a subscriber...that's weird!
wl-subscriber-failed-to-create = { $email } was unable to be invited. Please make a bug report!
## Account Data Readme

# This is a text file that is generated and bundled along with your account data
Expand Down
79 changes: 72 additions & 7 deletions backend/src/appointment/routes/waiting_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

from ..dependencies.database import get_db
from ..exceptions import validation
from ..tasks.emails import send_confirm_email
from ..l10n import l10n
from ..tasks.emails import send_confirm_email, send_invite_account_email
from itsdangerous import URLSafeSerializer, BadSignature
from secrets import token_bytes
from enum import Enum

router = APIRouter()
Expand All @@ -36,16 +36,15 @@ def join_the_waiting_list(

# If they were added, send the email
if added:
background_tasks.add_task(send_confirm_email, to=data.email, confirm_token=confirm_token, decline_token=decline_token)
background_tasks.add_task(
send_confirm_email, to=data.email, confirm_token=confirm_token, decline_token=decline_token
)

return added


@router.post('/action')
def act_on_waiting_list(
data: schemas.TokenForWaitingList,
db: Session = Depends(get_db)
):
def act_on_waiting_list(data: schemas.TokenForWaitingList, db: Session = Depends(get_db)):
"""Perform a waiting list action from a signed token"""
serializer = URLSafeSerializer(os.getenv('SIGNED_SECRET'), 'waiting-list')

Expand Down Expand Up @@ -92,3 +91,69 @@ def get_all_waiting_list_users(db: Session = Depends(get_db), _: models.Subscrib
"""List all existing waiting list users, needs admin permissions"""
response = db.query(models.WaitingList).all()
return response


@router.post('/invite', response_model=schemas.WaitingListInviteAdminOut)
def invite_waiting_list_users(
data: schemas.WaitingListInviteAdminIn,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
_: models.Subscriber = Depends(get_admin_subscriber),
):
"""Invites a list of ids to TBA
For each waiting list id:
- Retrieve the waiting list user model
- If already invited or doesn't exist, skip to next loop iteration
- If a subscriber with the same email exists then add error msg, and skip to the next loop iteration
- Create new subscriber based on the waiting list user's email
- If failed add the error msg, and skip to the next loop iteration
- Create invite code
- Attach the invite code to the subscriber and waiting list user
- Send the 'You're invited' email to the new user's email
- Done loop iteration!"""
accepted = []
errors = []

for id in data.id_list:
# Look the user up!
waiting_list_user: models.WaitingList|None = db.query(models.WaitingList).filter(models.WaitingList.id == id).first()
# If the user doesn't exist, or if they're already invited ignore them
if not waiting_list_user or waiting_list_user.invite:
continue

subscriber_check = repo.subscriber.get_by_email(db, waiting_list_user.email)
if subscriber_check:
errors.append(l10n('wl-subscriber-already-exists', {'email': waiting_list_user.email}))
continue

# Create a new subscriber
subscriber = repo.subscriber.create(
db,
schemas.SubscriberBase(
email=waiting_list_user.email,
username=waiting_list_user.email,
),
)

if not subscriber:
errors.append(l10n('wl-subscriber-failed-to-create', {'email': waiting_list_user.email}))
continue

# Generate an invite for that waiting list user and subscriber
invite_code = repo.invite.generate_codes(db, 1)[0]

invite_code.subscriber_id = subscriber.id
waiting_list_user.invite_id = invite_code.id

# Update the waiting list user and invite code
db.add(waiting_list_user)
db.add(invite_code)
db.commit()

background_tasks.add_task(send_invite_account_email, to=subscriber.email)
accepted.append(waiting_list_user.id)

return schemas.WaitingListInviteAdminOut(
accepted=accepted,
errors=errors,
)
139 changes: 139 additions & 0 deletions backend/test/integration/test_waiting_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from appointment.dependencies.auth import get_admin_subscriber, get_subscriber
from appointment.routes.auth import create_access_token
from appointment.routes.waiting_list import WaitingListAction
from appointment.tasks.emails import send_confirm_email, send_invite_account_email
from defines import auth_headers


Expand Down Expand Up @@ -221,3 +222,141 @@ def test_view_with_admin_non_admin(self, with_client, with_db, with_l10n, make_w
data = response.json()

assert response.status_code == 401, data


class TestWaitingListAdminInvite:
def test_invite_one_user(self, with_client, with_db, with_l10n, make_waiting_list):
"""Test a successful invitation of one user"""
os.environ['APP_ADMIN_ALLOW_LIST'] = os.getenv('TEST_USER_EMAIL')

waiting_list_user = make_waiting_list()

with patch('fastapi.BackgroundTasks.add_task') as mock:
response = with_client.post('/waiting-list/invite',
json={
'id_list': [waiting_list_user.id]
},
headers=auth_headers)

# Ensure the response was okay!
data = response.json()

assert response.status_code == 200, data
assert len(data['accepted']) == 1
assert len(data['errors']) == 0
assert data['accepted'][0] == waiting_list_user.id

# Ensure we sent out an email
mock.assert_called_once()
# Triple access D:, one for ArgList, one for Call<Function, Args...>), and then the function is in a tuple?!
assert mock.call_args_list[0][0][0] == send_invite_account_email
assert mock.call_args_list[0].kwargs == {'to': waiting_list_user.email}

with with_db() as db:
db.add(waiting_list_user)
db.refresh(waiting_list_user)

assert waiting_list_user.invite_id
assert waiting_list_user.invite.subscriber_id
assert waiting_list_user.invite.subscriber.email == waiting_list_user.email

def test_invite_many_users(self, with_client, with_db, with_l10n, make_waiting_list):
"""Test a successful invite of many users"""
os.environ['APP_ADMIN_ALLOW_LIST'] = os.getenv('TEST_USER_EMAIL')

waiting_list_users = [ make_waiting_list().id for i in range(0, 10) ]

with patch('fastapi.BackgroundTasks.add_task') as mock:
response = with_client.post('/waiting-list/invite',
json={
'id_list': waiting_list_users
},
headers=auth_headers)

# Ensure the response was okay!
data = response.json()

assert response.status_code == 200, data
assert len(data['accepted']) == len(waiting_list_users)
assert len(data['errors']) == 0

for i, id in enumerate(waiting_list_users):
assert data['accepted'][i] == id

# Ensure we sent out an email
mock.assert_called()

with with_db() as db:
for i, id in enumerate(waiting_list_users):
waiting_list_user = db.query(models.WaitingList).filter(models.WaitingList.id == id).first()

assert waiting_list_user
assert waiting_list_user.invite_id
assert waiting_list_user.invite.subscriber_id
assert waiting_list_user.invite.subscriber.email == waiting_list_user.email

assert mock.call_args_list[i][0][0] == send_invite_account_email
assert mock.call_args_list[i].kwargs == {'to': waiting_list_user.email}

def test_invite_existing_subscriber(self, with_client, with_db, with_l10n, make_waiting_list, make_basic_subscriber):
os.environ['APP_ADMIN_ALLOW_LIST'] = os.getenv('TEST_USER_EMAIL')

sub = make_basic_subscriber()
waiting_list_user = make_waiting_list(email=sub.email)

with patch('fastapi.BackgroundTasks.add_task') as mock:
response = with_client.post('/waiting-list/invite',
json={
'id_list': [waiting_list_user.id]
},
headers=auth_headers)

# Ensure the response was okay!
data = response.json()

assert response.status_code == 200, data
assert len(data['accepted']) == 0
assert len(data['errors']) == 1

assert sub.email in data['errors'][0]

mock.assert_not_called()

def test_invite_many_users_with_one_existing_subscriber(self, with_client, with_db, with_l10n, make_waiting_list, make_basic_subscriber):
os.environ['APP_ADMIN_ALLOW_LIST'] = os.getenv('TEST_USER_EMAIL')

sub = make_basic_subscriber()
waiting_list_users = [ make_waiting_list().id for i in range(0, 10) ]
waiting_list_users.append(make_waiting_list(email=sub.email).id)

with patch('fastapi.BackgroundTasks.add_task') as mock:

response = with_client.post('/waiting-list/invite',
json={
'id_list': waiting_list_users
},
headers=auth_headers)

# Ensure the response was okay!
data = response.json()

assert response.status_code == 200, data
assert len(data['accepted']) == len(waiting_list_users) - 1
assert len(data['errors']) == 1

for i, id in enumerate(waiting_list_users):
# Last entry was an error!
if i == 10:
# Should be in the error list, and it shouldn't have called add_task
assert sub.email in data['errors'][0]
assert i not in mock.call_args_list
else:
assert data['accepted'][i] == id

with with_db() as db:
waiting_list_user = db.query(models.WaitingList).filter(models.WaitingList.id == id).first()

assert waiting_list_user
assert mock.call_args_list[i][0][0] == send_invite_account_email
assert mock.call_args_list[i].kwargs == {'to': waiting_list_user.email}

34 changes: 30 additions & 4 deletions frontend/src/components/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,18 @@
<thead>
<tr>
<th v-if="allowMultiSelect">
<!-- Decide if we want to select all for the paginated list or all data -->
<input :checked="paginatedDataList.every((row) => selectedRows.includes(row))" @change="(evt) => onPageSelect(evt, paginatedDataList)" id="select-page-input" class="mr-2" type="checkbox"/>
<label class="select-none cursor-pointer" for="select-page-input">
Select Page
</label>
</th>
<th v-for="column in columns" :key="column.key">{{ column.name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(datum, i) in paginatedDataList" :key="i">
<tr v-for="(datum, i) in paginatedDataList" :key="datum[dataKey] as unknown as string">
<td v-if="allowMultiSelect">
<input type="checkbox" @change="(evt) => onFieldSelect(evt, datum)" />
<input :checked="selectedRows.includes(datum)" type="checkbox" @change="(evt) => onFieldSelect(evt, datum)" />
</td>
<td v-for="(fieldData, fieldKey) in datum" :key="fieldKey" :class="`column-${fieldKey}`">
<span v-if="fieldData.type === TableDataType.Text">
Expand Down Expand Up @@ -117,6 +120,7 @@ import LoadingSpinner from '@/elements/LoadingSpinner.vue';
interface Props {
allowMultiSelect: boolean, // Displays checkboxes next to each row, and emits the `fieldSelect` event with a list of currently selected rows
dataName: string, // The name for the object being represented on the table
dataKey: string, // A property to use as the list key
columns: TableDataColumn[], // List of columns to be displayed (these don't filter data, filter that yourself!)
dataList: TableDataRow[], // List of data to be displayed
filters: TableFilter[], // List of filters to be displayed
Expand All @@ -125,7 +129,7 @@ interface Props {
const props = defineProps<Props>();
const {
dataList, columns, dataName, allowMultiSelect, loading,
dataList, dataKey, columns, dataName, allowMultiSelect, loading,
} = toRefs(props);
const { t } = useI18n();
Expand Down Expand Up @@ -166,6 +170,28 @@ const totalDataLength = computed(() => {
return 0;
});
const onPageSelect = (evt: Event, list: TableDataRow[]) => {
const target = evt.target as HTMLInputElement;
const isChecked = target.checked;
list.forEach((row) => {
const index = selectedRows.value.indexOf(row);
// Add and we're already in? OR Remove and we're not in? Skip!
if ((isChecked && index !== -1) || (!isChecked && index === -1)) {
return;
}
if (isChecked) {
selectedRows.value.push(row);
} else {
selectedRows.value.splice(index, 1);
}
});
emit('fieldSelect', selectedRows.value);
}
const onFieldSelect = (evt: Event, row: TableDataRow) => {
const isChecked = (evt as HTMLInputElementEvent)?.target?.checked;
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@
"selectCalendar": "Select calendar",
"send": "Send",
"sendInvitationToAnotherEmail": "Send to another email",
"sendInviteToWaitingList": "Select someone to send an invite! | Send an invite to {count} folk | Send an invite to {count} folks",
"sentCountInvitesSuccessfully": "All selected users are already invited! | Sent {count} invite! | Sent {count} invites!",
"settings": "Settings",
"shareMyLink": "Share my link",
"showSecondaryTimeZone": "Show secondary time zone",
Expand Down Expand Up @@ -419,6 +421,7 @@
"signUpAlreadyExists": "You are already on the waiting list.",
"signUpCheckYourEmail": "Check your email for more information.",
"signUpHeading": "Just one more step!",
"signUpInfo": "Before you can be added to the waiting list, you need to confirm your email address."
"signUpInfo": "Before you can be added to the waiting list, you need to confirm your email address.",
"adminInviteNotice": "Notice: The Send button will not re-invite people already accepted, but you can still select them. Use the filters for clarity!"
}
}
6 changes: 6 additions & 0 deletions frontend/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ export type Subscriber = {
time_deleted?: string;
}

export type WaitingListInvite = {
accepted: number[];
errors: string[]
}

export type WaitingListEntry = {
id: number;
email: string;
Expand Down Expand Up @@ -285,6 +290,7 @@ export type StringListResponse = UseFetchReturn<string[]>;
export type SubscriberListResponse = UseFetchReturn<Subscriber[]|Exception>;
export type SubscriberResponse = UseFetchReturn<Subscriber>;
export type TokenResponse = UseFetchReturn<Token>;
export type WaitingListInviteResponse = UseFetchReturn<WaitingListInvite|Exception>
export type WaitingListResponse = UseFetchReturn<WaitingListEntry[]|Exception>;
export type WaitingListActionResponse = UseFetchReturn<WaitingListStatus>;

Expand Down
Loading

0 comments on commit fc900c2

Please sign in to comment.