Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Features/397 invite codes #427

Merged
merged 7 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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