Skip to content

Commit

Permalink
Add a preferred email option in account settings (Fixes #408)
Browse files Browse the repository at this point in the history
  • Loading branch information
MelissaAutumn committed May 30, 2024
1 parent 89d4328 commit fb4c482
Show file tree
Hide file tree
Showing 17 changed files with 221 additions and 78 deletions.
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
9 changes: 9 additions & 0 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,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 +126,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
7 changes: 6 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,12 @@ 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()

# Remove virtual fields
del data['preferred_email']

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}

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

0 comments on commit fb4c482

Please sign in to comment.