From 6124ec74afab3246ac1b96530ad19e529f59fff7 Mon Sep 17 00:00:00 2001 From: Melissa Autumn Date: Wed, 17 Apr 2024 16:01:09 -0700 Subject: [PATCH 01/19] Add wip invite panel --- backend/src/appointment/routes/invite.py | 5 + frontend/src/router.js | 5 + frontend/src/views/InvitePanelView.vue | 165 +++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 frontend/src/views/InvitePanelView.vue diff --git a/backend/src/appointment/routes/invite.py b/backend/src/appointment/routes/invite.py index 29eef5b8e..972ebba06 100644 --- a/backend/src/appointment/routes/invite.py +++ b/backend/src/appointment/routes/invite.py @@ -12,6 +12,11 @@ router = APIRouter() +@router.get('/', response_model=list[schemas.Invite]) +def get_all_invites(db: Session = Depends(get_db)): + return db.query(models.Invite).all() + + @router.post("/generate/{n}", response_model=list[schemas.Invite]) def generate_invite_codes(n: int, db: Session = Depends(get_db)): raise NotImplementedError diff --git a/frontend/src/router.js b/frontend/src/router.js index 53f487079..254fea0fe 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -90,6 +90,11 @@ const routes = [ name: 'terms', component: LegalView, }, + { + path: '/admin/invites', + name: 'admin-invite-panel', + component: () => import('@/views/InvitePanelView.vue'), + }, ]; // create router object to export diff --git a/frontend/src/views/InvitePanelView.vue b/frontend/src/views/InvitePanelView.vue new file mode 100644 index 000000000..80a32dbbd --- /dev/null +++ b/frontend/src/views/InvitePanelView.vue @@ -0,0 +1,165 @@ + + + + + From 4d15cb0341e134375c7409706f78aa4c023fcb2e Mon Sep 17 00:00:00 2001 From: Melissa Autumn Date: Wed, 8 May 2024 14:27:43 -0700 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9E=95=20Add=20admin=20subscribers=20l?= =?UTF-8?q?ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Andreas Müller --- backend/src/appointment/main.py | 2 + backend/src/appointment/routes/subscriber.py | 32 +++++ frontend/src/router.js | 9 +- frontend/src/views/SubscriberPanelView.vue | 125 +++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 backend/src/appointment/routes/subscriber.py create mode 100644 frontend/src/views/SubscriberPanelView.vue diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index 53a22b5d7..362cec2de 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -121,6 +121,7 @@ def server(): from .routes import google from .routes import schedule from .routes import invite + from .routes import subscriber from .routes import zoom from .routes import webhooks @@ -189,6 +190,7 @@ async def catch_google_refresh_errors(request, exc): app.include_router(google.router, prefix="/google") app.include_router(schedule.router, prefix="/schedule") app.include_router(invite.router, prefix="/invite") + app.include_router(subscriber.router, prefix="/subscriber") app.include_router(webhooks.router, prefix="/webhooks") if os.getenv("ZOOM_API_ENABLED"): app.include_router(zoom.router, prefix="/zoom") diff --git a/backend/src/appointment/routes/subscriber.py b/backend/src/appointment/routes/subscriber.py new file mode 100644 index 000000000..3cbaee8f6 --- /dev/null +++ b/backend/src/appointment/routes/subscriber.py @@ -0,0 +1,32 @@ + +from fastapi import APIRouter, Depends + +from sqlalchemy.orm import Session + +from ..database import repo, schemas, models +from ..database.models import Subscriber +from ..dependencies.auth import get_admin_subscriber +from ..dependencies.database import get_db + +from ..exceptions import validation + +router = APIRouter() + + +@router.get('/', response_model=list[schemas.Subscriber]) +def get_all_subscriber(db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): + """List all existing invites, needs admin permissions""" + return db.query(models.Subscriber).all() + + +@router.put("/disable/{email}") +def disable_subscriber(email: str, db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): + """endpoint to disable a subscriber by email, needs admin permissions""" + raise NotImplementedError + + subscriber = repo.subscriber.get_by_email(db, email) + if not subscriber: + raise validation.SubscriberNotFoundException() + # TODO: CAUTION! This actually deletes the subscriber. We might want to only disable them. + # This needs an active flag on the subscribers model. + return repo.subscriber.delete(db, subscriber) diff --git a/frontend/src/router.js b/frontend/src/router.js index 254fea0fe..43012c17a 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -14,6 +14,8 @@ const AppointmentsView = defineAsyncComponent(() => import('@/views/Appointments const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView')); const ProfileView = defineAsyncComponent(() => import('@/views/ProfileView')); const LegalView = defineAsyncComponent(() => import('@/views/LegalView')); +const InvitePanelView = defineAsyncComponent(() => import('@/views/InvitePanelView')); +const SubscriberPanelView = defineAsyncComponent(() => import('@/views/SubscriberPanelView')); /** * Defined routes for Thunderbird Appointment @@ -93,7 +95,12 @@ const routes = [ { path: '/admin/invites', name: 'admin-invite-panel', - component: () => import('@/views/InvitePanelView.vue'), + component: InvitePanelView, + }, + { + path: '/admin/subscribers', + name: 'admin-subscriber-panel', + component: SubscriberPanelView, }, ]; diff --git a/frontend/src/views/SubscriberPanelView.vue b/frontend/src/views/SubscriberPanelView.vue new file mode 100644 index 000000000..faf89f632 --- /dev/null +++ b/frontend/src/views/SubscriberPanelView.vue @@ -0,0 +1,125 @@ + + + From 183b540a3f14c73db0854ce61020ca8010ae3379 Mon Sep 17 00:00:00 2001 From: Melissa Autumn Date: Wed, 8 May 2024 14:31:04 -0700 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9E=95=20Add=20pagination=20and=20code?= =?UTF-8?q?=20revokal=20for=20invite=20codes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Andreas Müller --- backend/src/appointment/routes/invite.py | 2 - frontend/src/elements/ListPagination.vue | 92 ++++++++++++++ frontend/src/views/InvitePanelView.vue | 149 ++++++++++++++++------- 3 files changed, 196 insertions(+), 47 deletions(-) create mode 100644 frontend/src/elements/ListPagination.vue diff --git a/backend/src/appointment/routes/invite.py b/backend/src/appointment/routes/invite.py index 972ebba06..f783a132b 100644 --- a/backend/src/appointment/routes/invite.py +++ b/backend/src/appointment/routes/invite.py @@ -19,7 +19,6 @@ def get_all_invites(db: Session = Depends(get_db)): @router.post("/generate/{n}", response_model=list[schemas.Invite]) def generate_invite_codes(n: int, db: Session = Depends(get_db)): - raise NotImplementedError """endpoint to generate n invite codes""" return repo.invite.generate_codes(db, n) @@ -41,7 +40,6 @@ def use_invite_code(code: str, db: Session = Depends(get_db)): @router.put("/revoke/{code}") def revoke_invite_code(code: str, db: Session = Depends(get_db)): - raise NotImplementedError """endpoint to revoke a given invite code and mark in unavailable""" if not repo.invite.code_exists(db, code): raise validation.InviteCodeNotFoundException() diff --git a/frontend/src/elements/ListPagination.vue b/frontend/src/elements/ListPagination.vue new file mode 100644 index 000000000..313fad212 --- /dev/null +++ b/frontend/src/elements/ListPagination.vue @@ -0,0 +1,92 @@ + + + diff --git a/frontend/src/views/InvitePanelView.vue b/frontend/src/views/InvitePanelView.vue index 80a32dbbd..ca79cfa94 100644 --- a/frontend/src/views/InvitePanelView.vue +++ b/frontend/src/views/InvitePanelView.vue @@ -1,19 +1,24 @@ @@ -61,20 +87,22 @@ /* * Not dealing with tailwinding every single td */ -.round-support-div { - @apply rounded-t-xl w-full border pt-2 border-gray-100 bg-white text-sm shadow-sm dark:border-gray-500 dark:bg-gray-700; -} - table { @apply w-full table-auto border-collapse bg-white text-sm shadow-sm dark:bg-gray-600; } -thead { +thead, tfoot { @apply border-gray-200 bg-gray-100 dark:border-gray-500 dark:bg-gray-700 text-gray-600 dark:text-gray-300; } th { - @apply w-1/2 p-4 text-left font-semibold border-gray-200 dark:border-gray-500; + @apply px-4 text-left font-semibold border-gray-200 dark:border-gray-500; +} +thead th { + @apply pb-4 pt-2; +} +tfoot th { + @apply pb-2 pt-4; } td { @@ -91,16 +119,14 @@ td:last-child { From 87a7811ec85dea360537fe76fe2782ed3cba883f Mon Sep 17 00:00:00 2001 From: Melissa Autumn Date: Wed, 8 May 2024 14:31:33 -0700 Subject: [PATCH 04/19] =?UTF-8?q?=E2=9E=95=20Add=20copy=20button=20and=20m?= =?UTF-8?q?ove=20table=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Andreas Müller --- frontend/src/assets/main.css | 26 ++++++++++++++ frontend/src/views/InvitePanelView.vue | 50 +++++--------------------- 2 files changed, 35 insertions(+), 41 deletions(-) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 96eab96c1..8b0c8ae88 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -111,3 +111,29 @@ .v-leave-to { @apply opacity-0; } + +/* custom data table with wrapper */ +.data-table { + @apply rounded-xl w-full border py-2 border-gray-100 bg-white text-sm shadow-sm dark:border-gray-500 dark:bg-gray-700 mb-4 ml-auto mr-0; + + table { + @apply w-full table-auto border-collapse bg-white text-sm shadow-sm dark:bg-gray-600; + } + + thead, tfoot { + @apply border-gray-200 bg-gray-100 dark:border-gray-500 dark:bg-gray-700 text-gray-600 dark:text-gray-300; + } + + th { + @apply px-4 text-left font-semibold border-gray-200 dark:border-gray-500; + } + thead th { + @apply pb-4 pt-2; + } + tfoot th { + @apply pb-2 pt-4; + } + td { + @apply border-y border-gray-200 p-4 dark:border-gray-500; + } +} diff --git a/frontend/src/views/InvitePanelView.vue b/frontend/src/views/InvitePanelView.vue index ca79cfa94..f183650ac 100644 --- a/frontend/src/views/InvitePanelView.vue +++ b/frontend/src/views/InvitePanelView.vue @@ -10,7 +10,7 @@ :page-size="pageSize" @update="updatePage" /> -
+
-
+
@@ -42,7 +39,12 @@ - + @@ -83,41 +85,6 @@ - - diff --git a/frontend/src/definitions.js b/frontend/src/definitions.js index f220a724f..9fb5a26fe 100644 --- a/frontend/src/definitions.js +++ b/frontend/src/definitions.js @@ -243,6 +243,23 @@ export const qalendarSlotDurations = { */ export const loginRedirectKey = 'loginRedirect'; +/** + * Data types for table row items + * @enum + * @readonly + */ +export const tableDataType = { + text: 1, + link: 2, + button: 3, +}; + +export const tableDataButtonType = { + primary: 1, + secondary: 2, + caution: 3, +}; + export default { subscriberLevels, appointmentState, @@ -259,4 +276,6 @@ export default { meetingLinkProviderType, dateFormatStrings, qalendarSlotDurations, + tableDataType, + tableDataButtonType, }; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a83be434b..c201f8829 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -7,6 +7,7 @@ "actionNeeded": "Action needed", "authenticationRequired": "Sorry, this page requires you to be logged in.", "credentialsIncomplete": "Please provide login credentials.", + "dataSourceIsEmpty": "No {name} could be found.", "generalBookingError": "Sorry, there was a problem retrieving the schedule details. Please try again later.", "googleRefreshError": "Error connecting with Google API, please re-connect.", "loginMethodNotSupported": "Login method not supported. Please try again.", @@ -68,6 +69,7 @@ "copiedToClipboard": "Copied to clipboard", "eventWasCreated": "Event created in your calendar.", "invitationWasSent": "An invitation was sent to your email.", + "invitationWasSentContext": "An invitation was sent to {email}.", "messageWasNotSent": "Your message couldn't be sent. Please try again.", "messageWasSent": "Your message has been sent to our support team.", "noPendingAppointmentsInList": "You have no pending events.", @@ -151,9 +153,11 @@ "end": "End", "endDate": "End Date", "endTime": "End Time", + "enterEmailToInvite": "Enter an email to invite them to Thunderbird Appointment:", "error": "Error", "faq": "FAQ", "farthestBooking": "Farthest Booking", + "filter": "Filter", "general": "General", "generalDetails": "Details", "generateZoomLink": "Generate Zoom Meeting", diff --git a/frontend/src/views/SubscriberPanelView.vue b/frontend/src/views/SubscriberPanelView.vue index faf89f632..cad75b15a 100644 --- a/frontend/src/views/SubscriberPanelView.vue +++ b/frontend/src/views/SubscriberPanelView.vue @@ -1,103 +1,166 @@ From 42e106ed02cf29acd8962177894dcc9a9799cc32 Mon Sep 17 00:00:00 2001 From: Melissa Autumn Date: Fri, 10 May 2024 13:20:06 -0700 Subject: [PATCH 12/19] Fix up fieldClick event, and hide the disable button for now. --- .../appointment/database/repo/subscriber.py | 2 +- backend/src/appointment/routes/subscriber.py | 5 ++-- frontend/src/components/DataTable.vue | 8 +++--- frontend/src/views/SubscriberPanelView.vue | 25 +++++++++++-------- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/backend/src/appointment/database/repo/subscriber.py b/backend/src/appointment/database/repo/subscriber.py index 8d8fba1f8..bc8d4a5e5 100644 --- a/backend/src/appointment/database/repo/subscriber.py +++ b/backend/src/appointment/database/repo/subscriber.py @@ -15,7 +15,7 @@ def get(db: Session, subscriber_id: int) -> models.Subscriber | None: return db.get(models.Subscriber, subscriber_id) -def get_by_email(db: Session, email: str): +def get_by_email(db: Session, email: str) -> models.Subscriber | None: """retrieve subscriber by email""" return db.query(models.Subscriber).filter(models.Subscriber.email == email).first() diff --git a/backend/src/appointment/routes/subscriber.py b/backend/src/appointment/routes/subscriber.py index d23283468..105ac1086 100644 --- a/backend/src/appointment/routes/subscriber.py +++ b/backend/src/appointment/routes/subscriber.py @@ -15,15 +15,16 @@ @router.get('/', response_model=list[schemas.SubscriberAdminOut]) -def get_all_subscriber(db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): +def get_all_subscriber(db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)): """List all existing invites, needs admin permissions""" response = db.query(models.Subscriber).all() return response @router.put("/disable/{email}") -def disable_subscriber(email: str, db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): +def disable_subscriber(email: str, db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)): """endpoint to disable a subscriber by email, needs admin permissions""" + # TODO: Add status to subscriber, and disable it instead. raise NotImplementedError subscriber = repo.subscriber.get_by_email(db, email) diff --git a/frontend/src/components/DataTable.vue b/frontend/src/components/DataTable.vue index 9265e71fb..3ca767697 100644 --- a/frontend/src/components/DataTable.vue +++ b/frontend/src/components/DataTable.vue @@ -41,9 +41,9 @@ {{ fieldData.value }} - {{ fieldData.value }} - {{ fieldData.value }} - {{ fieldData.value }} + {{ fieldData.value }} + {{ fieldData.value }} + {{ fieldData.value }} @@ -82,7 +82,7 @@ * @param dataName {string} - The name for the object being represented on the table * @param columns {Array} - List of columns to be displayed (these don't filter data, filter that yourself!) * @param dataList {Array} - List of data to be displayed - * @param filters + * @param filters {Array} - List of filters to be displayed * @param loading {boolean} - Displays a loading spinner */ import ListPagination from '@/elements/ListPagination.vue'; diff --git a/frontend/src/views/SubscriberPanelView.vue b/frontend/src/views/SubscriberPanelView.vue index cad75b15a..ff8571d6d 100644 --- a/frontend/src/views/SubscriberPanelView.vue +++ b/frontend/src/views/SubscriberPanelView.vue @@ -23,7 +23,7 @@ :columns="columns" :filters="filters" :loading="loading" - @field-click="onFieldClick" + @field-click="(_key, field) => disableSubscriber(field.email.value)" >
{{ invite.code }} +
+ {{ invite.code }} + +
+
{{ invite.subscriber_id !== null ? 'Yes' : 'No' }} {{ invite.status === 1 ? 'Available' : 'Revoked' }} {{ dj(invite.time_created).format('ll LTS') }}