diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index d827cedb0..b7fc61836 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -25,8 +25,7 @@ jobs: run: | cd ./backend python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install -r requirements-test.txt + python -m pip install .'[test]' - name: Test with pytest run: | - cd ./backend/test && python -m pytest + cd ./backend && python -m pytest diff --git a/.gitignore b/.gitignore index 026e248ee..14dd1a240 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,13 @@ -.vscode -__pycache__ -.pytest_cache -dist -*.db -*.ini -*.log -*credentials.json -*.pickle -.env -venv -.idea \ No newline at end of file +.vscode +__pycache__ +.pytest_cache +dist +*.db +*.ini +*.log +*credentials.json +*.pickle +.env +venv +.idea +*.egg-info diff --git a/README.md b/README.md index ee2798952..4bd94da5a 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ Run application for development with hot reloading backend and frontend: ```bash cd appointment - pip install -r backend/requirements.txt + pip install . touch backend/src/appointment.db # when using sqlite cp backend/.env.example backend/.env # add your own configuration here - uvicorn --factory backend.src.appointment.main:server --host 0.0.0.0 --port 5000 + uvicorn --factory appointment.main:server --host 0.0.0.0 --port 5000 ``` You can now access the backend at [localhost:5000](http://localhost:5000). @@ -74,16 +74,16 @@ Run application for development with hot reloading backend and frontend: ## Testing -To run tests, setup the application manually (you don't need the mysql deps), and then install requirements-test.txt +To run tests, simply install the package in editing mode: ```bash -pip install -r requirements-test.txt +cd backend && pip install -e . ``` After this you can run tests with: ```bash -cd backend/test && python -m pytest +cd backend && python -m pytest ``` ## Contributing @@ -116,3 +116,7 @@ Commands (from /frontend) yarn run lint yarn run lint --fix ``` + +### Localization + +This project uses [Fluent](https://projectfluent.org/) for localization. Files are located in their respective `l10n//*.ftl`. diff --git a/backend/.env.example b/backend/.env.example index 7f17da42f..58e1854dc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,8 +11,7 @@ FRONTEND_URL=http://localhost:8080 SHORT_BASE_URL= # -- DATABASE -- -DATABASE_URL= -DATABASE_SECRETS= +DATABASE_URL="mysql+mysqldb://tba:tba@mysql:3306/appointment" # Secret phrase for database encryption (e.g. create it by running `openssl rand -hex 32`) DB_SECRET= diff --git a/backend/Dockerfile b/backend/Dockerfile index dfe1219fa..85e8c9668 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,23 +1,28 @@ -FROM python:3.11-buster - -RUN mkdir app -WORKDIR /app - -ENV PATH="${PATH}:/root/.local/bin" -ENV PYTHONPATH=. - -RUN mkdir scripts - -COPY requirements.txt . -COPY pyproject.toml . -COPY alembic.ini.example alembic.ini -COPY scripts/dev-entry.sh scripts/dev-entry.sh - -# Dev only -COPY .env . - -RUN pip install --upgrade pip -RUN pip install . - -EXPOSE 5000 -CMD ["/bin/sh", "./scripts/dev-entry.sh"] \ No newline at end of file +FROM python:3.11-buster + +RUN mkdir app +WORKDIR /app + +ENV PATH="${PATH}:/root/.local/bin" +ENV PYTHONPATH=. + +RUN mkdir scripts + +COPY requirements.txt . +COPY pyproject.toml . +COPY alembic.ini.example alembic.ini +COPY scripts/dev-entry.sh scripts/dev-entry.sh + +# Dev only +COPY .env . + +RUN pip install --upgrade pip +RUN pip install .'[deploy]' + +# Add this hack to line it up with our dev environment. +# I'll buy whoever fixes this a coffee. +RUN mkdir src +RUN ln -s /app/appointment src/appointment + +EXPOSE 5000 +CMD ["/bin/sh", "./scripts/dev-entry.sh"] diff --git a/backend/__init__.py b/backend/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/alembic.ini.example b/backend/alembic.ini.example index a3fdec178..19cb0821f 100644 --- a/backend/alembic.ini.example +++ b/backend/alembic.ini.example @@ -2,7 +2,7 @@ [alembic] # path to migration scripts -script_location = src/appointment/migrations +script_location = appointment/migrations # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # Uncomment the line below if you want the files to be prepended with date and time @@ -12,7 +12,7 @@ file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(re # sys.path path, will be prepended to sys.path if present. # defaults to the current working directory. -prepend_sys_path = src/appointment +prepend_sys_path = appointment # timezone to use when rendering the date within the migration file # as well as the filename. diff --git a/backend/deploy.dockerfile b/backend/deploy.dockerfile index 5c84d88b2..c4462f94e 100644 --- a/backend/deploy.dockerfile +++ b/backend/deploy.dockerfile @@ -17,7 +17,7 @@ COPY scripts/entry.sh scripts/entry.sh COPY src . RUN pip install --upgrade pip -RUN pip install . +RUN pip install .'[deploy]' # install removes the src file and installs the application as /app/appointment # that's fine, but uhh let's add this hack to line it up with our dev environment. @@ -26,4 +26,4 @@ RUN mkdir src RUN ln -s /app/appointment src/appointment EXPOSE 5000 -CMD ["/bin/sh", "./scripts/entry.sh"] \ No newline at end of file +CMD ["/bin/sh", "./scripts/entry.sh"] diff --git a/backend/google_credentials.json.example b/backend/google_credentials.json.example deleted file mode 100644 index bf231acea..000000000 --- a/backend/google_credentials.json.example +++ /dev/null @@ -1,16 +0,0 @@ -{ - "web": { - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "client_id": "some-string.apps.googleusercontent.com", - "client_secret": "SOME-SECRET", - "javascript_origins": [ - "http://allowe-origin-uri" - ], - "project_id": "project-id", - "redirect_uris": [ - "http://allowed-redirect-uri" - ], - "token_uri": "https://oauth2.googleapis.com/token" - } -} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bdbada1b3..dc8065aef 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,12 +1,12 @@ [project] -name = "appointment_backend" +name = "appointment" version = "0.2.0" description = "Backend component to Thunderbird Appointment" requires-python = ">3.11" dynamic = ["dependencies"] [project.scripts] -run-command = "src.appointment.main:cli" +run-command = "appointment.main:cli" [project.urls] homepage = "https://appointment.day" @@ -17,6 +17,16 @@ cli = [ "ruff", "black" ] +db = [ + "mysqlclient==2.1.1", + "mysql-connector-python==8.0.32", +] +test = [ + "Faker==20.1.0", + "httpx==0.25.1", + "pytest==7.4.3", +] +deploy = ['appointment[cli]', 'appointment[db]'] [tool.setuptools.dynamic] dependencies = { file = ["requirements.txt"] } @@ -70,3 +80,6 @@ max-complexity = 10 [tool.black] line-length = 120 + +[tool.pytest.ini_options] +pythonpath = "test" diff --git a/backend/requirements-test.txt b/backend/requirements-test.txt deleted file mode 100644 index 27c1cf093..000000000 --- a/backend/requirements-test.txt +++ /dev/null @@ -1,3 +0,0 @@ -Faker==20.1.0 -httpx==0.25.1 -pytest==7.4.3 diff --git a/backend/requirements.txt b/backend/requirements.txt index 9ac8b62e6..5afec7ed7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,23 +1,25 @@ alembic==1.9.3 argon2-cffi==23.1.0 argon2-cffi-bindings==21.2.0 +Babel==2.14.0 caldav==1.0.1 cryptography==39.0.1 fastapi-auth0==0.3.2 fastapi==0.104.1 +fluent.runtime==0.4.0 +fluent.syntax==0.19.0 google-api-python-client==2.85.0 google-auth-httplib2==0.1.0 google-auth-oauthlib==1.0.0 jinja2==3.1.2 icalendar==5.0.4 itsdangerous==2.1.2 -mysqlclient==2.1.1 -mysql-connector-python==8.0.32 python-dotenv==1.0.0 python-jose==3.3.0 python-multipart==0.0.6 pydantic==2.5.2 sentry-sdk==1.26.0 +starlette-context==0.3.6 sqlalchemy-utils==0.39.0 sqlalchemy==1.4.40 uvicorn==0.20.0 diff --git a/backend/scripts/dev-entry.sh b/backend/scripts/dev-entry.sh index 5c70fe6e0..1425bbd1b 100644 --- a/backend/scripts/dev-entry.sh +++ b/backend/scripts/dev-entry.sh @@ -6,4 +6,5 @@ run-command update-db python -u -m smtpd -n -c DebuggingServer localhost:8050 & # Start up real webserver -uvicorn --factory src.appointment.main:server --reload --host 0.0.0.0 --port 5173 +uvicorn --factory appointment.main:server --reload --host 0.0.0.0 --port 5173 + diff --git a/backend/scripts/entry.sh b/backend/scripts/entry.sh index 22ddedfa8..721b462dd 100644 --- a/backend/scripts/entry.sh +++ b/backend/scripts/entry.sh @@ -2,4 +2,4 @@ run-command update-db -uvicorn --factory src.appointment.main:server --host 0.0.0.0 --port 5000 \ No newline at end of file +uvicorn --factory appointment.main:server --host 0.0.0.0 --port 5000 diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 3215e8c99..ac9e7af2f 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -13,7 +13,7 @@ from ..database import schemas from ..database.models import CalendarProvider from ..controller.mailer import Attachment, InvitationMail - +from ..l10n import l10n DATEFMT = "%Y-%m-%d" @@ -118,10 +118,10 @@ def create_event( # Place url and phone in desc if available: if event.location.url: - description.append(f"Join online at: {event.location.url}") + description.append(l10n('join-online', {'url': event.location.url})) if event.location.phone: - description.append(f"Join by phone: {event.location.phone}") + description.append(l10n('join-phone', {'phone': event.location.phone})) body = { "summary": event.title, diff --git a/backend/src/appointment/controller/data.py b/backend/src/appointment/controller/data.py index cb261be8d..ce5852de5 100644 --- a/backend/src/appointment/controller/data.py +++ b/backend/src/appointment/controller/data.py @@ -1,11 +1,12 @@ import csv +import datetime from io import StringIO, BytesIO from zipfile import ZipFile from ..database import repo -from ..database.models import Subscriber -from ..download_readme import get_download_readme +from ..database.schemas import Subscriber from ..exceptions.account_api import AccountDeletionPartialFail, AccountDeletionSubscriberFail +from ..l10n import l10n def model_to_csv_buffer(models): @@ -70,7 +71,7 @@ def download(db, subscriber: Subscriber): 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()) + data_zip.writestr("readme.txt", l10n('account-data-readme', {'download_time': datetime.datetime.now(datetime.UTC)})) # Return our zip buffer return zip_buffer diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index 15f8e39d1..e84208e0a 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -17,11 +17,23 @@ from email.mime.text import MIMEText from fastapi.templating import Jinja2Templates -templates = Jinja2Templates("src/appointment/templates/email") +from ..l10n import l10n + + +def get_jinja(): + + path = 'src/appointment/templates/email' + + templates = Jinja2Templates(path) + # Add our l10n function + templates.env.globals.update(l10n=l10n) + + return templates def get_template(template_name) -> "jinja2.Template": """Retrieves a template under the templates/email folder. Make sure to include the file extension!""" + templates = get_jinja() return templates.get_template(template_name) @@ -119,7 +131,7 @@ def send(self): server.sendmail(self.sender, self.to, self.build()) except Exception as e: # sending email was not possible - logging.error("[mailer.send] An error occured on sending email: " + str(e)) + logging.error("[mailer.send] An error occurred on sending email: " + str(e)) finally: if server: server.quit() @@ -128,11 +140,11 @@ def send(self): class InvitationMail(Mailer): def __init__(self, *args, **kwargs): """init Mailer with invitation specific defaults""" - defaultKwargs = { - "subject": "[TBA] Invitation sent from Thunderbird Appointment", - "plain": "This message is sent from Thunderbird Appointment.", + default_kwargs = { + "subject": l10n('invite-mail-subject'), + "plain": l10n('invite-mail-body'), } - super(InvitationMail, self).__init__(*args, **defaultKwargs, **kwargs) + super(InvitationMail, self).__init__(*args, **default_kwargs, **kwargs) def html(self): return get_template("invite.jinja2").render() @@ -141,10 +153,10 @@ def html(self): class ZoomMeetingFailedMail(Mailer): def __init__(self, appointment_title, *args, **kwargs): """init Mailer with invitation specific defaults""" - defaultKwargs = { - "subject": "[TBA] Zoom Meeting Link Creation Error", + default_kwargs = { + "subject": l10n('zoom-invite-failed-subject') } - super(ZoomMeetingFailedMail, self).__init__(*args, **defaultKwargs, **kwargs) + super(ZoomMeetingFailedMail, self).__init__(*args, **default_kwargs, **kwargs) self.appointment_title = appointment_title @@ -152,37 +164,30 @@ def html(self): return get_template("errors/zoom_invite_failed.jinja2").render(title=self.appointment_title) def text(self): - return f"Unfortunately there was an error creating your Zoom meeting for your upcoming appointment: {self.appointment_title}" + return l10n('zoom-invite-failed-plain', {'title': self.appointment_title}) class ConfirmationMail(Mailer): - def __init__(self, confirmUrl, denyUrl, attendee, date, *args, **kwargs): + def __init__(self, confirm_url, deny_url, attendee, date, *args, **kwargs): """init Mailer with confirmation specific defaults""" self.attendee = attendee + self.attendee.name = self.attendee.name.title() self.date = date - self.confirmUrl = confirmUrl - self.denyUrl = denyUrl - defaultKwargs = { - "subject": "[TBA] Confirm booking request from Thunderbird Appointment", - "plain": """ -{name} ({email}) just requested this time slot from your schedule: {date} - -Visit this link to confirm the booking request: -{confirm} - -Or this link if you want to deny it: -{deny} - -This message is sent from Thunderbird Appointment. - """.format( - name=self.attendee.name, - email=self.attendee.email, - date=self.date, - confirm=self.confirmUrl, - deny=self.denyUrl - ), + self.confirmUrl = confirm_url + self.denyUrl = deny_url + default_kwargs = { + "subject": l10n('confirm-mail-subject') } - super(ConfirmationMail, self).__init__(*args, **defaultKwargs, **kwargs) + super(ConfirmationMail, self).__init__(*args, **default_kwargs, **kwargs) + + def text(self): + return l10n('confirm-mail-plain', { + 'attendee_name': self.attendee.name, + 'attendee_email': self.attendee.email, + 'date': self.date, + 'confirm_url': self.confirmUrl, + 'deny_url': self.denyUrl, + }) def html(self): return get_template("confirm.jinja2").render( @@ -198,15 +203,16 @@ def __init__(self, owner, date, *args, **kwargs): """init Mailer with rejection specific defaults""" self.owner = owner self.date = date - defaultKwargs = { - "subject": "[TBA] Booking request declined", - "plain": """ -{name} denied your booking request for this time slot: {date}. - -This message is sent from Thunderbird Appointment. - """.format(name=self.owner.name, date=self.date), + default_kwargs = { + "subject": l10n('reject-mail-subject') } - super(RejectionMail, self).__init__(*args, **defaultKwargs, **kwargs) + super(RejectionMail, self).__init__(*args, **default_kwargs, **kwargs) + + def text(self): + return l10n('reject-mail-plain', { + 'owner_name': self.owner.name, + 'date': self.date + }) def html(self): return get_template("rejected.jinja2").render(owner=self.owner, date=self.date) diff --git a/backend/src/appointment/defines.py b/backend/src/appointment/defines.py new file mode 100644 index 000000000..a7431de25 --- /dev/null +++ b/backend/src/appointment/defines.py @@ -0,0 +1,2 @@ +SUPPORTED_LOCALES = ['en', 'de'] +FALLBACK_LOCALE = 'en' diff --git a/backend/src/appointment/dependencies/auth.py b/backend/src/appointment/dependencies/auth.py index 6e7f73774..21b12e50f 100644 --- a/backend/src/appointment/dependencies/auth.py +++ b/backend/src/appointment/dependencies/auth.py @@ -9,6 +9,7 @@ from ..database import repo, schemas from ..dependencies.database import get_db +from ..exceptions.validation import InvalidTokenException oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -18,9 +19,9 @@ def get_user_from_token(db, token: str): payload = jwt.decode(token, os.getenv('JWT_SECRET'), algorithms=[os.getenv('JWT_ALGO')]) sub = payload.get("sub") if sub is None: - raise HTTPException(401, "Could not validate credentials") + raise InvalidTokenException() except JWTError: - raise HTTPException(401, "Could not validate credentials") + raise InvalidTokenException() id = sub.replace('uid-', '') return repo.get_subscriber(db, int(id)) @@ -34,6 +35,6 @@ def get_subscriber( user = get_user_from_token(db, token) if user is None: - raise HTTPException(403, detail='Missing bearer token') + raise InvalidTokenException() return user diff --git a/backend/src/appointment/download_readme.py b/backend/src/appointment/download_readme.py deleted file mode 100644 index 9d51dbe35..000000000 --- a/backend/src/appointment/download_readme.py +++ /dev/null @@ -1,43 +0,0 @@ -import datetime - - -def get_download_readme(): - """Returns the localized download readme""" - return """ -============================================ -Thunderbird Appointment Data Download Readme -============================================ - -Time of download (UTC): {download_time} - -Included in this zip is all of your user data from our system in .csv format. - -============================================ -How Do I View it? -============================================ - -You can open these .csv files in a variety applications like: - - Microsoft Excel - - Google Sheets - - Numbers - - LibreOffice Calc - -============================================ -What's Included? -============================================ - -Note: If a file is empty or blank then we don't have that type of data on record for you. - -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 - - 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) - ) diff --git a/backend/src/appointment/exceptions/validation.py b/backend/src/appointment/exceptions/validation.py new file mode 100644 index 000000000..f4f244e00 --- /dev/null +++ b/backend/src/appointment/exceptions/validation.py @@ -0,0 +1,127 @@ +from fastapi import HTTPException + +from ..l10n import l10n + + +class APIException(HTTPException): + """Base exception for all custom API exceptions + Custom messages are defined in a function, because l10n needs context set before use.""" + status_code = 500 + + def __init__(self, **kwargs): + super().__init__(status_code=self.status_code, detail=self.get_msg(), **kwargs) + + def get_msg(self): + return l10n('unknown-error') + + +class InvalidTokenException(APIException): + """Raise when the subscriber could not be parsed from the auth token""" + status_code = 401 + + def get_msg(self): + return l10n('protected-route-fail') + + +class InvalidLinkException(APIException): + """Raise when verify_subscriber_link fails""" + status_code = 400 + + def get_msg(self): + return l10n('invalid-link') + + +class SubscriberNotFoundException(APIException): + """Raise when the calendar is not found during route validation""" + status_code = 404 + + def get_msg(self): + return l10n('subscriber-not-found') + + +class CalendarNotFoundException(APIException): + """Raise when the calendar is not found during route validation""" + status_code = 404 + + def get_msg(self): + return l10n('calendar-not-found') + + +class CalendarNotAuthorizedException(APIException): + """Raise when the calendar is owned by someone else during route validation""" + status_code = 403 + + def get_msg(self): + return l10n('calendar-not-auth') + + +class CalendarNotConnectedException(APIException): + """Raise when the calendar is owned by someone else during route validation""" + status_code = 403 + + def get_msg(self): + return l10n('calendar-not-active') + + +class AppointmentNotFoundException(APIException): + """Raise when the appointment is not found during route validation""" + status_code = 404 + + def get_msg(self): + return l10n('appointment-not-found') + + +class AppointmentNotAuthorizedException(APIException): + """Raise when the appointment is owned by someone else during route validation""" + status_code = 403 + + def get_msg(self): + return l10n('appointment-not-auth') + + +class ScheduleNotFoundException(APIException): + """Raise when the schedule is not found during route validation""" + status_code = 404 + + def get_msg(self): + return l10n('schedule-not-found') + + +class ScheduleNotAuthorizedException(APIException): + """Raise when the schedule is owned by someone else during route validation""" + status_code = 403 + + def get_msg(self): + return l10n('schedule-not-auth') + + +class SlotNotFoundException(APIException): + """Raise when a timeslot is not found during route validation""" + status_code = 404 + + def get_msg(self): + return l10n('slot-not-found') + + +class SlotAlreadyTakenException(APIException): + """Raise when a timeslot is already taken during route validation""" + status_code = 403 + + def get_msg(self): + return l10n('slot-already-taken') + + +class SlotNotAuthorizedException(APIException): + """Raise when a slot is owned by someone else during route validation""" + status_code = 403 + + def get_msg(self): + return l10n('slot-not-auth') + + +class ZoomNotConnectedException(APIException): + """Raise if the user requires a zoom connection during route validation""" + status_code = 400 + + def get_msg(self): + return l10n('zoom-not-connected') diff --git a/backend/src/appointment/l10n.py b/backend/src/appointment/l10n.py new file mode 100644 index 000000000..8cc39d8fa --- /dev/null +++ b/backend/src/appointment/l10n.py @@ -0,0 +1,10 @@ +from typing import Union, Dict, Any +from starlette_context import context + + +def l10n(msg_id: str, args: Union[Dict[str, Any], None] = None) -> str: + """Helper function to automatically call fluent.format_value from context""" + if 'l10n' not in context: + return msg_id + + return context['l10n'](msg_id, args) diff --git a/backend/src/appointment/l10n/de/main.ftl b/backend/src/appointment/l10n/de/main.ftl new file mode 100644 index 000000000..9d3332935 --- /dev/null +++ b/backend/src/appointment/l10n/de/main.ftl @@ -0,0 +1,105 @@ +### Thunderbird Appointment Backend Strings + +# Locale code +locale = de + +## Health Check + +# Should indicate application wellness. +health-ok = Betriebsbereit + +## General Exceptions + +unknown-error = Ein unbekannter Fehler ist aufgetreten. Bitte später noch einmal versuchen. + +appointment-not-found = Der Termin konnte nicht gefunden werden. +calendar-not-found = Der Kalender konnte nicht gefunden werden. +schedule-not-found = Der Zeitplan konnte nicht gefunden werden. +slot-not-found = Das gewählte Zeitfenster konnte nicht gefunden werden. Bitte erneut versuchen. +subscriber-not-found = Der Benutzer konnte nicht gefunden werden. + +appointment-not-auth = Keine Berechtigung, diesen Termin einzusehen oder zu ändern. +calendar-not-auth = Keine Berechtigung, diesen Kalender anzusehen oder zu ändern. +schedule-not-auth = Keine Berechtigung, diesen Zeitplan einzusehen oder zu ändern. +slot-not-auth = Keine Berechtigung, dieses Zeitfenster zu sehen oder zu ändern. + +account-delete-fail = Bei der Löschung der Daten ist ein Problem aufgetreten. Dieser Vorfall wurde protokolliert und die Daten werden manuell gelöscht. +protected-route-fail = Keine gültigen Authentifizierungsdaten angegeben. +username-not-available = Dieser Benutzername ist bereits vergeben. +invalid-link = Dieser Link ist nicht mehr gültig. +calendar-sync-fail = Bei der Synchronisierung von Kalendern ist ein Fehler aufgetreten. Bitte später noch einmal versuchen. +calendar-not-active = Der Kalender ist nicht verbunden. +slot-not-found = Es gibt keine freien Zeitfenster zu buchen. +slot-already-taken = Das gewählte Zeitfenster ist nicht mehr verfügbar. Bitte erneut versuchen. +slot-invalid-email = Die angegebene E-Mail-Adresse war nicht gültig. Bitte erneut versuchen. + +## Authentication Exceptions + +email-mismatch = E-Mail-Adresse stimmen nicht überein. +invalid-credentials = Die angegebenen Anmeldedaten sind nicht gültig. +oauth-error = Es ist ein Fehler bei der Authentifizierung aufgetreten. Bitte erneut versuchen. + +## Zoom Exceptions + +zoom-not-connected = Es wird ein verbundenes Zoom-Konto benötigt, um einen Meeting-Link zu erstellen. + +## Google Exceptions + +google-connection-error = Fehler bei der Verbindung mit Google API, bitte Verbindung erneut herstellen. +google-scope-changed = Der Zugriff auf Kalender und Ereignisse muss aktiviert sein, um Thunderbird Appointment verwenden zu können. +google-invalid-creds = Die Anmeldedaten für die Google-Authentifizierung sind nicht gültig. +google-auth-fail = Google-Authentifizierung fehlgeschlagen. +google-auth-expired = Die Google-Authentifizierungssitzung ist abgelaufen, bitte erneut versuchen. +google-sync-fail = Bei der Synchronisierung von Kalendern ist ein Fehler aufgetreten. Bitte später noch einmal versuchen. + +## Frontend Facing Strings + +# If the calendar event does not have a title this will be used instead +event-summary-not-found = Ereignis nicht gefunden! + +## Event File Strings + +# Variables: +# $url (String) - A link to the external meeting room for the meeting (e.g. Zoom meeting, Google Meets, Matrix chatroom, etc..) +join-online = Online teilnehmen unter: { $url } + +# Variables: +# $phone (String) - An unformatted phone number for the meeting +join-phone = Per Telefon teilnehmen: { $phone } + +## Account Data Readme + +# This is a text file that is generated and bundled along with your account data +# Variables: +# $download_time (Date Time) - Today's date in UTC time +account-data-readme = =============================================== + Thunderbird Appointment Daten Download Liesmich + =============================================== + + Download-Zeitpunkt (UTC): { $download_time } + + In dieser Zip-Datei sind alle Benutzerdaten aus unserem System im CSV-Format enthalten. + + =============================================== + Wie können die Daten eingesehen werden? + =============================================== + + CSV-Dateien können in vielen Anwendungen geöffnet werden: + - Microsoft Excel + - Google Sheets + - Numbers + - LibreOffice Calc + + =============================================== + Was ist enthalten? + =============================================== + + Hinweis: Wenn eine Datei leer ist, ist diese Art von Daten nicht vorhanden. + + Die folgenden Dateien sind enthalten: + - appointments.csv : Eine Liste von Terminen aus unserer Datenbank + - attendees.csv : Eine Liste von Termin-Teilnehmern aus unserer Datenbank + - calendars.csv : Eine Liste von Kalendern aus unserer Datenbank + - slots.csv : Eine Liste von Zeitfenstern aus unserer Datenbank. + - subscriber.csv : Die persönlichen Informationen, die wir von Benutzern in unserer Datenbank speichern. + - readme.txt : Diese Datei diff --git a/backend/src/appointment/l10n/en/email.ftl b/backend/src/appointment/l10n/en/email.ftl new file mode 100644 index 000000000..91484cbd7 --- /dev/null +++ b/backend/src/appointment/l10n/en/email.ftl @@ -0,0 +1,66 @@ +### Thunderbird Appointment Backend Email Strings + +## General + +-brand-name = Thunderbird Appointment +-brand-footer = This message is sent from {-brand-name}. + +mail-brand-footer = {-brand-footer} + +## Invitation + +invite-mail-subject = Invitation sent from {-brand-name} +invite-mail-plain = {-brand-footer} +invite-mail-html = {-brand-footer} + +## Confirm Appointment + +confirm-mail-subject = Confirm booking request from Thunderbird Appointment +# Variables: +# $attendee_name (String) - Name of the person who requested the appointment +# $appointment_email (String) - Email of the person who requested the appointment +# $date (String) - Date of the Appointment +# $confirm_url (String) - URL that when clicked will confirm the appointment +# $deny_url (String) - URL that when clicked will deny the appointment +confirm-mail-plain = { $attendee_name } ({ $attendee_email }) just requested this time slot from your schedule: { $date } + + Visit this link to confirm the booking request: + { $confirm_url } + + Or this link if you want to deny it: + { $deny_url } + + This message is sent from Thunderbird Appointment. +# Variables: +# $attendee_name (String) - Name of the person who requested the appointment +# $appointment_email (String) - Email of the person who requested the appointment +# $date (String) - Date of the requested appointment +confirm-mail-html-heading = { $attendee_name } ({ $attendee_email }) just requested this time slot from your schedule: { $date }. +confirm-mail-html-confirm-text = Click here to confirm the booking request: +confirm-mail-html-confirm-action = Confirm Booking +confirm-mail-html-deny-text = Or here if you want to deny it: +confirm-mail-html-deny-action = Deny Booking + +## Rejected Appointment + +reject-mail-subject = Booking request declined +# Variables: +# $owner_name (String) - Name of the person who owns the schedule +# $date (String) - Date of the requested appointment +reject-mail-html-heading = { $owner_name } denied your booking request for this time slot: { $date }. +# Variables: +# $owner_name (String) - Name of the person who owns the schedule +# $date (String) - Date of the requested appointment +reject-mail-plain = { $owner_name } denied your booking request for this time slot: { $date }. + {-brand-footer} + +## Zoom Invite Link Failed + +zoom-invite-failed-subject = Zoom Meeting Link Creation Error +# Variables: +# $title - The appointment's title +zoom-invite-failed-html-heading = Unfortunately there was an error creating your Zoom meeting for your upcoming appointment: { $title }. +# Variables: +# $title - The appointment's title +zoom-invite-failed-plain = Unfortunately there was an error creating your Zoom meeting for your upcoming appointment: { $title }. + {-brand-footer} diff --git a/backend/src/appointment/l10n/en/main.ftl b/backend/src/appointment/l10n/en/main.ftl new file mode 100644 index 000000000..1f4b51828 --- /dev/null +++ b/backend/src/appointment/l10n/en/main.ftl @@ -0,0 +1,105 @@ +### Thunderbird Appointment Backend Strings + +# Locale code +locale = en + +## Health Check + +# Should indicate application wellness. +health-ok = Health OK + +## General Exceptions + +unknown-error = An unknown error occurred. Please try again later. + +appointment-not-found = The appointment could not be found. +calendar-not-found = The calendar could not be found. +schedule-not-found = The schedule could not be found. +slot-not-found = The time slot you have selected could not be found. Please try again. +subscriber-not-found = The subscriber could not be found. + +appointment-not-auth = You are not authorized to view or modify this appointment. +calendar-not-auth = You are not authorized to view or modify this calendar. +schedule-not-auth = You are not authorized to view or modify this schedule. +slot-not-auth = You are not authorized to view or modify this time slot. + +account-delete-fail = There was a problem deleting your data. This incident has been logged and your data will manually be removed. +protected-route-fail = No valid authentication credentials provided. +username-not-available = This username has already been taken. +invalid-link = This link is no longer valid. +calendar-sync-fail = An error occurred while syncing calendars. Please try again later. +calendar-not-active = The calendar connection is not active. +slot-not-found = There are no available time slots to book. +slot-already-taken = The time slot you have selected is no longer available. Please try again. +slot-invalid-email = The email you have provided was not valid. Please try again. + +## Authentication Exceptions + +email-mismatch = Email mismatch. +invalid-credentials = The provided credentials are not valid. +oauth-error = There was an error with the authentication flow. Please try again. + +## Zoom Exceptions + +zoom-not-connected = You need a connected Zoom account in order to create a meeting link. + +## Google Exceptions + +google-connection-error = Error connecting with Google API, please re-connect. +google-scope-changed = You must enable Calendar and Event access to use Thunderbird Appointment. +google-invalid-creds = Google authentication credentials are not valid. +google-auth-fail = Google authentication failed. +google-auth-expired = Google authentication session expired, please try again. +google-sync-fail = An error occurred while syncing calendars. Please try again later. + +## Frontend Facing Strings + +# If the calendar event does not have a title this will be used instead +event-summary-not-found = Title not found! + +## Event File Strings + +# Variables: +# $url (String) - A link to the external meeting room for the meeting (e.g. Zoom meeting, Google Meets, Matrix chatroom, etc..) +join-online = Join online at: { $url } + +# Variables: +# $phone (String) - An unformatted phone number for the meeting +join-phone = Join by phone: { $phone } + +## Account Data Readme + +# This is a text file that is generated and bundled along with your account data +# Variables: +# $download_time (Date Time) - Today's date in UTC time +account-data-readme = ============================================ + Thunderbird Appointment Data Download Readme + ============================================ + + Time of download (UTC): { $download_time } + + Included in this zip is all of your user data from our system in .csv format. + + ============================================ + How Do I View it? + ============================================ + + You can open these .csv files in a variety applications like: + - Microsoft Excel + - Google Sheets + - Numbers + - LibreOffice Calc + + ============================================ + What's Included? + ============================================ + + Note: If a file is empty or blank then we don't have that type of data on record for you. + + 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 + - 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. + - readme.txt : This file! diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index fbb234dad..8be487a8e 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -3,7 +3,10 @@ Boot application, init database, authenticate user and provide all API endpoints. """ from starlette.middleware.sessions import SessionMiddleware +from starlette_context.middleware import RawContextMiddleware +from .l10n import l10n +from .middleware.l10n import L10n # Ignore "Module level import not at top of file" # ruff: noqa: E402 from .secrets import normalize_secrets @@ -22,6 +25,7 @@ from fastapi.exception_handlers import ( http_exception_handler, ) +from starlette_context import context import sentry_sdk @@ -85,6 +89,13 @@ def server(): # init app app = FastAPI(openapi_url=openapi_url) + app.add_middleware( + RawContextMiddleware, + plugins=( + L10n(), + ) + ) + app.add_middleware( SessionMiddleware, secret_key=os.getenv("SESSION_SECRET") @@ -105,11 +116,10 @@ def server(): allow_headers=["*"], ) - @app.exception_handler(RefreshError) async def catch_google_refresh_errors(request, exc): """Catch google refresh errors, and use our error instead.""" - return await http_exception_handler(request, APIGoogleRefreshError()) + return await http_exception_handler(request, APIGoogleRefreshError(message=l10n('google-connection-error'))) # Mix in our extra routes app.include_router(api.router) diff --git a/backend/src/appointment/middleware/l10n.py b/backend/src/appointment/middleware/l10n.py new file mode 100644 index 000000000..0053b87f9 --- /dev/null +++ b/backend/src/appointment/middleware/l10n.py @@ -0,0 +1,51 @@ +import os + +from starlette_context.plugins import Plugin +from fastapi import Request +from fluent.runtime import FluentLocalization, FluentResourceLoader + +from ..defines import SUPPORTED_LOCALES, FALLBACK_LOCALE + + +class L10n(Plugin): + """Provides fluent's format_value function via context['l10n']""" + key = 'l10n' + + def parse_accept_language(self, accept_language_header): + languages = accept_language_header.split(',') + parsed_locales = [] + + for language in languages: + split_language = language.split(';') + if len(split_language) == 1: + language = language.strip() + else: + language, _ = split_language + language = language.strip() + + if language in SUPPORTED_LOCALES or language == '*': + parsed_locales.append(language) + + if len(parsed_locales) == 0 or parsed_locales[0] == '*': + parsed_locales = [FALLBACK_LOCALE] + + 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" + + loader = FluentResourceLoader(f"{base_url}/{{locale}}") + fluent = FluentLocalization(supported_locales, ["main.ftl", "email.ftl"], loader) + + return fluent.format_value + + async def process_request( + self, request: Request + ): + return self.get_fluent(request.headers.get('accept-language', FALLBACK_LOCALE)) diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index d69a5dd15..3d9fa6586 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -15,8 +15,7 @@ # authentication from ..controller.calendar import CalDavConnector, Tools, GoogleConnector - -from fastapi import APIRouter, Depends, HTTPException, Security, Body +from fastapi import APIRouter, Depends, HTTPException, Body from datetime import timedelta, timezone from ..controller.apis.google_client import GoogleClient from ..controller.auth import signed_url_by_subscriber @@ -25,6 +24,8 @@ from ..dependencies.auth import get_subscriber from ..dependencies.database import get_db from ..dependencies.zoom import get_zoom_client +from ..exceptions import validation +from ..l10n import l10n router = APIRouter() @@ -32,7 +33,7 @@ @router.get("/") def health(): """Small route with no processing that will be used for health checks""" - return True + return l10n('health-ok') @router.put("/me", response_model=schemas.SubscriberBase) @@ -40,10 +41,9 @@ def update_me( data: schemas.SubscriberIn, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber) ): """endpoint to update data of authenticated subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if subscriber.username != data.username and repo.get_subscriber_by_username(db, data.username): - raise HTTPException(status_code=403, detail="Username not available") + raise HTTPException(status_code=403, detail=l10n('username-not-available')) + me = repo.update_subscriber(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 @@ -55,8 +55,6 @@ def read_my_calendars( db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), only_connected: bool = True ): """get all calendar connections of authenticated subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") calendars = repo.get_calendars_by_subscriber( db, subscriber_id=subscriber.id, include_unconnected=not only_connected ) @@ -66,8 +64,6 @@ def read_my_calendars( @router.get("/me/appointments", response_model=list[schemas.AppointmentWithCalendarOut]) def read_my_appointments(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """get all appointments of authenticated subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") appointments = repo.get_appointments_by_subscriber(db, subscriber_id=subscriber.id) # Mix in calendar title and color. # Note because we `__dict__` any relationship values won't be carried over, so don't forget to manually add those! @@ -80,17 +76,12 @@ def read_my_appointments(db: Session = Depends(get_db), subscriber: Subscriber = @router.get("/me/signature") def get_my_signature(subscriber: Subscriber = Depends(get_subscriber)): """Retrieve a subscriber's signed short link""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") return {"url": signed_url_by_subscriber(subscriber)} @router.post("/me/signature") def refresh_signature(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Refresh a subscriber's signed short link""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") - repo.update_subscriber( db, schemas.SubscriberAuth( @@ -107,7 +98,8 @@ def verify_signature(url: str = Body(..., embed=True), db: Session = Depends(get """Verify a signed short link""" if repo.verify_subscriber_link(db, url): return True - raise HTTPException(400, "Invalid link") + + raise validation.InvalidLinkException() @router.post("/cal", response_model=schemas.CalendarOut) @@ -117,8 +109,6 @@ def create_my_calendar( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to add a new calendar connection for authenticated subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") # create calendar try: cal = repo.create_subscriber_calendar(db=db, calendar=calendar, subscriber_id=subscriber.id) @@ -130,13 +120,13 @@ def create_my_calendar( @router.get("/cal/{id}", response_model=schemas.CalendarConnectionOut) def read_my_calendar(id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """endpoint to get a calendar from db""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") cal = repo.get_calendar(db, calendar_id=id) + if cal is None: - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") + raise validation.CalendarNotAuthorizedException() + return schemas.CalendarConnectionOut( id=cal.id, title=cal.title, @@ -156,12 +146,11 @@ def update_my_calendar( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to update an existing calendar connection for authenticated subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if not repo.calendar_exists(db, calendar_id=id): - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") + raise validation.CalendarNotAuthorizedException() + cal = repo.update_subscriber_calendar(db=db, calendar=calendar, calendar_id=id) return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) @@ -173,12 +162,11 @@ def connect_my_calendar( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to update an existing calendar connection for authenticated subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if not repo.calendar_exists(db, calendar_id=id): - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") + raise validation.CalendarNotAuthorizedException() + try: cal = repo.update_subscriber_calendar_connection(db=db, calendar_id=id, is_connected=True) except HTTPException as e: @@ -189,12 +177,11 @@ def connect_my_calendar( @router.delete("/cal/{id}", response_model=schemas.CalendarOut) def delete_my_calendar(id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """endpoint to remove a calendar from db""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if not repo.calendar_exists(db, calendar_id=id): - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") + raise validation.CalendarNotAuthorizedException() + cal = repo.delete_subscriber_calendar(db=db, calendar_id=id) return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) @@ -206,8 +193,6 @@ def read_remote_calendars( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to get calendars from a remote CalDAV server""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if connection.provider == CalendarProvider.google: con = GoogleConnector( db=None, @@ -228,8 +213,6 @@ def sync_remote_calendars( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to sync calendars from a remote server""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") # Create a list of connections and loop through them with sync # TODO: Also handle CalDAV connections connections = [ @@ -245,7 +228,7 @@ def sync_remote_calendars( error_occurred = connection.sync_calendars() # And then redirect back to frontend if error_occurred: - raise HTTPException(500, "An error occurred while syncing calendars. Please try again later.") + raise HTTPException(500, l10n('calendar-sync-fail')) return True @@ -259,11 +242,11 @@ def read_remote_events( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to get events in a given date range from a remote calendar""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") db_calendar = repo.get_calendar(db, calendar_id=id) + if db_calendar is None: - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() + if db_calendar.provider == CalendarProvider.google: con = GoogleConnector( db=db, @@ -286,29 +269,27 @@ def create_my_calendar_appointment( a_s: schemas.AppointmentSlots, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber) ): """endpoint to add a new appointment with slots for a given calendar""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if not repo.calendar_exists(db, calendar_id=a_s.appointment.calendar_id): - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=a_s.appointment.calendar_id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") + raise validation.CalendarNotAuthorizedException() if not repo.calendar_is_connected(db, calendar_id=a_s.appointment.calendar_id): - raise HTTPException(status_code=403, detail="Calendar connection is not active") + raise validation.CalendarNotConnectedException() if a_s.appointment.meeting_link_provider == MeetingLinkProviderType.zoom and subscriber.get_external_connection(ExternalConnectionType.zoom) is None: - raise HTTPException(status_code=400, detail="You need a connected Zoom account in order to create a meeting link") + raise validation.ZoomNotConnectedException() return repo.create_calendar_appointment(db=db, appointment=a_s.appointment, slots=a_s.slots) @router.get("/apmt/{id}", response_model=schemas.Appointment) def read_my_appointment(id: str, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """endpoint to get an appointment from db by id""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") db_appointment = repo.get_appointment(db, appointment_id=id) + if db_appointment is None: - raise HTTPException(status_code=404, detail="Appointment not found") + raise validation.AppointmentNotFoundException() if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Appointment not owned by subscriber") + raise validation.AppointmentNotAuthorizedException() + return db_appointment @@ -320,26 +301,26 @@ def update_my_appointment( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to update an existing appointment with slots""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") db_appointment = repo.get_appointment(db, appointment_id=id) + if db_appointment is None: - raise HTTPException(status_code=404, detail="Appointment not found") + raise validation.AppointmentNotFoundException() if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Appointment not owned by subscriber") + raise validation.AppointmentNotAuthorizedException() + return repo.update_calendar_appointment(db=db, appointment=a_s.appointment, slots=a_s.slots, appointment_id=id) @router.delete("/apmt/{id}", response_model=schemas.Appointment) def delete_my_appointment(id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """endpoint to remove an appointment from db""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") db_appointment = repo.get_appointment(db, appointment_id=id) + if db_appointment is None: - raise HTTPException(status_code=404, detail="Appointment not found") + raise validation.AppointmentNotFoundException() if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Appointment not owned by subscriber") + raise validation.AppointmentNotAuthorizedException() + return repo.delete_calendar_appointment(db=db, appointment_id=id) @@ -348,10 +329,10 @@ def read_public_appointment(slug: str, db: Session = Depends(get_db)): """endpoint to retrieve an appointment from db via public link and only expose necessary data""" a = repo.get_public_appointment(db, slug=slug) if a is None: - raise HTTPException(status_code=404, detail="Appointment not found") + raise validation.AppointmentNotFoundException() s = repo.get_subscriber_by_appointment(db=db, appointment_id=a.id) if s is None: - raise HTTPException(status_code=404, detail="Subscriber not found") + raise validation.SubscriberNotFoundException() slots = [ schemas.SlotOut(id=sl.id, start=sl.start, duration=sl.duration, attendee_id=sl.attendee_id) for sl in a.slots ] @@ -370,16 +351,16 @@ def update_public_appointment_slot( """endpoint to update a time slot for an appointment via public link and create an event in remote calendar""" db_appointment = repo.get_public_appointment(db, slug=slug) if db_appointment is None: - raise HTTPException(status_code=404, detail="Appointment not found") + raise validation.AppointmentNotFoundException() db_calendar = repo.get_calendar(db, calendar_id=db_appointment.calendar_id) if db_calendar is None: - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() if not repo.appointment_has_slot(db, appointment_id=db_appointment.id, slot_id=s_a.slot_id): - raise HTTPException(status_code=404, detail="Time slot not found for Appointment") + raise validation.SlotNotFoundException() if not repo.slot_is_available(db, slot_id=s_a.slot_id): - raise HTTPException(status_code=403, detail="Time slot not available anymore") + raise validation.SlotAlreadyTakenException() if not validators.email(s_a.attendee.email): - raise HTTPException(status_code=400, detail="No valid email provided") + raise HTTPException(status_code=400, detail=l10n('slot-invalid-email')) slot = repo.get_slot(db=db, slot_id=s_a.slot_id) @@ -459,12 +440,15 @@ def public_appointment_serve_ics(slug: str, slot_id: int, db: Session = Depends( """endpoint to serve ICS file for time slot to download""" db_appointment = repo.get_public_appointment(db, slug=slug) if db_appointment is None: - raise HTTPException(status_code=404, detail="Appointment not found") + raise validation.AppointmentNotFoundException() + if not repo.appointment_has_slot(db, appointment_id=db_appointment.id, slot_id=slot_id): - raise HTTPException(status_code=404, detail="Time slot not found for Appointment") + raise validation.SlotNotFoundException() + slot = repo.get_slot(db=db, slot_id=slot_id) if slot is None: - raise HTTPException(status_code=404, detail="Time slot not found") + raise validation.SlotNotFoundException() + organizer = repo.get_subscriber_by_appointment(db=db, appointment_id=db_appointment.id) return schemas.FileDownload( diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 65d9393d9..13bb29df5 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -22,6 +22,7 @@ from ..controller.apis.fxa_client import FxaClient from ..dependencies.fxa import get_fxa_client from ..exceptions.fxa_api import NotInAllowListException +from ..l10n import l10n router = APIRouter() ph = PasswordHasher() @@ -111,7 +112,7 @@ def fxa_callback( if profile['email'] != email: fxa_client.logout() - raise HTTPException(400, "Email mismatch.") + raise HTTPException(400, l10n('email-mismatch')) # Check if we have an existing fxa connection by profile's uid external_connection = repo.get_subscriber_by_fxa_uid(db, profile['uid']) @@ -169,13 +170,13 @@ def token( """Retrieve an access token from a given username and password.""" subscriber = repo.get_subscriber_by_username(db, form_data.username) if not subscriber or subscriber.password is None: - raise HTTPException(status_code=403, detail="User credentials mismatch") + raise HTTPException(status_code=403, detail=l10n('invalid-credentials')) # Verify the incoming password, and re-hash our password if needed try: verify_password(form_data.password, subscriber.password) except argon2.exceptions.VerifyMismatchError: - raise HTTPException(status_code=403, detail="User credentials mismatch") + raise HTTPException(status_code=403, detail=l10n('invalid-credentials')) if ph.check_needs_rehash(subscriber.password): subscriber.password = get_password_hash(form_data.password) diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index 562b27eb7..d6b64902c 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -15,6 +15,7 @@ from ..dependencies.google import get_google_client from ..exceptions.google_api import GoogleInvalidCredentials from ..exceptions.google_api import GoogleScopeChanged +from ..l10n import l10n router = APIRouter() @@ -42,20 +43,20 @@ def google_callback( try: creds = google_client.get_credentials(code) except GoogleScopeChanged: - return google_callback_error("You must enable Calendar and Event access to use Thunderbird Appointment.") + return google_callback_error(l10n('google-scope-changed')) except GoogleInvalidCredentials: - return google_callback_error("Google authentication credentials are not valid") + return google_callback_error(l10n('google-invalid-creds')) subscriber = repo.get_subscriber_by_google_state(db, state) if subscriber is None: - return google_callback_error("Google authentication failed") + return google_callback_error(l10n('google-auth-fail')) if not subscriber.google_state_expires_at or subscriber.google_state_expires_at < datetime.now(): # Clear state for our db copy repo.set_subscriber_google_state(db, None, subscriber.id) - return google_callback_error("Google authentication session expired, please try again.") + return google_callback_error(l10n('google-auth-expired')) # Clear state for our db copy repo.set_subscriber_google_state(db, None, subscriber.id) @@ -68,7 +69,7 @@ def google_callback( # And then redirect back to frontend if error_occurred: - return google_callback_error("An error occurred while syncing calendars. Please try again later.") + return google_callback_error(l10n('google-sync-fail')) return RedirectResponse(f"{os.getenv('FRONTEND_URL', 'http://localhost:8080')}/settings/calendar") diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index afb267871..1ea032204 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException, Body +from fastapi import APIRouter, Depends, Body import logging import os @@ -20,6 +20,7 @@ from zoneinfo import ZoneInfo from ..dependencies.zoom import get_zoom_client +from ..exceptions import validation router = APIRouter() @@ -31,22 +32,18 @@ def create_calendar_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to add a new schedule for a given calendar""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if not repo.calendar_exists(db, calendar_id=schedule.calendar_id): - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=schedule.calendar_id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") + raise validation.CalendarNotAuthorizedException() if not repo.calendar_is_connected(db, calendar_id=schedule.calendar_id): - raise HTTPException(status_code=403, detail="Calendar connection is not active") + raise validation.CalendarNotConnectedException() return repo.create_calendar_schedule(db=db, schedule=schedule) @router.get("/", response_model=list[schemas.Schedule]) def read_schedules(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Gets all of the available schedules for the logged in subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") return repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) @@ -57,13 +54,11 @@ def read_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """Gets information regarding a specific schedule""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") schedule = repo.get_schedule(db, schedule_id=id) if schedule is None: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Schedule not owned by subscriber") + raise validation.ScheduleNotAuthorizedException() return schedule @@ -75,14 +70,12 @@ def update_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to update an existing calendar connection for authenticated subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if not repo.schedule_exists(db, schedule_id=id): - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Schedule not owned by subscriber") + raise validation.ScheduleNotAuthorizedException() if schedule.meeting_link_provider == MeetingLinkProviderType.zoom and subscriber.get_external_connection(ExternalConnectionType.zoom) is None: - raise HTTPException(status_code=400, detail="You need a connected Zoom account in order to create a meeting link") + raise validation.ZoomNotConnectedException() return repo.update_calendar_schedule(db=db, schedule=schedule, schedule_id=id) @@ -95,17 +88,18 @@ def read_schedule_availabilities( """Returns the calculated availability for the first schedule from a subscribers public profile link""" subscriber = repo.verify_subscriber_link(db, url) if not subscriber: - raise HTTPException(status_code=401, detail="Invalid profile link") + raise validation.InvalidLinkException() + schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) try: schedule = schedules[0] # for now we only process the first existing schedule except IndexError: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() # check if schedule is enabled if not schedule.active: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() # calculate theoretically possible slots from schedule config availableSlots = Tools.available_slots_from_schedule(schedule) @@ -114,13 +108,13 @@ def read_schedule_availabilities( calendars = repo.get_calendars_by_subscriber(db, subscriber.id, False) if not calendars or len(calendars) == 0: - raise HTTPException(status_code=404, detail="No calendars found") + raise validation.CalendarNotFoundException() existingEvents = Tools.existing_events_for_schedule(schedule, calendars, subscriber, google_client, db) actualSlots = Tools.events_set_difference(availableSlots, existingEvents) if not actualSlots or len(actualSlots) == 0: - raise HTTPException(status_code=404, detail="No possible booking slots found") + raise validation.SlotNotFoundException() return schemas.AppointmentOut( title=schedule.name, @@ -139,24 +133,27 @@ def request_schedule_availability_slot( """endpoint to request a time slot for a schedule via public link and send confirmation mail to owner""" subscriber = repo.verify_subscriber_link(db, url) if not subscriber: - raise HTTPException(status_code=401, detail="Invalid profile link") + raise validation.InvalidLinkException + schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) try: schedule = schedules[0] # for now we only process the first existing schedule except IndexError: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() + # check if schedule is enabled if not schedule.active: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() + # get calendar db_calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) if db_calendar is None: - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() # check if slot still available, might already be taken at this time slot = schemas.SlotBase(**s_a.slot.dict()) if repo.schedule_slot_exists(db, slot, schedule.id): - raise HTTPException(status_code=403, detail="Slot not available") + raise validation.SlotAlreadyTakenException() # create slot in db with token and expiration date token = random_slug() @@ -195,19 +192,22 @@ def decide_on_schedule_availability_slot( """ subscriber = repo.verify_subscriber_link(db, data.owner_url) if not subscriber: - raise HTTPException(status_code=401, detail="Invalid profile link") + raise validation.InvalidLinkException() schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) try: schedule = schedules[0] # for now we only process the first existing schedule except IndexError: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() + # check if schedule is enabled if not schedule.active: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() + # get calendar calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) if calendar is None: - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() + # get slot and check if slot exists and is not booked yet and token is the same slot = repo.get_slot(db, data.slot_id) if ( @@ -216,7 +216,8 @@ def decide_on_schedule_availability_slot( or not repo.schedule_has_slot(db, schedule.id, slot.id) or slot.booking_tkn != data.slot_token ): - raise HTTPException(status_code=404, detail="Booking slot not found") + raise validation.SlotNotFoundException() + # TODO: check booking expiration date # check if request was denied if data.confirmed is False: @@ -310,19 +311,22 @@ def schedule_serve_ics( """endpoint to serve ICS file for availability time slot to download""" subscriber = repo.verify_subscriber_link(db, url) if not subscriber: - raise HTTPException(status_code=401, detail="Invalid profile link") + raise validation.InvalidLinkException + schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) try: schedule = schedules[0] # for now we only process the first existing schedule except IndexError: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() + # check if schedule is enabled if not schedule.active: - raise HTTPException(status_code=404, detail="Schedule not found") + raise validation.ScheduleNotFoundException() + # get calendar db_calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) if db_calendar is None: - raise HTTPException(status_code=404, detail="Calendar not found") + raise validation.CalendarNotFoundException() appointment = schemas.AppointmentBase(title=schedule.name, details=schedule.details, location_url=schedule.location_url) return schemas.FileDownload( diff --git a/backend/src/appointment/routes/zoom.py b/backend/src/appointment/routes/zoom.py index 3e180121a..0c54d50f8 100644 --- a/backend/src/appointment/routes/zoom.py +++ b/backend/src/appointment/routes/zoom.py @@ -12,6 +12,7 @@ from ..dependencies.auth import get_subscriber from ..dependencies.database import get_db from ..dependencies.zoom import get_zoom_client +from ..l10n import l10n router = APIRouter() @@ -41,9 +42,9 @@ def zoom_callback( db=Depends(get_db), ): if 'zoom_state' not in request.session or request.session['zoom_state'] != state: - raise HTTPException(400, "Invalid state.") + raise HTTPException(400, l10n('oauth-error')) if 'zoom_user_id' not in request.session or 'zoom_user_id' == '': - raise HTTPException(400, "User ID could not be retrieved.") + raise HTTPException(400, l10n('oauth-error')) # Retrieve the user id set at the start of the zoom oauth process subscriber = repo.get_subscriber(db, request.session['zoom_user_id']) diff --git a/backend/src/appointment/templates/email/confirm.jinja2 b/backend/src/appointment/templates/email/confirm.jinja2 index de43f3579..0dc857772 100644 --- a/backend/src/appointment/templates/email/confirm.jinja2 +++ b/backend/src/appointment/templates/email/confirm.jinja2 @@ -1,38 +1,36 @@ - - -

- {{ attendee.name|title }} ({{ attendee.email }}) just requested this time slot from your schedule: {{ date }}. -

-

- Click here to confirm the booking request:
- Confirm Booking -

-

- Or here if you want to deny it:
- Deny Booking -

-

- This message is sent from Thunderbird Appointment. -

- - + + +

+ {{ l10n('confirm-mail-html-heading', {'attendee_name': attendee.name, 'attendee_email': attendee.email, 'date': date}) }} +

+

+ {{ l10n('confirm-mail-html-confirm-text') }}
+ {{ l10n('confirm-mail-html-confirm-action') }} +

+

+ {{ l10n('confirm-mail-html-deny-text') }}
+ {{ l10n('confirm-mail-html-deny-action') }} +

+ {% include 'includes/footer.jinja2' %} + + diff --git a/backend/src/appointment/templates/email/errors/zoom_invite_failed.jinja2 b/backend/src/appointment/templates/email/errors/zoom_invite_failed.jinja2 index d3d6c6c89..ede9e7461 100644 --- a/backend/src/appointment/templates/email/errors/zoom_invite_failed.jinja2 +++ b/backend/src/appointment/templates/email/errors/zoom_invite_failed.jinja2 @@ -1,5 +1,6 @@ - + -

Unfortunately there was an error creating your Zoom meeting for your upcoming appointment: {{ title }}.

+

{{ l10n('zoom-invite-failed-html-heading', {'title': title}) }}

+ {% include 'includes/footer.jinja2' %} diff --git a/backend/src/appointment/templates/email/includes/footer.jinja2 b/backend/src/appointment/templates/email/includes/footer.jinja2 new file mode 100644 index 000000000..5386d7286 --- /dev/null +++ b/backend/src/appointment/templates/email/includes/footer.jinja2 @@ -0,0 +1,3 @@ +

+ {{ l10n('mail-brand-footer') }} +

diff --git a/backend/src/appointment/templates/email/invite.jinja2 b/backend/src/appointment/templates/email/invite.jinja2 index 932ce49c9..d67390364 100644 --- a/backend/src/appointment/templates/email/invite.jinja2 +++ b/backend/src/appointment/templates/email/invite.jinja2 @@ -1,5 +1,5 @@ - - -

This message is sent from Appointment.

- - \ No newline at end of file + + + {% include 'includes/footer.jinja2' %} + + diff --git a/backend/src/appointment/templates/email/rejected.jinja2 b/backend/src/appointment/templates/email/rejected.jinja2 index 7bebd7c6a..06e0a87ea 100644 --- a/backend/src/appointment/templates/email/rejected.jinja2 +++ b/backend/src/appointment/templates/email/rejected.jinja2 @@ -1,10 +1,8 @@ - +

- {{ owner.name|title }} denied your booking request for this time slot: {{ date }}. -

-

- This message is sent from Thunderbird Appointment. + {{ l10n('reject-mail-html-heading', {'owner_name': owner.name, 'date': date}) }}

+ {% include 'includes/footer.jinja2' %} diff --git a/backend/test/conftest.py b/backend/test/conftest.py index d4424be43..bffaafbdf 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -7,6 +7,8 @@ from sqlalchemy.pool import StaticPool from fastapi.testclient import TestClient from fastapi import Request +from starlette_context import request_cycle_context + from defines import TEST_USER_ID, TEST_CALDAV_URL, TEST_CALDAV_USER, FXA_CLIENT_PATCH # Factory functions @@ -21,9 +23,10 @@ # Load our env load_dotenv(find_dotenv(".env.test")) -from backend.src.appointment.main import server # noqa: E402 -from backend.src.appointment.database import models, repo, schemas # noqa: E402 -from backend.src.appointment.dependencies import database, auth, google # noqa: E402 +from appointment.main import server # noqa: E402 +from appointment.database import models, repo, schemas # noqa: E402 +from appointment.dependencies import database, auth, google # noqa: E402 +from appointment.middleware.l10n import L10n # noqa: E402 def _patch_caldav_connector(monkeypatch): @@ -53,7 +56,7 @@ def delete_event(self, start): return True # Patch up the caldav constructor, and list_calendars - from backend.src.appointment.controller.calendar import CalDavConnector + from appointment.controller.calendar import CalDavConnector monkeypatch.setattr(CalDavConnector, "__init__", MockCaldavConnector.__init__) monkeypatch.setattr(CalDavConnector, "list_calendars", MockCaldavConnector.list_calendars) monkeypatch.setattr(CalDavConnector, "create_event", MockCaldavConnector.create_event) @@ -67,7 +70,7 @@ class MockMailer: def send(self): return - from backend.src.appointment.controller.mailer import Mailer + from appointment.controller.mailer import Mailer monkeypatch.setattr(Mailer, "send", MockMailer.send) @@ -102,7 +105,7 @@ def logout(self): def get_jwk(self): return {} - from backend.src.appointment.controller.apis.fxa_client import FxaClient + from appointment.controller.apis.fxa_client import FxaClient monkeypatch.setattr(FxaClient, "setup", MockFxaClient.setup) monkeypatch.setattr(FxaClient, "get_redirect_url", MockFxaClient.get_redirect_url) monkeypatch.setattr(FxaClient, "get_credentials", MockFxaClient.get_credentials) @@ -172,3 +175,13 @@ def override_get_google_client(): client = TestClient(app) yield client + + +@pytest.fixture() +def with_l10n(): + """Creates a fake starlette_context context with just the l10n function, only needed for unit tests. Only supports english for now!""" + l10n_plugin = L10n() + l10n_fn = l10n_plugin.get_fluent('en') + + with request_cycle_context({'l10n': l10n_fn}): + yield diff --git a/backend/test/defines.py b/backend/test/defines.py index 05f3eccc0..36e708a1c 100644 --- a/backend/test/defines.py +++ b/backend/test/defines.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta -from backend.src.appointment.controller.calendar import DATEFMT +from appointment.controller.calendar import DATEFMT now = datetime.today() DAY1 = now.strftime(DATEFMT) diff --git a/backend/test/factory/appointment_factory.py b/backend/test/factory/appointment_factory.py index 459a25433..6397f3339 100644 --- a/backend/test/factory/appointment_factory.py +++ b/backend/test/factory/appointment_factory.py @@ -1,6 +1,6 @@ import pytest from faker import Faker -from backend.src.appointment.database import repo, schemas, models +from appointment.database import repo, schemas, models from defines import FAKER_RANDOM_VALUE, factory_has_value diff --git a/backend/test/factory/attendee_factory.py b/backend/test/factory/attendee_factory.py index eca4d8300..15de52a29 100644 --- a/backend/test/factory/attendee_factory.py +++ b/backend/test/factory/attendee_factory.py @@ -1,6 +1,6 @@ import pytest from faker import Faker -from backend.src.appointment.database import repo, schemas, models +from appointment.database import models from defines import FAKER_RANDOM_VALUE, factory_has_value diff --git a/backend/test/factory/calendar_factory.py b/backend/test/factory/calendar_factory.py index 0ca4a8edf..c8c45951a 100644 --- a/backend/test/factory/calendar_factory.py +++ b/backend/test/factory/calendar_factory.py @@ -1,6 +1,6 @@ import pytest from faker import Faker -from backend.src.appointment.database import repo, schemas, models +from appointment.database import repo, schemas, models from defines import TEST_USER_ID, FAKER_RANDOM_VALUE, factory_has_value diff --git a/backend/test/factory/external_connection_factory.py b/backend/test/factory/external_connection_factory.py index e3b0273c6..55776ff71 100644 --- a/backend/test/factory/external_connection_factory.py +++ b/backend/test/factory/external_connection_factory.py @@ -1,6 +1,6 @@ import pytest from faker import Faker -from backend.src.appointment.database import repo, schemas, models +from appointment.database import repo, schemas, models from defines import FAKER_RANDOM_VALUE, factory_has_value diff --git a/backend/test/factory/schedule_factory.py b/backend/test/factory/schedule_factory.py index 38811d284..03d22b6c1 100644 --- a/backend/test/factory/schedule_factory.py +++ b/backend/test/factory/schedule_factory.py @@ -1,6 +1,6 @@ import pytest from faker import Faker -from backend.src.appointment.database import repo, schemas, models +from appointment.database import repo, schemas, models from defines import FAKER_RANDOM_VALUE, factory_has_value diff --git a/backend/test/factory/slot_factory.py b/backend/test/factory/slot_factory.py index be145bd48..b7c18a7c0 100644 --- a/backend/test/factory/slot_factory.py +++ b/backend/test/factory/slot_factory.py @@ -1,6 +1,6 @@ import pytest from faker import Faker -from backend.src.appointment.database import repo, schemas, models +from appointment.database import repo, schemas, models from defines import FAKER_RANDOM_VALUE, factory_has_value diff --git a/backend/test/factory/subscriber_factory.py b/backend/test/factory/subscriber_factory.py index c6ac18eee..2ff5cafea 100644 --- a/backend/test/factory/subscriber_factory.py +++ b/backend/test/factory/subscriber_factory.py @@ -1,7 +1,7 @@ import pytest from argon2 import PasswordHasher from faker import Faker -from backend.src.appointment.database import repo, schemas, models +from appointment.database import repo, schemas, models from defines import FAKER_RANDOM_VALUE, factory_has_value diff --git a/backend/test/integration/__init__.py b/backend/test/integration/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/test/integration/test_appointment.py b/backend/test/integration/test_appointment.py index 2634ab4ab..ebff1b6fa 100644 --- a/backend/test/integration/test_appointment.py +++ b/backend/test/integration/test_appointment.py @@ -330,7 +330,7 @@ def test_read_public_appointment_after_attendee_selection(self, with_db, with_cl # db.refresh doesn't work because it only refreshes instances created by the current db session? with with_db() as db: - from backend.src.appointment.database import models + from appointment.database import models generated_appointment = db.get(models.Appointment, generated_appointment.id) # Reload slots generated_appointment.slots @@ -348,7 +348,7 @@ def test_attendee_selects_slot_of_unavailable_appointment(self, with_db, with_cl # db.refresh doesn't work because it only refreshes instances created by the current db session? with with_db() as db: - from backend.src.appointment.database import models + from appointment.database import models generated_appointment = db.get(models.Appointment, generated_appointment.id) # Reload slots generated_appointment.slots @@ -388,11 +388,11 @@ def test_attendee_provides_invalid_email_address(self, with_client, make_appoint def test_get_remote_caldav_events(self, with_client, make_appointment, monkeypatch): """Test against a fake remote caldav, we're testing the route controller, not the actual caldav connector here!""" - from backend.src.appointment.controller.calendar import CalDavConnector + from appointment.controller.calendar import CalDavConnector generated_appointment = make_appointment() def list_events(self, start, end): - from backend.src.appointment.database import schemas + from appointment.database import schemas return [schemas.Event( title=generated_appointment.title, start=str(generated_appointment.slots[0].start), diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index f3e57a45a..2ec652ccd 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -1,7 +1,7 @@ import os from defines import FXA_CLIENT_PATCH -from backend.src.appointment.database import repo, models +from appointment.database import repo, models class TestAuth: diff --git a/backend/test/integration/test_caldav.py b/backend/test/integration/test_caldav.py index 70feef5a1..583442ade 100644 --- a/backend/test/integration/test_caldav.py +++ b/backend/test/integration/test_caldav.py @@ -1,8 +1,8 @@ import os -from backend.src.appointment.database.models import CalendarProvider -from backend.src.appointment.controller.calendar import CalDavConnector -from backend.src.appointment.database import schemas, models, repo +from appointment.database.models import CalendarProvider +from appointment.controller.calendar import CalDavConnector +from appointment.database import schemas, models, repo from sqlalchemy import insert, select diff --git a/backend/test/integration/test_general.py b/backend/test/integration/test_general.py index 237340da6..5285c3457 100644 --- a/backend/test/integration/test_general.py +++ b/backend/test/integration/test_general.py @@ -18,6 +18,17 @@ def test_health(self, with_client): response = with_client.get("/abcdefg") assert response.status_code == 404 + def test_health_for_locale(self, with_client): + # Try english first + response = with_client.get("/", headers={'accept-language': 'en'}) + assert response.status_code == 200 + assert response.json() == 'Health OK' + + # Try german next + response = with_client.get("/", headers={'accept-language': 'de'}) + assert response.status_code == 200 + assert response.json() == 'Betriebsbereit' + def test_access_without_authentication_token(self, with_client): # response = client.get("/login") # assert response.status_code == 403 diff --git a/backend/test/integration/test_profile.py b/backend/test/integration/test_profile.py index 197f3ac5a..23464e8c2 100644 --- a/backend/test/integration/test_profile.py +++ b/backend/test/integration/test_profile.py @@ -1,6 +1,6 @@ import os from defines import auth_headers -from backend.src.appointment.database import repo +from appointment.database import repo class TestProfile: diff --git a/backend/test/unit/__init__.py b/backend/test/unit/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/test/unit/test_data.py b/backend/test/unit/test_data.py index 8603d9b29..d3819a9ed 100644 --- a/backend/test/unit/test_data.py +++ b/backend/test/unit/test_data.py @@ -1,6 +1,6 @@ from argon2 import PasswordHasher -from backend.src.appointment.controller.data import model_to_csv_buffer, delete_account +from appointment.controller.data import model_to_csv_buffer, delete_account class TestData: diff --git a/backend/test/unit/test_fxa_client.py b/backend/test/unit/test_fxa_client.py index dc817e1cf..14792cbf2 100644 --- a/backend/test/unit/test_fxa_client.py +++ b/backend/test/unit/test_fxa_client.py @@ -1,6 +1,6 @@ import os -from backend.src.appointment.controller.apis.fxa_client import FxaClient +from appointment.controller.apis.fxa_client import FxaClient class TestFxaClient: diff --git a/backend/test/unit/test_mailer.py b/backend/test/unit/test_mailer.py new file mode 100644 index 000000000..25b75c3b2 --- /dev/null +++ b/backend/test/unit/test_mailer.py @@ -0,0 +1,56 @@ +import datetime + +from appointment.controller.mailer import ConfirmationMail, RejectionMail, ZoomMeetingFailedMail, InvitationMail +from appointment.database import schemas + + +class TestMailer: + def test_invite(self, with_l10n): + fake_email = 'to@example.org' + + mailer = InvitationMail(to=fake_email) + assert mailer.html() + assert mailer.text() + + def test_confirm(self, faker, with_l10n): + confirm_url = 'https://example.org/yes' + deny_url = 'https://example.org/no' + fake_email = 'to@example.org' + now = datetime.datetime.now() + attendee = schemas.AttendeeBase(email=faker.email(), name=faker.name()) + + mailer = ConfirmationMail(confirm_url, deny_url, attendee, now, to=fake_email) + assert mailer.html() + assert mailer.text() + + for idx, content in enumerate([mailer.text(), mailer.html()]): + fault = 'text' if idx == 0 else 'html' + assert confirm_url in content, fault + assert deny_url in content, fault + assert attendee.name in content, fault + assert attendee.email in content, fault + + def test_reject(self, faker, with_l10n, make_pro_subscriber): + subscriber = make_pro_subscriber() + now = datetime.datetime.now() + fake_email = 'to@example.org' + + mailer = RejectionMail(owner=subscriber, date=now, to=fake_email) + assert mailer.html() + assert mailer.text() + + for idx, content in enumerate([mailer.text(), mailer.html()]): + fault = 'text' if idx == 0 else 'html' + assert subscriber.name in content, fault + + def test_zoom_invite_failed(self, faker, with_l10n): + fake_title = faker.name() + fake_email = 'to@example.org' + + mailer = ZoomMeetingFailedMail(appointment_title=fake_title, to=fake_email) + assert mailer.html() + assert mailer.text() + + for idx, content in enumerate([mailer.text(), mailer.html()]): + fault = 'text' if idx == 0 else 'html' + assert fake_title in content, fault diff --git a/docker-compose.yml b/docker-compose.yml index bf8d45dc1..a974a7735 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: ports: - 5173:5173 volumes: - - ./backend/src:/app/src + - ./backend/src/appointment:/app/appointment environment: - DATABASE_URL=mysql+mysqldb://tba:tba@mysql:3306/appointment depends_on: diff --git a/frontend/src/stores/user-store.js b/frontend/src/stores/user-store.js index 74f63aa62..2f7cd25a0 100644 --- a/frontend/src/stores/user-store.js +++ b/frontend/src/stores/user-store.js @@ -26,7 +26,7 @@ export const useUserStore = defineStore('user', { const { error, data } = await fetch('me').get().json(); // Failed to get profile data, log this user out and return false - if (error.value) { + if (error.value || !data.value) { this.reset(); return false; } @@ -47,7 +47,7 @@ export const useUserStore = defineStore('user', { async login(fetch, username, password) { this.reset(); - if (import.meta.env?.VUE_AUTH_SCHEME === 'password') { + if (import.meta.env.VITE_AUTH_SCHEME === 'password') { // fastapi wants us to send this as formdata :| const formData = new FormData(document.createElement('form')); formData.set('username', username);