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

Add a preferred email option in account settings (Fixes #408) #440

Merged
merged 3 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions backend/src/appointment/controller/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,8 @@ def create_vevent(
cal = Calendar()
cal.add("prodid", "-//Thunderbird Appointment//tba.dk//")
cal.add("version", "2.0")
org = vCalAddress("MAILTO:" + organizer.email)
org.params["cn"] = vText(organizer.name)
org = vCalAddress("MAILTO:" + organizer.preferred_email)
org.params["cn"] = vText(organizer.preferred_email)
org.params["role"] = vText("CHAIR")
event = Event()
event.add("uid", appointment.uuid.hex)
Expand Down
12 changes: 12 additions & 0 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ def touch(self):
"""Updates the time_updated field with the current datetime. This function does not save the model!"""
self.time_updated = datetime.datetime.now()

def get_columns(self) -> list:
return list(self.__table__.columns.keys())

time_created = Column(DateTime, server_default=func.now(), default=func.now(), index=True)
time_updated = Column(DateTime, server_default=func.now(), default=func.now(), onupdate=func.now(), index=True)

Expand All @@ -102,7 +105,11 @@ class Subscriber(Base):
username = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True)
# Encrypted (here) and hashed (by the associated hashing functions in routes/auth)
password = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False)

# Use subscriber.preferred_email for any email, or other user-facing presence.
email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True)
secondary_email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), nullable=True, index=True)

name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True)
level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True)
timezone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True)
Expand All @@ -122,6 +129,11 @@ def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConn
"""Retrieves the first found external connection by type or returns None if not found"""
return next(filter(lambda ec: ec.type == type, self.external_connections), None)

@property
def preferred_email(self):
"""Returns the preferred email address."""
return self.secondary_email if self.secondary_email is not None else self.email


class Calendar(Base):
__tablename__ = "calendars"
Expand Down
8 changes: 7 additions & 1 deletion backend/src/appointment/database/repo/subscriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ def get_by_google_state(db: Session, state: str):

def create(db: Session, subscriber: schemas.SubscriberBase):
"""create new subscriber"""
db_subscriber = models.Subscriber(**subscriber.dict())
data = subscriber.model_dump()

# Filter incoming data to just the available model columns
columns = models.Subscriber().get_columns()
data = {k: v for k, v in data.items() if k in columns}

db_subscriber = models.Subscriber(**data)
db.add(db_subscriber)
db.commit()
db.refresh(db_subscriber)
Expand Down
2 changes: 2 additions & 0 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,12 @@ class SubscriberIn(BaseModel):
username: str
name: str | None = None
avatar_url: str | None = None
secondary_email: str | None = None


class SubscriberBase(SubscriberIn):
email: str
preferred_email: str | None = None
level: SubscriberLevel | None = SubscriberLevel.basic


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""update subscribers add preferred_email

Revision ID: 9fe08ba6f2ed
Revises: 89e1197d980d
Create Date: 2024-05-28 17:45:48.192560

"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import StringEncryptedType
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine


def secret():
return os.getenv("DB_SECRET")


# revision identifiers, used by Alembic.
revision = '9fe08ba6f2ed'
down_revision = '89e1197d980d'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('subscribers', sa.Column('secondary_email', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), nullable=True, index=True))


def downgrade() -> None:
op.drop_column('subscribers', 'secondary_email')
19 changes: 17 additions & 2 deletions backend/src/appointment/routes/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from ..dependencies.auth import get_subscriber
from ..dependencies.database import get_db

from ..database.models import Subscriber
from ..database.models import Subscriber, ExternalConnectionType
from ..database.repo.external_connection import get_by_type
from ..database import schemas

from fastapi.responses import StreamingResponse
Expand All @@ -30,10 +31,11 @@ def get_external_connections(subscriber: Subscriber = Depends(get_subscriber)):

for ec in subscriber.external_connections:
external_connections[ec.type.name].append(schemas.ExternalConnectionOut(owner_id=ec.owner_id, type=ec.type.name,
type_id=ec.type_id, name=ec.name))
type_id=ec.type_id, name=ec.name))

return external_connections


@router.get("/download")
def download_data(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)):
"""Download your account data in zip format! Returns a streaming response with the zip buffer."""
Expand All @@ -52,3 +54,16 @@ def delete_account(db: Session = Depends(get_db), subscriber: Subscriber = Depen
return data.delete_account(db, subscriber)
except AccountDeletionException as e:
raise HTTPException(status_code=500, detail=e.message)


@router.get("/available-emails")
def get_available_emails(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)):
"""Return the list of emails they can use within Thunderbird Appointment"""
google_connections = get_by_type(db, subscriber_id=subscriber.id, type=ExternalConnectionType.google)

emails = {subscriber.email, *[connection.name for connection in google_connections]} - {subscriber.preferred_email}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should ensure there's no duplicates if they already have their preferred email set as their main email, or a google account email.


return [
subscriber.preferred_email,
*emails
]
9 changes: 7 additions & 2 deletions backend/src/appointment/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ def update_me(

me = repo.subscriber.update(db=db, data=data, subscriber_id=subscriber.id)
return schemas.SubscriberBase(
username=me.username, email=me.email, name=me.name, level=me.level, timezone=me.timezone
username=me.username,
email=me.email,
preferred_email=me.preferred_email,
name=me.name,
level=me.level,
timezone=me.timezone
)


Expand Down Expand Up @@ -473,7 +478,7 @@ def send_feedback(
background_tasks.add_task(
send_support_email,
requestee_name=subscriber.name,
requestee_email=subscriber.email,
requestee_email=subscriber.preferred_email,
topic=form_data.topic,
details=form_data.details,
)
Expand Down
9 changes: 7 additions & 2 deletions backend/src/appointment/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,13 @@ def me(
):
"""Return the currently authed user model"""
return schemas.SubscriberBase(
username=subscriber.username, email=subscriber.email, name=subscriber.name, level=subscriber.level,
timezone=subscriber.timezone, avatar_url=subscriber.avatar_url
username=subscriber.username,
email=subscriber.email,
preferred_email=subscriber.preferred_email,
name=subscriber.name,
level=subscriber.level,
timezone=subscriber.timezone,
avatar_url=subscriber.avatar_url
)


Expand Down
7 changes: 7 additions & 0 deletions backend/src/appointment/routes/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,15 @@ def disconnect_account(
# Remove all of their google calendars (We only support one connection so this should be good for now)
repo.calendar.delete_by_subscriber_and_provider(db, subscriber.id, provider=models.CalendarProvider.google)

# Unassociated any secondary emails if they're attached to their google connection
if subscriber.secondary_email == google_connection.name:
subscriber.secondary_email = None
db.add(subscriber)
db.commit()

# Remove their account details
repo.external_connection.delete_by_type(db, subscriber.id, google_connection.type, google_connection.type_id)



return True
4 changes: 2 additions & 2 deletions backend/src/appointment/routes/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def request_schedule_availability_slot(
db.commit()

# Sending confirmation email to owner
background_tasks.add_task(send_confirmation_email, url=url, attendee_name=attendee.name, date=date, to=subscriber.email)
background_tasks.add_task(send_confirmation_email, url=url, attendee_name=attendee.name, date=date, to=subscriber.preferred_email)

# Sending pending email to attendee
background_tasks.add_task(send_pending_email, owner_name=subscriber.name, date=attendee_date, to=slot.attendee.email)
Expand Down Expand Up @@ -376,7 +376,7 @@ def decide_on_schedule_availability_slot(
capture_exception(err)

# Notify the organizer that the meeting link could not be created!
background_tasks.add_task(send_zoom_meeting_failed_email, to=subscriber.email, appointment_title=schedule.name)
background_tasks.add_task(send_zoom_meeting_failed_email, to=subscriber.preferred_email, appointment_title=schedule.name)
except SQLAlchemyError as err: # Not fatal, but could make things tricky
logging.error("Failed to save the zoom meeting link to the appointment: ", err)
if os.getenv('SENTRY_DSN') != '':
Expand Down
9 changes: 9 additions & 0 deletions backend/test/integration/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@


class TestAuth:
def test_me(self, with_db, with_client):
response = with_client.get('/me', headers=auth_headers)
assert response.status_code == 200, response.text
data = response.json()
assert data.get('username') == os.getenv('TEST_USER_EMAIL')
assert data.get('email') == os.getenv('TEST_USER_EMAIL')
assert data.get('secondary_email') is None
assert data.get('preferred_email') == os.getenv('TEST_USER_EMAIL')

def test_token(self, with_db, with_client, make_pro_subscriber):
"""Test that our username/password authentication works correctly."""
password = 'test'
Expand Down
13 changes: 5 additions & 8 deletions backend/test/integration/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def test_update_me(self, with_db, with_client):
"username": "test",
"name": "Test Account",
"timezone": "Europe/Berlin",
"secondary_email": "[email protected]"
},
headers=auth_headers,
)
Expand All @@ -20,21 +21,17 @@ def test_update_me(self, with_db, with_client):
assert data["username"] == "test"
assert data["name"] == "Test Account"
assert data["timezone"] == "Europe/Berlin"

# Can't test login right now

# response = client.get("/login", headers=headers)
# data = response.json()
# assert data["username"] == "test"
# assert data["name"] == "Test Account"
# assert data["timezone"] == "Europe/Berlin"
# Response returns preferred_email
assert data["preferred_email"] == "[email protected]"

# Confirm the data was saved
with with_db() as db:
subscriber = repo.subscriber.get_by_email(db, os.getenv('TEST_USER_EMAIL'))
assert subscriber.username == "test"
assert subscriber.name == "Test Account"
assert subscriber.timezone == "Europe/Berlin"
assert subscriber.secondary_email == "[email protected]"
assert subscriber.preferred_email == "[email protected]"

def test_signed_short_link(self, with_client):
"""Retrieves our unique short link, and ensures it exists"""
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/BookingModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@
</template>

<script setup>
import { inject, computed, reactive, ref, onMounted } from 'vue';
import {
inject, computed, reactive, ref, onMounted,
} from 'vue';
import { timeFormat } from '@/utils';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
Expand Down Expand Up @@ -109,7 +111,9 @@ const props = defineProps({

// Store
const bookingModalStore = useBookingModalStore();
const { open, state, stateData, isLoading, hasErrors, isFinished, isEditable } = storeToRefs(bookingModalStore);
const {
open, state, stateData, isLoading, hasErrors, isFinished, isEditable,
} = storeToRefs(bookingModalStore);

// Refs

Expand Down Expand Up @@ -141,7 +145,7 @@ const bookIt = () => {
onMounted(() => {
if (user.exists()) {
attendee.name = user.data.name;
attendee.email = user.data.email;
attendee.email = user.data.preferredEmail;
attendee.timezone = user.data.timezone;
}
});
Expand Down
Loading