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

Update download and delete #217

Merged
merged 5 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
49 changes: 29 additions & 20 deletions backend/src/appointment/controller/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from zipfile import ZipFile

from ..database import repo
from ..database.schemas import Subscriber
from ..database.models import Subscriber
from ..download_readme import get_download_readme
from ..exceptions.account_api import AccountDeletionPartialFail, AccountDeletionSubscriberFail

Expand All @@ -13,10 +13,13 @@ def model_to_csv_buffer(models):
if len(models) == 0:
return StringIO()

# Don't write out these columns
scrub_columns = ['password', 'google_tkn', 'google_state', 'google_state_expires_at', 'token']

string_buffer = StringIO()

writer = csv.writer(string_buffer)
columns = models[0].__table__.c
columns = list(filter(lambda c: c.name not in scrub_columns, models[0].__table__.c))

writer.writerow(columns)
for model in models:
Expand All @@ -38,16 +41,23 @@ def download(db, subscriber: Subscriber):
calendars = repo.get_calendars_by_subscriber(db, subscriber_id=subscriber.id)
subscribers = [subscriber]
slots = repo.get_slots_by_subscriber(db, subscriber_id=subscriber.id)

# Clear out the google token (?)
subscribers[0].google_tkn = None
external_connections = subscriber.external_connections
schedules = repo.get_schedules_by_subscriber(db, subscriber.id)
availability = [repo.get_availability_by_schedule(db, schedule.id) for schedule in schedules]

# Convert models to csv
attendee_buffer = model_to_csv_buffer(attendees)
appointment_buffer = model_to_csv_buffer(appointments)
calendar_buffer = model_to_csv_buffer(calendars)
subscriber_buffer = model_to_csv_buffer(subscribers)
slot_buffer = model_to_csv_buffer(slots)
external_connections_buffer = model_to_csv_buffer(external_connections)
schedules_buffer = model_to_csv_buffer(schedules)

# Unique behaviour because we can have lists of lists..too annoying to not do it this way.
availability_buffer = ''
for avail in availability:
availability_buffer += model_to_csv_buffer(avail).getvalue()

# Create an in-memory zip and append our csvs
zip_buffer = BytesIO()
Expand All @@ -57,40 +67,39 @@ def download(db, subscriber: Subscriber):
data_zip.writestr("calendar.csv", calendar_buffer.getvalue())
data_zip.writestr("subscriber.csv", subscriber_buffer.getvalue())
data_zip.writestr("slot.csv", slot_buffer.getvalue())
data_zip.writestr("external_connection.csv", external_connections_buffer.getvalue())
data_zip.writestr("schedules.csv", schedules_buffer.getvalue())
data_zip.writestr("availability.csv", availability_buffer)
data_zip.writestr("readme.txt", get_download_readme())

# Return our zip buffer
return zip_buffer


def delete_account(db, subscriber: Subscriber):
# Ok nuke everything
repo.delete_attendees_by_subscriber(db, subscriber.id)
repo.delete_appointment_slots_by_subscriber_id(db, subscriber.id)
repo.delete_calendar_appointments_by_subscriber_id(db, subscriber.id)
repo.delete_subscriber_calendar_by_subscriber_id(db, subscriber.id)
# Ok nuke everything (thanks cascade=all,delete)
repo.delete_subscriber(db, subscriber)

# Make sure we actually nuked the subscriber
if repo.get_subscriber(db, subscriber.id) is not None:
raise AccountDeletionSubscriberFail(
subscriber.id,
"There was a problem deleting your data. This incident has been logged and your data will manually be removed.",
)

empty_check = [
len(repo.get_attendees_by_subscriber(db, subscriber.id)),
len(repo.get_slots_by_subscriber(db, subscriber.id)),
len(repo.get_appointments_by_subscriber(db, subscriber.id)),
len(repo.get_calendars_by_subscriber(db, subscriber.id)),
len(repo.get_schedules_by_subscriber(db, subscriber.id))
]

# Check if we have any left-over subscriber data before we nuke the subscriber
# Check if we have any left-over subscriber data
if any(empty_check) > 0:
raise AccountDeletionPartialFail(
subscriber.id,
"There was a problem deleting your data. This incident has been logged and your data will manually be removed.",
)

repo.delete_subscriber(db, subscriber)

# Make sure we actually nuked the subscriber
if repo.get_subscriber(db, subscriber.id) is not None:
raise AccountDeletionSubscriberFail(
subscriber.id,
"There was a problem deleting your data. This incident has been logged and your data will manually be removed.",
)

return True
7 changes: 5 additions & 2 deletions backend/src/appointment/download_readme.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ def get_download_readme():
The following files are included:
- appointments.csv : A list of Appointments from our database
- attendees.csv : A list of Appointment Slot Attendees from our database
- availability.csv : Not used right now
- calendars.csv : A list of Calendars from our database
- slots.csv : A list of Appointment Slots from our database.
- subscriber.csv : The personal information we store about you from our database.
- external_connections.csv : A list of external services you've connected to your account
- slots.csv : A list of Appointment Slots from our database
- schedules.csv : Your general availability schedule
- subscriber.csv : The personal information we store about you from our database
- readme.txt : This file!
""".format(
download_time=datetime.datetime.now(datetime.UTC)
Expand Down
1 change: 1 addition & 0 deletions backend/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from factory.attendee_factory import make_attendee # noqa: F401
from factory.appointment_factory import make_appointment # noqa: F401
from factory.calendar_factory import make_caldav_calendar # noqa: F401
from factory.external_connection_factory import make_external_connections # noqa: F401
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
Expand Down
26 changes: 26 additions & 0 deletions backend/test/factory/external_connection_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pytest
from faker import Faker
from backend.src.appointment.database import repo, schemas, models
from defines import FAKER_RANDOM_VALUE, factory_has_value


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

def _make_external_connections(subscriber_id,
name=FAKER_RANDOM_VALUE,
type=FAKER_RANDOM_VALUE,
type_id=FAKER_RANDOM_VALUE,
token=FAKER_RANDOM_VALUE):
with with_db() as db:
return repo.create_subscriber_external_connection(db, schemas.ExternalConnection(
owner_id=subscriber_id,
name=name if factory_has_value(name) else fake.name(),
type=type if factory_has_value(type) else fake.random_element(
(models.ExternalConnectionType.zoom.value, models.ExternalConnectionType.google.value, models.ExternalConnectionType.fxa.value)),
type_id=type_id if factory_has_value(type_id) else fake.uuid4(),
token=token if factory_has_value(token) else fake.password(),
))

return _make_external_connections
46 changes: 46 additions & 0 deletions backend/test/unit/test_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from argon2 import PasswordHasher

from backend.src.appointment.controller.data import model_to_csv_buffer, delete_account


class TestData:
def test_model_to_csv_buffer(self, make_pro_subscriber):
"""Make sure our model to csv buffer is working, scrubbers and all!"""
ph = PasswordHasher()

password = "cool beans"
subscriber = make_pro_subscriber(password=password)

buffer = model_to_csv_buffer([subscriber])
csv_data = buffer.getvalue()

assert csv_data
# Check if our scrubber is working as intended
assert 'subscriber.password' not in csv_data
assert password not in csv_data
assert ph.hash(password) not in csv_data
# Ensure that some of our subscriber data is there
assert subscriber.email in csv_data
assert subscriber.username in csv_data

def test_delete_account(self, with_db, make_pro_subscriber, make_appointment, make_schedule, make_caldav_calendar, make_external_connections):
"""Test that our delete account functionality actually deletes everything"""
subscriber = make_pro_subscriber()
calendar = make_caldav_calendar(subscriber_id=subscriber.id)
appointment = make_appointment(calendar_id=calendar.id)
schedule = make_schedule(calendar_id=calendar.id)
external_connection = make_external_connections(subscriber_id=subscriber.id)

# Get some relationships
slots = appointment.slots

# Bunch them together into a list. They must have an id field, otherwise assert them manually.
models_to_check = [subscriber, external_connection, calendar, appointment, schedule, *slots]

with with_db() as db:
ret = delete_account(db, subscriber)
assert ret is True

for model in models_to_check:
check = db.get(model.__class__, model.id)
assert check is None, f"Ensuring {model.__class__} is None"
5 changes: 4 additions & 1 deletion frontend/src/components/ConfirmationModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
</div>
<div class="flex gap-4">
<secondary-button :label="cancelLabel" @click="emit('close')" />
<primary-button :label="confirmLabel" @click="emit('confirm')" />
<primary-button v-if="!useCautionButton" :label="confirmLabel" @click="emit('confirm')" />
<caution-button v-else :label="confirmLabel" @click="emit('confirm')" />
</div>
</div>
</template>

<script setup>
import PrimaryButton from '@/elements/PrimaryButton';
import SecondaryButton from '@/elements/SecondaryButton';
import CautionButton from '@/elements/CautionButton';

// icons
import { IconX } from '@tabler/icons-vue';
Expand All @@ -37,6 +39,7 @@ defineProps({
message: String,
confirmLabel: String,
cancelLabel: String,
useCautionButton: Boolean,
});

// component emits
Expand Down
34 changes: 6 additions & 28 deletions frontend/src/components/SettingsAccount.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,35 +123,26 @@
:open="downloadAccountModalOpen"
:title="t('label.accountData')"
:message="t('text.accountDataNotice')"
:confirm-label="t('label.loginToContinue')"
:confirm-label="t('label.continue')"
:cancel-label="t('label.cancel')"
@confirm="() => reauthenticateSubscriber(actuallyDownloadData)"
@confirm="actuallyDownloadData"
@close="closeModals"
></ConfirmationModal>
<!-- Account deletion modals -->
<ConfirmationModal
:open="deleteAccountFirstModalOpen"
:title="t('label.deleteYourAccount')"
:message="t('text.accountDeletionWarning')"
:confirm-label="t('label.loginToContinue')"
:cancel-label="t('label.cancel')"
@confirm="() => reauthenticateSubscriber(secondDeleteAccountPrompt)"
@close="closeModals"
></ConfirmationModal>
<ConfirmationModal
:open="deleteAccountSecondModalOpen"
:title="t('label.deleteYourAccount')"
:message="t('text.accountDeletionFinalWarning')"
:confirm-label="t('label.deleteYourAccount')"
:cancel-label="t('label.cancel')"
:use-caution-button="true"
@confirm="actuallyDeleteAccount"
@close="closeModals"
></ConfirmationModal>
</template>

<script setup>
import { ref, inject, onMounted, computed } from 'vue';
import { useAuth0 } from '@auth0/auth0-vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user-store';
Expand All @@ -170,7 +161,6 @@ const call = inject('call');
const refresh = inject('refresh');
const router = useRouter();
const user = useUserStore();
const logout = inject('logout');

const externalConnections = ref({});
const hasZoomAccountConnected = computed(() => (externalConnections.value?.zoom?.length ?? []) > 0);
Expand Down Expand Up @@ -298,21 +288,7 @@ const refreshLinkConfirm = async () => {
* @returns {Promise<void>}
*/
const reauthenticateSubscriber = async (callbackFn) => {
/*
try {
// Prompt the user to re-login
await auth0.loginWithPopup({
authorizationParams: {
prompt: 'login',
},
}, {});
} catch (e) {
// TODO: Throw an error
console.log('Reauth failed', e);
closeModals();
return;
}
*/
// Currently not supported
await callbackFn();
};

Expand Down Expand Up @@ -349,6 +325,8 @@ const actuallyDeleteAccount = async () => {
return;
}

// We can't logout since we've deleted the user by now, so just delete local storage data.
await user.reset();
await router.push('/');
};

Expand Down
3 changes: 1 addition & 2 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,7 @@
},
"text": {
"accountDataNotice": "Download all of your data from Thunderbird Appointment.",
"accountDeletionFinalWarning": "Your account and data on Thunderbird Appointment will be deleted. But you can create a new account with us anytime.",
"accountDeletionWarning": "Are you sure? Deleting your account is permanent and your saved data will be lost. This does not impact your linked calendars.",
"accountDeletionWarning": "Your account and data on Thunderbird Appointment will be deleted. This does not impact your linked calendars. And you can create a new account with us anytime.",
"calendarDeletionWarning": "Disconnecting this calendar will remove all appointments and schedules from Thunderbird Appointment. Any confirmed events currently stored in your calendar will not be removed.",
"chooseDateAndTime": "Choose when to meet.",
"connectZoom": "You can connect your Zoom account to enable instant meeting invites with your appointments.",
Expand Down