Skip to content

Commit

Permalink
Update download and delete (#217)
Browse files Browse the repository at this point in the history
* Add the new tables and an updated data scrubber to download

* Add test for account deletion, including new external connections factory.

* Streamline the account deletion flow, and update scrubbers/download readme.

* Delete localstorage user data after we delete the account

* ➕ update German translations

---------

Co-authored-by: Andreas Müller <[email protected]>
  • Loading branch information
MelissaAutumn and devmount authored Dec 13, 2023
1 parent 6ffff25 commit e7d5c2e
Show file tree
Hide file tree
Showing 9 changed files with 119 additions and 55 deletions.
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/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,7 @@
},
"text": {
"accountDataNotice": "Lade all Deine Daten von Thunderbird Appointment herunter.",
"accountDeletionFinalWarning": "Warnung: Die Löschung des Benutzerkontos ist dauerhaft! Du wirst all Deine Daten von Thunderbird Appointment verlieren. Deine verbundenen Kalender bleiben unverändert.",
"accountDeletionWarning": "Achtung: Die Löschung des Benutzerkontos ist dauerhaft! Du wirst all Deine Daten von Thunderbird Appointment verlieren. Deine verbundenen Kalender bleiben unverändert. Bestätige die Löschung mit deinen Login-Daten.",
"accountDeletionWarning": "Achtung: Die Löschung des Benutzerkontos ist dauerhaft! Du wirst all Deine Daten von Thunderbird Appointment verlieren. Deine verbundenen Kalender bleiben selbstverständlich unverändert. Du kannst jederzeit ein neues Konto erstellen.",
"calendarDeletionWarning": "Wenn die Verbindung zu diesem Kalender getrennt wird, werden alle Termine und Zeitpläne aus Thunderbird Appointment entfernt. Es werden keine Termine entfernt, die derzeit in diesem Kalendern gespeichert sind.",
"chooseDateAndTime": "Wähle einen Tag und eine Zeit für ein Treffen:",
"connectZoom": "Du kannst dein Zoom-Konto verbinden, um Besprechungen direkt mit einer Zoom-Einladung zu erweitern.",
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

0 comments on commit e7d5c2e

Please sign in to comment.