Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
MelissaAutumn committed May 10, 2024
1 parent 551c8e8 commit 4bc31ef
Show file tree
Hide file tree
Showing 11 changed files with 369 additions and 100 deletions.
6 changes: 3 additions & 3 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time
from sqlalchemy_utils import StringEncryptedType, ChoiceType, UUIDType
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine
from sqlalchemy.orm import relationship, as_declarative, declared_attr
from sqlalchemy.orm import relationship, as_declarative, declared_attr, Mapped
from sqlalchemy.sql import func


Expand Down Expand Up @@ -116,7 +116,7 @@ class Subscriber(Base):
calendars = relationship("Calendar", cascade="all,delete", back_populates="owner")
slots = relationship("Slot", cascade="all,delete", back_populates="subscriber")
external_connections = relationship("ExternalConnections", cascade="all,delete", back_populates="owner")
invite: "Invite" = relationship("Invite", back_populates="subscriber")
invite: Mapped["Invite"] = relationship("Invite", back_populates="subscriber", uselist=False)

def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConnections':
"""Retrieves the first found external connection by type or returns None if not found"""
Expand Down Expand Up @@ -288,7 +288,7 @@ class Invite(Base):
code = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False)
status = Column(Enum(InviteStatus), index=True)

subscriber = relationship("Subscriber", back_populates="invite")
subscriber: Mapped["Subscriber"] = relationship("Subscriber", back_populates="invite", single_parent=True)

@property
def is_used(self) -> bool:
Expand Down
32 changes: 23 additions & 9 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ class CalendarOut(CalendarBase):
id: int


""" INVITE model schemas
"""


class Invite(BaseModel):
subscriber_id: int | None = None
code: str
status: InviteStatus = InviteStatus.active
time_created: datetime | None = None
time_updated: datetime | None = None



""" SUBSCRIBER model schemas
"""

Expand Down Expand Up @@ -253,16 +266,11 @@ class Config:
from_attributes = True


""" INVITE model schemas
"""

class SubscriberAdminOut(Subscriber):
invite: Invite | None = None

class Invite(BaseModel):
subscriber_id: int | None = None
code: str
status: InviteStatus = InviteStatus.active
time_created: datetime | None = None
time_updated: datetime | None = None
class Config:
from_attributes = True


""" other schemas used for requests or data migration
Expand Down Expand Up @@ -357,3 +365,9 @@ class Login(BaseModel):

class TokenData(BaseModel):
username: str


"""Invite"""

class SendInviteEmailIn(BaseModel):
email: str
8 changes: 7 additions & 1 deletion backend/src/appointment/dependencies/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ def get_admin_subscriber(
):
"""Retrieve the subscriber and check if they're an admin"""
# check admin allow list
admin_emails = os.getenv("APP_ADMIN_ALLOW_LIST", '').split(',')
admin_emails = os.getenv("APP_ADMIN_ALLOW_LIST")

# Raise an error if we don't have any admin emails specified
if not admin_emails:
raise InvalidPermissionLevelException()

admin_emails = admin_emails.split(',')
if not any([user.email.endswith(allowed_email) for allowed_email in admin_emails]):
raise InvalidPermissionLevelException()

Expand Down
10 changes: 9 additions & 1 deletion backend/src/appointment/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import time
from datetime import timedelta, datetime, UTC
from secrets import token_urlsafe
from typing import Annotated
Expand All @@ -18,7 +19,7 @@
from ..database.models import Subscriber, ExternalConnectionType

from ..dependencies.database import get_db
from ..dependencies.auth import get_subscriber
from ..dependencies.auth import get_subscriber, get_admin_subscriber

from ..controller import auth
from ..controller.apis.fxa_client import FxaClient
Expand Down Expand Up @@ -240,6 +241,13 @@ def me(
timezone=subscriber.timezone, avatar_url=subscriber.avatar_url
)


@router.post("/permission-check")
def permission_check(_: Subscriber = Depends(get_admin_subscriber)):
"""Checks if they have admin permissions"""
return True


# @router.get('/test-create-account')
# def test_create_account(email: str, password: str, timezone: str, db: Session = Depends(get_db)):
# """Used to create a test account"""
Expand Down
9 changes: 7 additions & 2 deletions backend/src/appointment/routes/invite.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from fastapi import APIRouter, Depends, BackgroundTasks
from typing import Annotated

from fastapi import APIRouter, Depends, BackgroundTasks, Request, Body

from sqlalchemy.orm import Session

from ..database import repo, schemas, models
from ..database.models import Subscriber
from ..database.schemas import SendInviteEmailIn
from ..dependencies.auth import get_admin_subscriber
from ..dependencies.database import get_db

Expand Down Expand Up @@ -52,13 +55,15 @@ def revoke_invite_code(code: str, db: Session = Depends(get_db), admin: Subscrib

@router.post("/send", response_model=schemas.Invite)
def send_invite_email(
data: SendInviteEmailIn,
background_tasks: BackgroundTasks,
email: str,
db: Session = Depends(get_db),
# Note admin must be here to for permission reasons
_admin: Subscriber = Depends(get_admin_subscriber)
):
"""With a given email address, generate a subscriber and email them, welcoming them to Thunderbird Appointment."""

email = data.email
invite_code = repo.invite.generate_codes(db, 1)[0]
subscriber = repo.subscriber.create(db, schemas.SubscriberBase(
email=email,
Expand Down
5 changes: 3 additions & 2 deletions backend/src/appointment/routes/subscriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
router = APIRouter()


@router.get('/', response_model=list[schemas.Subscriber])
@router.get('/', response_model=list[schemas.SubscriberAdminOut])
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()
response = db.query(models.Subscriber).all()
return response


@router.put("/disable/{email}")
Expand Down
57 changes: 57 additions & 0 deletions backend/test/integration/test_invite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import os
from defines import auth_headers
from appointment.database import repo


class TestInvite:
def test_send_invite_email_requires_admin(self, with_db, with_client):
"""Ensures send_invite_email requires an admin user"""

os.environ['APP_ADMIN_ALLOW_LIST'] = '@notexample.org'

response = with_client.post(
"/invite/send",
json={
"email": "[email protected]"
},
headers=auth_headers,
)
assert response.status_code == 401, response.text

def test_send_invite_email_requires_at_least_one_admin_email(self, with_db, with_client):
"""Ensures send_invite_email requires an admin user"""

os.environ['APP_ADMIN_ALLOW_LIST'] = ''

response = with_client.post(
"/invite/send",
json={
"email": "[email protected]"
},
headers=auth_headers,
)
assert response.status_code == 401, response.text

def test_send_invite_email(self, with_db, with_client):
"""Ensures send_invite_email requires an admin user"""

os.environ['APP_ADMIN_ALLOW_LIST'] = '@example.org'

invite_email = '[email protected]'

with with_db() as db:
subscriber = repo.subscriber.get_by_email(db, invite_email)
assert subscriber is None

response = with_client.post(
"/invite/send",
json={
'email': invite_email
},
headers=auth_headers,
)
assert response.status_code == 200, response.text

with with_db() as db:
subscriber = repo.subscriber.get_by_email(db, invite_email)
assert subscriber is not None
128 changes: 128 additions & 0 deletions frontend/src/components/DataTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<template>
<div class="flex flex-col items-center justify-center gap-4">
<div class="flex w-full flex-row items-center justify-between">
<div>
<span class="font-bold">{{ paginatedDataList.length }}</span> {{ dataName }}
</div>
<list-pagination
:list-length="dataList.length"
:page-size="pageSize"
@update="updatePage"
/>
</div>
<div class="data-table">
<table>
<thead>
<tr>
<th v-if="allowMultiSelect">
<!-- Decide if we want to select all for the paginated list or all data -->
</th>
<th v-for="column in columns" :key="column.key">{{ column.name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="datum in paginatedDataList" :key="datum">
<td v-if="allowMultiSelect">
<input type="checkbox" @change="(evt) => onFieldSelect(evt, datum)" />
</td>
<td v-for="(fieldData, fieldKey) in datum" :key="fieldKey">
<span v-if="fieldData.type === tableDataType.text">
{{ 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', datum)">{{ fieldData.value }}</primary-button>
<secondary-button v-else-if="fieldData.buttonType === tableDataButtonType.secondary" @click="emit('fieldClick', datum)">{{ fieldData.value }}</secondary-button>
<caution-button v-else-if="fieldData.buttonType === tableDataButtonType.caution" @click="emit('fieldClick', datum)">{{ fieldData.value }}</caution-button>
</span>
</td>
</tr>
<tr v-if="paginatedDataList.length === 0">
<td :colspan="columnSpan">{{ t('error.dataSourceIsEmpty', {name: dataName}) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<th :colspan="columnSpan">
<slot name="footer"></slot>
</th>
</tr>
</tfoot>
</table>
</div>
</div>
</template>

<script setup>
/**
* Data Table
* @typedef {{type: tableDataType, value: string, link?: string, buttonType?: tableDataButtonType }} DataField
* @typedef {{name: string, key: string}} DataColumn
*
* @param allowMultiSelect {boolean} - Displays checkboxes next to each row, and emits the `fieldSelect` event with a list of currently selected rows
* @param dataName {string} - The name for the object being represented on the table
* @param columns {Array<DataColumn>} - List of columns to be displayed (these don't filter data, filter that yourself!)
* @param dataList {Array<DataField>} - List of data to be displayed
*/
import ListPagination from '@/elements/ListPagination.vue';
import { useI18n } from 'vue-i18n';
import {
computed, onMounted, ref, toRefs,
} from 'vue';
import { tableDataButtonType, tableDataType } from '@/definitions';
import PrimaryButton from '@/elements/PrimaryButton.vue';
import SecondaryButton from '@/elements/SecondaryButton.vue';
import CautionButton from '@/elements/CautionButton.vue';
const props = defineProps({
allowMultiSelect: Boolean,
dataName: String,
dataList: Array,
columns: Array,
});
const {
dataList, columns, dataName, allowMultiSelect,
} = toRefs(props);
const { t } = useI18n();
const emit = defineEmits(['fieldSelect', 'fieldClick']);
// pagination
const pageSize = 10;
const currentPage = ref(0);
const updatePage = (index) => {
currentPage.value = index;
};
const columnSpan = computed(() => (columns.value.length + (allowMultiSelect.value ? 1 : 0)));
const selectedFields = ref([]);
const paginatedDataList = computed(() => (dataList.value && dataList.value.length
? dataList.value.slice(currentPage.value * pageSize, (currentPage.value + 1) * pageSize)
: []));
const onFieldSelect = (evt, fieldData) => {
console.log(evt, fieldData);
const isChecked = evt?.target?.checked;
if (isChecked) {
selectedFields.value.push(fieldData);
} else {
const index = selectedFields.value.indexOf(fieldData);
if (index !== -1) {
selectedFields.value.splice(index, 1);
}
}
console.log('->', selectedFields.value);
emit('fieldSelect', selectedFields.value);
};
onMounted(() => {
console.log(dataList.value);
});
</script>
19 changes: 19 additions & 0 deletions frontend/src/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -259,4 +276,6 @@ export default {
meetingLinkProviderType,
dateFormatStrings,
qalendarSlotDurations,
tableDataType,
tableDataButtonType,
};
1 change: 1 addition & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading

0 comments on commit 4bc31ef

Please sign in to comment.