From fc900c22b8ffd0dcf6fb4651f66857ebcbf41e6e Mon Sep 17 00:00:00 2001 From: Melissa Autumn Date: Fri, 16 Aug 2024 11:23:35 -0700 Subject: [PATCH] Add the ability to invite users on the waiting list. --- backend/src/appointment/database/schemas.py | 9 ++ backend/src/appointment/l10n/en/email.ftl | 2 +- backend/src/appointment/l10n/en/main.ftl | 4 + .../src/appointment/routes/waiting_list.py | 79 +++++++++- backend/test/integration/test_waiting_list.py | 139 ++++++++++++++++++ frontend/src/components/DataTable.vue | 34 ++++- frontend/src/locales/en.json | 5 +- frontend/src/models.ts | 6 + .../src/views/admin/InviteCodePanelView.vue | 1 + .../src/views/admin/SubscriberPanelView.vue | 1 + .../src/views/admin/WaitingListPanelView.vue | 74 +++++++++- 11 files changed, 338 insertions(+), 16 deletions(-) diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 61afd15bb..3956d9653 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -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 diff --git a/backend/src/appointment/l10n/en/email.ftl b/backend/src/appointment/l10n/en/email.ftl index ac9816b87..fcb2f08cb 100644 --- a/backend/src/appointment/l10n/en/email.ftl +++ b/backend/src/appointment/l10n/en/email.ftl @@ -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: diff --git a/backend/src/appointment/l10n/en/main.ftl b/backend/src/appointment/l10n/en/main.ftl index 046645a55..75a0fe6d7 100644 --- a/backend/src/appointment/l10n/en/main.ftl +++ b/backend/src/appointment/l10n/en/main.ftl @@ -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 diff --git a/backend/src/appointment/routes/waiting_list.py b/backend/src/appointment/routes/waiting_list.py index 53f265e55..a39b86c07 100644 --- a/backend/src/appointment/routes/waiting_list.py +++ b/backend/src/appointment/routes/waiting_list.py @@ -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() @@ -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') @@ -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, + ) diff --git a/backend/test/integration/test_waiting_list.py b/backend/test/integration/test_waiting_list.py index e52241e58..6e0c54593 100644 --- a/backend/test/integration/test_waiting_list.py +++ b/backend/test/integration/test_waiting_list.py @@ -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 @@ -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), 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} + diff --git a/frontend/src/components/DataTable.vue b/frontend/src/components/DataTable.vue index ab3587f6c..fd7cbfcb4 100644 --- a/frontend/src/components/DataTable.vue +++ b/frontend/src/components/DataTable.vue @@ -25,15 +25,18 @@ - + + {{ column.name }} - + - + @@ -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 @@ -125,7 +129,7 @@ interface Props { const props = defineProps(); const { - dataList, columns, dataName, allowMultiSelect, loading, + dataList, dataKey, columns, dataName, allowMultiSelect, loading, } = toRefs(props); const { t } = useI18n(); @@ -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; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 013db59b7..78dc872b8 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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", @@ -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!" } } diff --git a/frontend/src/models.ts b/frontend/src/models.ts index 652c82394..63c362173 100644 --- a/frontend/src/models.ts +++ b/frontend/src/models.ts @@ -225,6 +225,11 @@ export type Subscriber = { time_deleted?: string; } +export type WaitingListInvite = { + accepted: number[]; + errors: string[] +} + export type WaitingListEntry = { id: number; email: string; @@ -285,6 +290,7 @@ export type StringListResponse = UseFetchReturn; export type SubscriberListResponse = UseFetchReturn; export type SubscriberResponse = UseFetchReturn; export type TokenResponse = UseFetchReturn; +export type WaitingListInviteResponse = UseFetchReturn export type WaitingListResponse = UseFetchReturn; export type WaitingListActionResponse = UseFetchReturn; diff --git a/frontend/src/views/admin/InviteCodePanelView.vue b/frontend/src/views/admin/InviteCodePanelView.vue index eeb8fa0fa..63950efc7 100644 --- a/frontend/src/views/admin/InviteCodePanelView.vue +++ b/frontend/src/views/admin/InviteCodePanelView.vue @@ -231,6 +231,7 @@ onMounted(async () => {
{
import { - computed, inject, onMounted, ref, + computed, inject, onMounted, Ref, ref, } from 'vue'; import { useI18n } from 'vue-i18n'; import { AlertSchemes, TableDataType } from '@/definitions'; import { useRouter } from 'vue-router'; -import { WaitingListEntry, WaitingListResponse, BooleanResponse, TableDataRow, TableDataColumn, TableFilter } from "@/models"; +import { + WaitingListEntry, + WaitingListResponse, + BooleanResponse, + TableDataRow, + TableDataColumn, + TableFilter, + WaitingListInviteResponse +} from "@/models"; import { dayjsKey, callKey } from "@/keys"; import DataTable from '@/components/DataTable.vue'; import LoadingSpinner from '@/elements/LoadingSpinner.vue'; import AlertBox from '@/elements/AlertBox.vue'; import AdminNav from '@/elements/admin/AdminNav.vue'; +import PrimaryButton from "@/elements/PrimaryButton.vue"; +import {IconSend} from "@tabler/icons-vue"; const router = useRouter(); const { t } = useI18n(); @@ -23,6 +33,7 @@ const displayPage = ref(false); const loading = ref(true); const pageError = ref(''); const pageNotification = ref(''); +const selectedFields = ref([]); const filteredUsers = computed(() => waitingListUsers.value.map((user) => ({ id: { @@ -145,6 +156,15 @@ const filters = [ }, ] as TableFilter[]; +/** + * Keep track of how many folks are selected + * The data table just sends us a full list each time! + * @param rows + */ +const onFieldSelect = async (rows: TableDataRow[]) => { + selectedFields.value = [...rows]; +}; + /** * Retrieve waiting list entries */ @@ -171,6 +191,36 @@ const amIAdmin = async () => { return !error.value; }; +const sendInvites = async () => { + console.log("Send!"); + loading.value = true; + + const idList = selectedFields.value.map((row) => row.id.value); + + const response: WaitingListInviteResponse = await call('waiting-list/invite').post({id_list: idList}).json(); + const { data, error } = response; + + if (error.value) { + + } + + const { accepted, errors } = data.value; + + console.log(data.value); + + pageNotification.value = t('label.sentCountInvitesSuccessfully', {count: accepted.length}) + + if (errors.length) { + pageError.value = errors.join('\n'); + } + + // Unselect everything! + selectedFields.value = []; + + await refresh(); + loading.value = false; +}; + onMounted(async () => { const okToContinue = await amIAdmin(); if (!okToContinue) { @@ -193,6 +243,8 @@ onMounted(async () => { {{ pageNotification }} @@ -203,12 +255,28 @@ onMounted(async () => {
+