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 subscriber language and localize emails #803

Merged
merged 9 commits into from
Dec 20, 2024
Merged
55 changes: 39 additions & 16 deletions backend/src/appointment/controller/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ def __init__(
html: str = '',
plain: str = '',
attachments: list[Attachment] = [],
method: str = 'REQUEST'
method: str = 'REQUEST',
lang: str = None
):
self.sender = sender
self.to = to
Expand All @@ -66,6 +67,7 @@ def __init__(
self.body_plain = plain
self.attachments = attachments
self.method = method
self.lang = lang

def html(self):
"""provide email body as html per default"""
Expand Down Expand Up @@ -164,7 +166,9 @@ def send(self):

class BaseBookingMail(Mailer):
def __init__(self, name, email, date, duration, *args, **kwargs):
"""Base class for emails with name, email, and event information"""
"""Base class for emails with name, email, and event information
Can have a different locale by providing a lang argument
"""
self.name = name
self.email = email
self.date = date
Expand All @@ -174,8 +178,8 @@ def __init__(self, name, email, date, duration, *args, **kwargs):
super().__init__(*args, **kwargs)

# Localize date and time range format
self.time_format = l10n('time-format')
self.date_format = l10n('date-format')
self.time_format = l10n('time-format', lang=self.lang)
self.date_format = l10n('date-format', lang=self.lang)

# If value is key then there's no localization available, set a default.
if self.time_format == 'time-format':
Expand Down Expand Up @@ -217,7 +221,10 @@ def _attachments(self):

class InvitationMail(BaseBookingMail):
def __init__(self, *args, **kwargs):
"""init Mailer with invitation specific defaults"""
"""Init Mailer with invitation/booking-accepted specific defaults
To: Bookee
Reply-To: Event owner
"""
default_kwargs = {
'subject': l10n('invite-mail-subject'),
'plain': l10n('invite-mail-plain'),
Expand All @@ -236,14 +243,14 @@ def html(self):
# Icon cids
calendar_icon_cid=self._attachments()[0].filename,
clock_icon_cid=self._attachments()[1].filename,
# Calendar ics cid
#invite_cid=self._attachments()[2].filename,
)


class ZoomMeetingFailedMail(Mailer):
def __init__(self, appointment_title, *args, **kwargs):
"""init Mailer with invitation specific defaults"""
"""Init Mailer with zoom-meeting-failed specific defaults
To: Event owner
"""
default_kwargs = {'subject': l10n('zoom-invite-failed-subject')}
super(ZoomMeetingFailedMail, self).__init__(*args, **default_kwargs, **kwargs)

Expand All @@ -258,11 +265,13 @@ def text(self):

class ConfirmationMail(BaseBookingMail):
def __init__(self, confirm_url, deny_url, name, email, date, duration, schedule_name, *args, **kwargs):
"""init Mailer with confirmation specific defaults"""
"""Init Mailer with action-required:confirm/deny specific defaults
To: Event owner
"""
self.confirmUrl = confirm_url
self.denyUrl = deny_url
self.schedule_name = schedule_name
default_kwargs = {'subject': l10n('confirm-mail-subject', {'name': name})}
default_kwargs = {'subject': l10n('confirm-mail-subject', {'name': name}, kwargs['lang'])}
super().__init__(name=name, email=email, date=date, duration=duration, *args, **default_kwargs, **kwargs)


Expand Down Expand Up @@ -292,6 +301,7 @@ def html(self):
confirm=self.confirmUrl,
deny=self.denyUrl,
schedule_name=self.schedule_name,
lang=self.lang,
# Icon cids
calendar_icon_cid=self._attachments()[0].filename,
clock_icon_cid=self._attachments()[1].filename,
Expand All @@ -300,7 +310,10 @@ def html(self):

class RejectionMail(Mailer):
def __init__(self, owner_name, date, *args, **kwargs):
"""init Mailer with rejection specific defaults"""
"""Init Mailer with rejection specific defaults
To: Bookee
Reply-To: Event owner
"""
self.owner_name = owner_name
self.date = date
default_kwargs = {'subject': l10n('reject-mail-subject')}
Expand All @@ -316,7 +329,9 @@ def html(self):

class PendingRequestMail(Mailer):
def __init__(self, owner_name, date, *args, **kwargs):
"""init Mailer with pending specific defaults"""
"""Init Mailer with pending-request specific defaults
To: Bookee
"""
self.owner_name = owner_name
self.date = date
default_kwargs = {'subject': l10n('pending-mail-subject')}
Expand All @@ -331,10 +346,14 @@ def html(self):

class NewBookingMail(BaseBookingMail):
def __init__(self, name, email, date, duration, schedule_name, *args, **kwargs):
"""init Mailer with confirmation specific defaults"""
"""Init Mailer with new-booking specific defaults
To: Event owner
Reply-To: Bookee
"""
self.schedule_name = schedule_name
default_kwargs = {'subject': l10n('new-booking-subject', {'name': name})}
super().__init__(name=name, email=email, date=date, duration=duration, *args, **default_kwargs, **kwargs)
lang = kwargs['lang'] if 'lang' in kwargs else None
default_kwargs = {'subject': l10n('new-booking-subject', {'name': name}, lang)}
super(NewBookingMail, self).__init__(name=name, email=email, date=date, duration=duration, *args, **default_kwargs, **kwargs)
self.reply_to = email

def text(self):
Expand Down Expand Up @@ -364,13 +383,17 @@ def html(self):

class SupportRequestMail(Mailer):
def __init__(self, requestee_name, requestee_email, topic, details, *args, **kwargs):
"""init Mailer with support specific defaults"""
"""Init Mailer with support specific defaults
To: Support
Reply-To: Requestee
"""
self.requestee_name = requestee_name
self.requestee_email = requestee_email
self.topic = topic
self.details = details
default_kwargs = {'subject': l10n('support-mail-subject', {'topic': topic})}
super(SupportRequestMail, self).__init__(os.getenv('SUPPORT_EMAIL', '[email protected]'), *args, **default_kwargs, **kwargs)
self.reply_to = requestee_email
devmount marked this conversation as resolved.
Show resolved Hide resolved

def text(self):
return l10n(
Expand Down
2 changes: 2 additions & 0 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine
from sqlalchemy.orm import relationship, as_declarative, declared_attr, Mapped
from sqlalchemy.sql import func
from appointment.defines import FALLBACK_LOCALE


def secret():
Expand Down Expand Up @@ -133,6 +134,7 @@ class Subscriber(HasSoftDelete, Base):

name = Column(encrypted_type(String), index=True)
level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True)
language = Column(encrypted_type(String), nullable=False, default=FALLBACK_LOCALE, index=True)
timezone = Column(encrypted_type(String), index=True)
avatar_url = Column(encrypted_type(String, length=2048), index=False)

Expand Down
4 changes: 2 additions & 2 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from pydantic import BaseModel, Field, EmailStr, model_validator
from pydantic_core import PydanticCustomError

from ..defines import DEFAULT_CALENDAR_COLOUR
from ..defines import DEFAULT_CALENDAR_COLOUR, FALLBACK_LOCALE
from ..l10n import l10n


Expand Down Expand Up @@ -307,6 +307,7 @@ class SubscriberIn(BaseModel):
name: Optional[str] = Field(min_length=1, max_length=128, default=None)
avatar_url: str | None = None
secondary_email: str | None = None
language: str | None = FALLBACK_LOCALE


class SubscriberBase(SubscriberIn):
Expand Down Expand Up @@ -499,4 +500,3 @@ class PageLoadIn(BaseModel):
class FTUEStepIn(BaseModel):
step_level: int
step_name: str

12 changes: 10 additions & 2 deletions backend/src/appointment/l10n.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
from typing import Union, Dict, Any

from starlette_context import context, errors
from .middleware.l10n import get_fluent


def l10n(msg_id: str, args: Union[Dict[str, Any], None] = None) -> str:
"""Helper function to automatically call fluent.format_value from context"""
def l10n(msg_id: str, args: Union[Dict[str, Any], None] = None, lang: str = None) -> str:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The l10n helper now accepts a third parameter for a custom language, independent from the request.

"""Helper function to automatically call fluent.format_value from context.
If a lang parameter was given, no context to retrieve languages from is needed
and the translation can be directly loaded
"""
if lang:
return get_fluent([lang])(msg_id, args)

# Get locale from context
try:
if 'l10n' not in context:
return msg_id
Expand Down
30 changes: 19 additions & 11 deletions backend/src/appointment/middleware/l10n.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@
from ..defines import SUPPORTED_LOCALES, FALLBACK_LOCALE


def get_fluent(locales: list[str]):
"""Provides fluent's format_value function for given locales"""

# Make sure our fallback locale is always in locales
if FALLBACK_LOCALE not in locales:
locales.append(FALLBACK_LOCALE)

base_url = 'src/appointment/l10n'

loader = FluentResourceLoader(f'{base_url}/{{locale}}')
fluent = FluentLocalization(locales, ['main.ftl', 'email.ftl', 'fields.ftl'], loader)

return fluent.format_value
devmount marked this conversation as resolved.
Show resolved Hide resolved


class L10n(Plugin):
"""Provides fluent's format_value function via context['l10n']"""

Expand All @@ -30,19 +45,12 @@ def parse_accept_language(self, accept_language_header):

return parsed_locales

def get_fluent(self, accept_languages):
supported_locales = self.parse_accept_language(accept_languages)

# Make sure our fallback locale is always in supported_locales
if FALLBACK_LOCALE not in supported_locales:
supported_locales.append(FALLBACK_LOCALE)

base_url = 'src/appointment/l10n'
def get_fluent_with_header(self, accept_languages):
supported_locales = self.parse_accept_language(accept_languages)

loader = FluentResourceLoader(f'{base_url}/{{locale}}')
fluent = FluentLocalization(supported_locales, ['main.ftl', 'email.ftl', 'fields.ftl'], loader)
return get_fluent(supported_locales)

return fluent.format_value

async def process_request(self, request: Request):
return self.get_fluent(request.headers.get('accept-language', FALLBACK_LOCALE))
return self.get_fluent_with_header(request.headers.get('accept-language', FALLBACK_LOCALE))
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Add language to subscriber table

Revision ID: b398005a40e7
Revises: d791a3f0e478
Create Date: 2024-12-18 12:26:56.211080

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import Session

from appointment.database import models
from appointment.defines import FALLBACK_LOCALE

# revision identifiers, used by Alembic.
revision = 'b398005a40e7'
down_revision = 'd791a3f0e478'
branch_labels = None
depends_on = None


def upgrade() -> None:
# Add language column to subscribers table
op.add_column('subscribers', sa.Column('language', models.encrypted_type(sa.String), nullable=True, default=FALLBACK_LOCALE, index=True))

# Prefill new column with default value
session = Session(op.get_bind())
subscribers: list[models.Subscriber] = session.query(models.Subscriber).all()
Copy link
Member

Choose a reason for hiding this comment

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

You'll have to confirm but having a default value and nullable=False should remove the need for this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I thought so too, in fact I first tried it with default values. But it didn't work, the language column would always stay empty... I thought maybe it's because of the encrypted type? Maybe you have another idea?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I switched to db defaults, you can test the branch again, if you like

Copy link
Member

Choose a reason for hiding this comment

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

Looks like it works with server_default (which means the default value defined in the table as opposed to the default value sqlalchemy provides), so switched it over to that.

for subscriber in subscribers:
subscriber.language = FALLBACK_LOCALE

# Add the subscriber to the database session and commit (update) it
session.add(subscriber)
session.commit()



def downgrade() -> None:
op.drop_column('subscribers', 'language')
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""make subscribers.language not nullable

Revision ID: 0c99f6a02f3b
Revises: b398005a40e7
Create Date: 2024-12-20 17:33:19.666412

"""

from alembic import op
import sqlalchemy as sa
from appointment.database import models

# revision identifiers, used by Alembic.
revision = '0c99f6a02f3b'
down_revision = 'b398005a40e7'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.alter_column('subscribers', 'language', type_=models.encrypted_type(sa.String), nullable=False)


def downgrade() -> None:
op.alter_column('subscribers', 'language', type_=models.encrypted_type(sa.String), nullable=True)
2 changes: 0 additions & 2 deletions backend/src/appointment/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,5 +486,3 @@ def terms():
with open(f'{os.path.dirname(__file__)}/../templates/legal/en/terms.jinja2') as fh:
contents = fh.read()
return HTMLResponse(contents)


14 changes: 11 additions & 3 deletions backend/src/appointment/routes/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,15 @@ def request_schedule_availability_slot(
if schedule.booking_confirmation:
# Sending confirmation email to owner
background_tasks.add_task(
send_confirmation_email, url=url, attendee_name=attendee.name, attendee_email=attendee.email, date=date,
duration=slot.duration, to=subscriber.preferred_email, schedule_name=schedule.name
send_confirmation_email,
url=url,
attendee_name=attendee.name,
attendee_email=attendee.email,
date=date,
duration=slot.duration,
schedule_name=schedule.name,
to=subscriber.preferred_email,
lang=subscriber.language
)

# Create remote HOLD event
Expand Down Expand Up @@ -416,7 +423,8 @@ def request_schedule_availability_slot(
date=date,
duration=slot.duration,
schedule_name=schedule.name,
to=subscriber.preferred_email
to=subscriber.preferred_email,
lang=subscriber.language
)

# Mini version of slot, so we can grab the newly created slot id for tests
Expand Down
8 changes: 4 additions & 4 deletions backend/src/appointment/tasks/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ def send_invite_email(owner_name, owner_email, date, duration, to, attachment):
sentry_sdk.capture_exception(e)


def send_confirmation_email(url, attendee_name, attendee_email, date, duration, to, schedule_name):
def send_confirmation_email(url, attendee_name, attendee_email, date, duration, to, schedule_name, lang):
# send confirmation mail to owner
try:
mail = ConfirmationMail(
f'{url}/1', f'{url}/0', attendee_name, attendee_email, date, duration, schedule_name, to=to
f'{url}/1', f'{url}/0', attendee_name, attendee_email, date, duration, schedule_name, to=to, lang=lang
)
mail.send()
except Exception as e:
Expand All @@ -47,10 +47,10 @@ def send_confirmation_email(url, attendee_name, attendee_email, date, duration,
sentry_sdk.capture_exception(e)


def send_new_booking_email(name, email, date, duration, to, schedule_name):
def send_new_booking_email(name, email, date, duration, to, schedule_name, lang):
# send notice mail to owner
try:
mail = NewBookingMail(name, email, date, duration, schedule_name, to=to)
mail = NewBookingMail(name, email, date, duration, schedule_name, to=to, lang=lang)
mail.send()
except Exception as e:
if os.getenv('APP_ENV') == APP_ENV_DEV:
Expand Down
Loading
Loading