Skip to content

Commit

Permalink
Features/397 invite codes (#427)
Browse files Browse the repository at this point in the history
* Hook up invite codes to the login screen

* Re-organized admin panels, add invite panel, add admin nav, and fix up data table's null filter handling.

* Add invite generation, and fix using invites during user registration

* Add invite factory

* ➕ Add table data type 'code'

* 🌐 Update German lang strings

* Fix admin nav elements

---------

Co-authored-by: Andreas Müller <[email protected]>
  • Loading branch information
MelissaAutumn and devmount authored May 24, 2024
1 parent e25f2ba commit be6c0e6
Show file tree
Hide file tree
Showing 15 changed files with 429 additions and 62 deletions.
21 changes: 21 additions & 0 deletions backend/src/appointment/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from ..controller import auth
from ..controller.apis.fxa_client import FxaClient
from ..dependencies.fxa import get_fxa_client
from ..exceptions import validation
from ..exceptions.fxa_api import NotInAllowListException
from ..l10n import l10n

Expand All @@ -48,6 +49,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
def fxa_login(request: Request,
email: str,
timezone: str | None = None,
invite_code: str | None = None,
db: Session = Depends(get_db),
fxa_client: FxaClient = Depends(get_fxa_client)):
"""Request an authorization url from fxa"""
Expand All @@ -64,6 +66,7 @@ def fxa_login(request: Request,
request.session['fxa_state'] = state
request.session['fxa_user_email'] = email
request.session['fxa_user_timezone'] = timezone
request.session['fxa_user_invite_code'] = invite_code

return {
'url': url
Expand Down Expand Up @@ -99,11 +102,14 @@ def fxa_callback(
email = request.session['fxa_user_email']
# We only use timezone during subscriber creation, or if their timezone is None
timezone = request.session['fxa_user_timezone']
invite_code = request.session.get('fxa_user_invite_code')

# Clear session keys
request.session.pop('fxa_state')
request.session.pop('fxa_user_email')
request.session.pop('fxa_user_timezone')
if invite_code:
request.session.pop('fxa_user_invite_code')

fxa_client.setup()

Expand All @@ -123,11 +129,26 @@ def fxa_callback(
new_subscriber_flow = not fxa_subscriber and not subscriber

if new_subscriber_flow:
# Ensure the invite code exists and is available
# Use some inline-errors for now. We don't have a good error flow!
if not repo.invite.code_exists(db, invite_code):
raise HTTPException(404, l10n('invite-code-not-valid'))
if not repo.invite.code_is_available(db, invite_code):
raise HTTPException(403, l10n('invite-code-not-valid'))

subscriber = repo.subscriber.create(db, schemas.SubscriberBase(
email=email,
username=email,
timezone=timezone,
))

# Use the invite code after we've created the new subscriber
used = repo.invite.use_code(db, invite_code, subscriber.id)

# This shouldn't happen, but just in case!
if not used:
repo.subscriber.delete(db, subscriber)
raise HTTPException(500, l10n('unknown-error'))
elif not subscriber:
subscriber = fxa_subscriber

Expand Down
19 changes: 2 additions & 17 deletions backend/src/appointment/routes/invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,32 +19,17 @@


@router.get('/', response_model=list[schemas.Invite])
def get_all_invites(db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)):
def get_all_invites(db: Session = Depends(get_db), _admin: Subscriber = Depends(get_admin_subscriber)):
"""List all existing invites, needs admin permissions"""
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), admin: Subscriber = Depends(get_admin_subscriber)):
def generate_invite_codes(n: int, db: Session = Depends(get_db), _admin: Subscriber = Depends(get_admin_subscriber)):
"""endpoint to generate n invite codes, needs admin permissions"""
return repo.invite.generate_codes(db, n)


@router.put("/redeem/{code}")
def use_invite_code(code: str, db: Session = Depends(get_db)):
raise NotImplementedError

"""endpoint to create a new subscriber and update the corresponding invite"""
if not repo.invite.code_exists(db, code):
raise validation.InviteCodeNotFoundException()
if not repo.invite.code_is_available(db, code):
raise validation.InviteCodeNotAvailableException()
# TODO: get email from admin panel
email = '[email protected]'
subscriber = repo.subscriber.create(db, schemas.SubscriberBase(email=email, username=email))
return repo.invite.use_code(db, code, subscriber.id)


@router.put("/revoke/{code}")
def revoke_invite_code(code: str, db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)):
"""endpoint to revoke a given invite code and mark in unavailable, needs admin permissions"""
Expand Down
1 change: 1 addition & 0 deletions backend/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from factory.schedule_factory import make_schedule # noqa: F401
from factory.slot_factory import make_appointment_slot # noqa: F401
from factory.subscriber_factory import make_subscriber, make_basic_subscriber, make_pro_subscriber # noqa: F401
from factory.invite_factory import make_invite

# Load our env
load_dotenv(find_dotenv(".env.test"))
Expand Down
19 changes: 19 additions & 0 deletions backend/test/factory/invite_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import pytest
from faker import Faker
from appointment.database import models
from defines import FAKER_RANDOM_VALUE, factory_has_value


@pytest.fixture
def make_invite(with_db):
fake = Faker()

def _make_invite(subscriber_id=None, code=FAKER_RANDOM_VALUE, status=models.InviteStatus.active) -> models.Invite:
with with_db() as db:
invite = models.Invite(subscriber_id=subscriber_id, status=status, code=code if factory_has_value(code) else fake.uuid4())
db.add(invite)
db.commit()
db.refresh(invite)
return invite

return _make_invite
7 changes: 5 additions & 2 deletions backend/test/integration/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,19 @@ def test_fxa_login(self, with_client):
assert 'url' in data
assert data.get('url') == FXA_CLIENT_PATCH.get('authorization_url')

def test_fxa_callback(self, with_db, with_client, monkeypatch):
def test_fxa_callback(self, with_db, with_client, monkeypatch, make_invite):
"""Test that our callback function correctly handles the session states, and creates a new subscriber"""
os.environ['AUTH_SCHEME'] = 'fxa'

state = 'a1234'

invite = make_invite()

monkeypatch.setattr('starlette.requests.HTTPConnection.session', {
'fxa_state': state,
'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'),
'fxa_user_timezone': 'America/Vancouver'
'fxa_user_timezone': 'America/Vancouver',
'fxa_user_invite_code': invite.code,
})

response = with_client.get(
Expand Down
30 changes: 23 additions & 7 deletions frontend/src/components/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</label>
</div>
<list-pagination
:list-length="mutableDataList.length > 0 ? mutableDataList.length : dataList.length"
:list-length="totalDataLength"
:page-size="pageSize"
@update="updatePage"
/>
Expand All @@ -37,13 +37,17 @@
<span v-if="fieldData.type === tableDataType.text">
{{ fieldData.value }}
</span>
<span v-else-if="fieldData.type === tableDataType.code" class="flex gap-4 items-center">
<code>{{ fieldData.value }}</code>
<text-button :copy="fieldData.value" />
</span>
<span v-else-if="fieldData.type === tableDataType.link">
<a :href="fieldData.link" target="_blank">{{ fieldData.value }}</a>
</span>
<span v-else-if="fieldData.type === tableDataType.button">
<primary-button v-if="fieldData.buttonType === tableDataButtonType.primary" @click="emit('fieldClick', fieldKey, datum)">{{ fieldData.value }}</primary-button>
<secondary-button v-else-if="fieldData.buttonType === tableDataButtonType.secondary" @click="emit('fieldClick', fieldKey, datum)">{{ fieldData.value }}</secondary-button>
<caution-button v-else-if="fieldData.buttonType === tableDataButtonType.caution" @click="emit('fieldClick', fieldKey, datum)">{{ fieldData.value }}</caution-button>
<primary-button v-if="fieldData.buttonType === tableDataButtonType.primary" :disabled="fieldData.disabled" @click="emit('fieldClick', fieldKey, datum)">{{ fieldData.value }}</primary-button>
<secondary-button v-else-if="fieldData.buttonType === tableDataButtonType.secondary" :disabled="fieldData.disabled" @click="emit('fieldClick', fieldKey, datum)">{{ fieldData.value }}</secondary-button>
<caution-button v-else-if="fieldData.buttonType === tableDataButtonType.caution" :disabled="fieldData.disabled" @click="emit('fieldClick', fieldKey, datum)">{{ fieldData.value }}</caution-button>
</span>
</td>
</tr>
Expand Down Expand Up @@ -94,6 +98,7 @@ import { tableDataButtonType, tableDataType } from '@/definitions';
import PrimaryButton from '@/elements/PrimaryButton.vue';
import SecondaryButton from '@/elements/SecondaryButton.vue';
import CautionButton from '@/elements/CautionButton.vue';
import TextButton from '@/elements/TextButton.vue';
import LoadingSpinner from '@/elements/LoadingSpinner.vue';
const props = defineProps({
Expand Down Expand Up @@ -122,13 +127,13 @@ const updatePage = (index) => {
const columnSpan = computed(() => (columns.value.length + (allowMultiSelect.value ? 1 : 0)));
const selectedFields = ref([]);
const mutableDataList = ref([]);
const mutableDataList = ref(null);
/**
* Returns either a filtered data list, or the original all nice and paginated
*/
const paginatedDataList = computed(() => {
if (mutableDataList?.value?.length) {
if (mutableDataList?.value !== null) {
return mutableDataList.value.slice(currentPage.value * pageSize, (currentPage.value + 1) * pageSize);
}
if (dataList?.value?.length) {
Expand All @@ -138,6 +143,16 @@ const paginatedDataList = computed(() => {
return [];
});
const totalDataLength = computed(() => {
if (mutableDataList?.value !== null) {
return mutableDataList.value?.length ?? 0;
}
if (dataList?.value?.length) {
return dataList.value.length;
}
return 0;
});
const onFieldSelect = (evt, fieldData) => {
const isChecked = evt?.target?.checked;
Expand All @@ -154,8 +169,9 @@ const onFieldSelect = (evt, fieldData) => {
const onColumnFilter = (evt, filter) => {
mutableDataList.value = filter.fn(evt.target.value, dataList.value);
console.log('Data list info: ', mutableDataList.value, ' vs ', dataList.value);
if (mutableDataList.value === dataList.value) {
mutableDataList.value = [];
mutableDataList.value = null;
}
};
Expand Down
1 change: 1 addition & 0 deletions frontend/src/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export const tableDataType = {
text: 1,
link: 2,
button: 3,
code: 4,
};

export const tableDataButtonType = {
Expand Down
42 changes: 14 additions & 28 deletions frontend/src/elements/ListPagination.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<nav class="flex items-center flex-nowrap gap-2" :class="{ 'hidden': !listLength }">
<nav class="flex flex-nowrap items-center gap-2">
<button @click="prev" :disabled="isFirstPage" :class="{ 'text-gray-500': isFirstPage }">
<icon-chevron-left class="w-5 h-5 stroke-1.5" />
<icon-chevron-left class="stroke-1.5 size-5" />
</button>
<div
v-for="(p, i) in pageCount" :key="i"
Expand All @@ -19,7 +19,7 @@
<div v-show="showLastEllipsis(p)">&hellip;</div>
</div>
<button @click="next" :disabled="isLastPage" :class="{ 'text-gray-500': isLastPage }">
<icon-chevron-right class="w-5 h-5 stroke-1.5" />
<icon-chevron-right class="stroke-1.5 size-5" />
</button>
</nav>
</template>
Expand All @@ -39,19 +39,13 @@ const props = defineProps({
listLength: Number, // number of total items in the displayed list
pageSize: Number, // number of items per page
});
const emit = defineEmits(['update'])
const emit = defineEmits(['update']);
const currentPage = ref(0); // index of active page
const isFirstPage = computed(() => {
return currentPage.value <= 0;
});
const pageCount = computed(() => {
return Math.ceil(props.listLength/props.pageSize);
});
const isLastPage = computed(() => {
return currentPage.value >= pageCount.value-1;
});
const isFirstPage = computed(() => currentPage.value <= 0);
const pageCount = computed(() => Math.ceil(props.listLength / props.pageSize) || 1);
const isLastPage = computed(() => currentPage.value >= pageCount.value - 1);
const prev = () => {
if (!isFirstPage.value) {
Expand All @@ -70,23 +64,15 @@ const goto = (index) => {
emit('update', currentPage.value);
};
const showPageItem = (p) => {
return pageCount.value < 6 || p == 1 || p == 2 || isVisibleInnerPage(p) || p == pageCount.value-1;
};
const showFirstEllipsis = (p) => {
return pageCount.value >= 6 && currentPage.value > 2 && p == 2;
};
const showPageItemLink = (p) => {
return pageCount.value < 6 || p == 1 || isVisibleInnerPage(p);
};
const showLastEllipsis = (p) => {
return pageCount.value >= 6 && currentPage.value < pageCount.value-3 && p == pageCount.value-1;
};
const showPageItem = (p) => pageCount.value < 6 || p == 1 || p == 2 || isVisibleInnerPage(p) || p == pageCount.value - 1;
const showFirstEllipsis = (p) => pageCount.value >= 6 && currentPage.value > 2 && p == 2;
const showPageItemLink = (p) => pageCount.value < 6 || p == 1 || isVisibleInnerPage(p);
const showLastEllipsis = (p) => pageCount.value >= 6 && currentPage.value < pageCount.value - 3 && p == pageCount.value - 1;
const isVisibleInnerPage = (p) => (currentPage.value == 0 && p == 3)
|| ((currentPage.value == 0 || currentPage.value == 1) && p == 4)
|| (p > currentPage.value-1 && p < currentPage.value+3)
|| ((currentPage.value == pageCount.value-1 || currentPage.value == pageCount.value-2) && p == pageCount.value-3)
|| (currentPage.value == pageCount.value-1 && p == pageCount.value-2)
|| (p > currentPage.value - 1 && p < currentPage.value + 3)
|| ((currentPage.value == pageCount.value - 1 || currentPage.value == pageCount.value - 2) && p == pageCount.value - 3)
|| (currentPage.value == pageCount.value - 1 && p == pageCount.value - 2)
|| p == pageCount.value;
</script>
27 changes: 27 additions & 0 deletions frontend/src/elements/admin/AdminNav.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<template>
<div class="mb-4 flex h-12 justify-center gap-8">
<nav-bar-item
v-for="item in navItems"
:key="item"
:active="route.name == item"
:label="t(`label.${item}`)"
:link-name="item"
/>
</div>
</template>

<script setup>
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import NavBarItem from '@/elements/NavBarItem.vue';
const { t } = useI18n();
const navItems = [
'admin-invite-codes-panel',
'admin-subscriber-panel',
];
const route = useRoute();
</script>
6 changes: 6 additions & 0 deletions frontend/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"bookingSuccessfullyRequested": "Buchung erfolgreich angefragt!",
"copiedToClipboard": "In Zwischenablage kopiert",
"eventWasCreated": "Das Event wurde in deinem Kalender erstellt.",
"invitationGenerated": "Die Einladungscodes wurden erfolgreich generiert.",
"invitationWasSent": "Eine Einladung wurde an deine E-Mail-Adresse gesendet.",
"invitationWasSentContext": "Eine Einladung wurde an {email} gesendet.",
"messageWasNotSent": "Die Nachricht konnte nicht gesendet werden. Bitte nochmal versuchen.",
Expand All @@ -88,10 +89,13 @@
"addCalendar": "{provider} Kalender hinzufügen",
"addDay": "Tag hinzufügen",
"addTime": "Zeit hinzufügen",
"admin-invite-codes-panel": "Einladungen",
"admin-subscriber-panel": "Abonnenten",
"all": "Alle",
"allAppointments": "Alle Termine",
"allDay": "Ganzer Tag",
"allFutureAppointments": "Alle zukünftigen Termine",
"amountOfCodes": "Anzahl zu erzeugender Codes",
"appearance": "Aussehen",
"appointmentCreationError": "Fehler bei Terminerstellung",
"appointmentDetails": "Termindetails",
Expand Down Expand Up @@ -160,12 +164,14 @@
"filter": "Filter",
"general": "Allgemein",
"generalDetails": "Allgemeine Infos",
"generate": "Generieren",
"generateZoomLink": "Zoom Meeting generieren",
"goBack": "Zurück",
"google": "Google",
"guest": "{count} Gäste | {count} Gast | {count} Gäste",
"immediately": "Sofort",
"inPerson": "Vor Ort",
"inviteCode": "Du hast einen Einladungscode?",
"language": "Sprache",
"legal": "Impressum",
"light": "Hell",
Expand Down
Loading

0 comments on commit be6c0e6

Please sign in to comment.