From a6b5ae3fd5f676e1653d9c2c0f2fc46b6b720f0f Mon Sep 17 00:00:00 2001 From: Andreas Date: Tue, 11 Jun 2024 05:35:56 +0200 Subject: [PATCH] Chores/CS and linter fixes backend (#457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 👕 Apply black CS * 👕 Apply black CS and ruff fixes * 📜 Update comment * 🔨 Fix tests * 🔨 Fix deprecated ruff config * 🔨 Modify python version for ruff * ❌ Remove black * ➕ Add rule for single quotes * 👕 Apply quote fixes * ➕ Add rule for single quotes --- README.md | 7 +- backend/pyproject.toml | 35 +- .../commands/create_invite_codes.py | 6 +- .../appointment/commands/download_legal.py | 12 +- backend/src/appointment/commands/update_db.py | 15 +- .../appointment/controller/apis/fxa_client.py | 64 +-- .../controller/apis/google_client.py | 70 ++- .../controller/apis/zoom_client.py | 75 ++-- backend/src/appointment/controller/auth.py | 18 +- .../src/appointment/controller/calendar.py | 205 +++++---- backend/src/appointment/controller/data.py | 28 +- backend/src/appointment/controller/mailer.py | 146 +++---- backend/src/appointment/database/models.py | 201 +++++---- .../src/appointment/database/repo/__init__.py | 2 +- .../appointment/database/repo/appointment.py | 4 +- .../src/appointment/database/repo/attendee.py | 2 +- .../src/appointment/database/repo/calendar.py | 10 +- .../database/repo/external_connection.py | 17 +- .../src/appointment/database/repo/invite.py | 2 +- .../src/appointment/database/repo/schedule.py | 2 +- backend/src/appointment/database/repo/slot.py | 16 +- .../appointment/database/repo/subscriber.py | 8 +- backend/src/appointment/database/schemas.py | 13 +- backend/src/appointment/defines.py | 2 +- backend/src/appointment/dependencies/auth.py | 36 +- .../src/appointment/dependencies/database.py | 6 +- backend/src/appointment/dependencies/fxa.py | 15 +- .../src/appointment/dependencies/google.py | 10 +- backend/src/appointment/dependencies/zoom.py | 10 +- .../src/appointment/exceptions/account_api.py | 4 +- .../src/appointment/exceptions/calendar.py | 1 + backend/src/appointment/exceptions/fxa_api.py | 2 + .../src/appointment/exceptions/google_api.py | 1 + .../src/appointment/exceptions/validation.py | 41 +- backend/src/appointment/main.py | 84 ++-- .../middleware/SanitizeMiddleware.py | 14 +- backend/src/appointment/middleware/l10n.py | 13 +- backend/src/appointment/migrations/env.py | 17 +- ...03-eb50007f7a21_change_subscriber_table.py | 15 +- ..._25_1303-5aec90d60d85_calendar_provider.py | 11 +- ...4_26_1452-d9ecfcaf83a6_add_google_token.py | 15 +- ...7_1633-81ace90a911b_add_google_state_id.py | 19 +- ...201-da069f44bca7_add_calendar_connected.py | 15 +- ...0_2216-845089644770_add_short_link_hash.py | 15 +- ...871710e_add_general_availability_tables.py | 53 +-- ...102-f9c5471478d0_modify_schedules_table.py | 41 +- ...157-3789c9fd57c5_extend_schedules_table.py | 5 +- ...35-2b1d96fb4058_extend_slots_table_for_.py | 23 +- ...96baa7ecd5_create_external_connections_.py | 30 +- ...36eef5da9_add_meeting_link_provider_to_.py | 14 +- ...5d26beef0_add_meeting_link_provider_to_.py | 13 +- .../versions/2023_11_14_2255-7e426358642e_.py | 1 + ...33a37c43c_add_meeting_link_id_to_slots_.py | 18 +- ...df612c_add_password_to_subscriber_table.py | 9 +- ...8b4f463f1d_update_external_connections_.py | 10 +- ...29ca07f5_add_avatar_url_to_subscribers_.py | 9 +- ...d_minimum_valid_iat_time_to_subscribers.py | 8 +- ...502c76bc79e0_add_time_created_and_time_.py | 10 +- ...24_01_12_1801-ea551afc14fc_merge_commit.py | 3 - ...2bae6c27da_update_subscribers_table_to_.py | 17 +- ...5_2221-bbdfad87a7fb_fix_null_timestamps.py | 13 +- ...a32de9fb_add_uuid_to_appointments_table.py | 2 +- ...c31b555_fill_uuid_in_appointments_table.py | 5 +- ..._1241-fadd0d1ef438_create_invites_table.py | 7 +- ...0823-89e1197d980d_add_attendee_timezone.py | 1 + ...45-9fe08ba6f2ed_update_subscribers_add_.py | 13 +- ...d5de8f10ab87_add_subscriber_soft_delete.py | 1 + backend/src/appointment/routes/account.py | 22 +- backend/src/appointment/routes/api.py | 111 ++--- backend/src/appointment/routes/auth.py | 84 ++-- backend/src/appointment/routes/commands.py | 1 + backend/src/appointment/routes/google.py | 28 +- backend/src/appointment/routes/invite.py | 24 +- backend/src/appointment/routes/schedule.py | 149 ++++--- backend/src/appointment/routes/subscriber.py | 12 +- backend/src/appointment/routes/webhooks.py | 33 +- backend/src/appointment/routes/zoom.py | 20 +- backend/src/appointment/secrets.py | 48 +-- backend/src/appointment/tasks/emails.py | 32 +- backend/src/appointment/utils.py | 6 +- backend/test/conftest.py | 54 +-- backend/test/defines.py | 5 +- backend/test/factory/appointment_factory.py | 74 ++-- backend/test/factory/attendee_factory.py | 2 +- backend/test/factory/calendar_factory.py | 62 ++- .../factory/external_connection_factory.py | 40 +- backend/test/factory/invite_factory.py | 4 +- backend/test/factory/schedule_factory.py | 77 ++-- backend/test/factory/slot_factory.py | 46 +- backend/test/factory/subscriber_factory.py | 31 +- backend/test/integration/test_appointment.py | 389 +++++++++-------- backend/test/integration/test_auth.py | 155 +++---- backend/test/integration/test_calendar.py | 331 +++++++------- backend/test/integration/test_general.py | 65 ++- backend/test/integration/test_invite.py | 18 +- backend/test/integration/test_profile.py | 50 +-- backend/test/integration/test_schedule.py | 403 +++++++++--------- backend/test/integration/test_webhooks.py | 76 ++-- backend/test/integration/test_zoom.py | 22 +- backend/test/unit/test_auth_dependency.py | 13 +- backend/test/unit/test_cache.py | 19 +- backend/test/unit/test_calendar_tools.py | 14 +- backend/test/unit/test_data.py | 14 +- backend/test/unit/test_fxa_client.py | 3 - 104 files changed, 2225 insertions(+), 1922 deletions(-) diff --git a/README.md b/README.md index 1c71c6800..75d5edcf3 100644 --- a/README.md +++ b/README.md @@ -110,18 +110,17 @@ Contributions are very welcome. Please lint/format code before creating PRs. ### Backend -Backend is formatted using Ruff and Black. +Backend is formatted using Ruff. ```bash pip install ruff -pip install black ``` Commands (from git root) ```bash -ruff backend -black backend +ruff check backend +ruff check backend --fix ``` ### Frontend diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2cfd38d1e..5ae683e0d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,7 +15,6 @@ repository = "https://github.com/thunderbird/appointment.git" [project.optional-dependencies] cli = [ "ruff", - "black" ] db = [ "mysqlclient==2.1.1", @@ -35,13 +34,7 @@ dependencies = { file = ["requirements.txt"] } # Ruff [tool.ruff] -# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. -select = ["E", "F"] -ignore = [] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] -unfixable = [] +line-length = 120 # Exclude a variety of commonly ignored directories. exclude = [ @@ -67,22 +60,32 @@ exclude = [ "venv", ] -# Same as Black. -line-length = 120 +# Always generate Python 3.11-compatible code. +target-version = "py311" + +[tool.ruff.format] +# Prefer single quotes over double quotes. +quote-style = "single" + +[tool.ruff.lint] +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. +select = ["E", "F"] +ignore = [] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] +unfixable = [] # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -# Assume Python 3.10. -target-version = "py310" +[tool.ruff.lint.flake8-quotes] +inline-quotes = "single" -[tool.ruff.mccabe] +[tool.ruff.lint.mccabe] # Unlike Flake8, default to a complexity level of 10. max-complexity = 10 -[tool.black] -line-length = 120 - [tool.pytest.ini_options] pythonpath = "test" diff --git a/backend/src/appointment/commands/create_invite_codes.py b/backend/src/appointment/commands/create_invite_codes.py index 275f6f4b0..b2f4b10d6 100644 --- a/backend/src/appointment/commands/create_invite_codes.py +++ b/backend/src/appointment/commands/create_invite_codes.py @@ -1,11 +1,9 @@ -import os - from ..database import repo from ..dependencies.database import get_engine_and_session def run(n: int): - print(f"Generating {n} new invite codes...") + print(f'Generating {n} new invite codes...') _, session = get_engine_and_session() db = session() @@ -14,4 +12,4 @@ def run(n: int): db.close() - print(f"Successfull added {len(codes)} shiny new invite codes to the database.") + print(f'Successfull added {len(codes)} shiny new invite codes to the database.') diff --git a/backend/src/appointment/commands/download_legal.py b/backend/src/appointment/commands/download_legal.py index bfea47b92..74918d13d 100644 --- a/backend/src/appointment/commands/download_legal.py +++ b/backend/src/appointment/commands/download_legal.py @@ -6,8 +6,10 @@ def run(): - """Helper function to update privacy and terms. Please check to ensure you're not getting a 404 before committing lol.""" - print("Downloading the latest legal documents...") + """Helper function to update privacy and terms. + Please check to ensure you're not getting a 404 before committing lol. + """ + print('Downloading the latest legal documents...') extensions = ['markdown.extensions.attr_list'] # Only english for now. There's no german TB privacy policy? @@ -20,7 +22,7 @@ def run(): os.makedirs(f'{os.path.dirname(__file__)}/../tmp/legal/{locale}', exist_ok=True) if privacy_policy: - print("Privacy policy url found.") + print('Privacy policy url found.') contents = requests.get(privacy_policy).text html = markupsafe.Markup(markdown.markdown(contents, extensions=extensions)) @@ -28,11 +30,11 @@ def run(): fh.write(html) if terms_of_use: - print("Terms of use url found.") + print('Terms of use url found.') contents = requests.get(terms_of_use).text html = markupsafe.Markup(markdown.markdown(contents, extensions=extensions)) with open(f'{os.path.dirname(__file__)}/../tmp/legal/{locale}/terms.html', 'w') as fh: fh.write(html) - print("Done! Copy them over to the frontend/src/assets/legal!") + print('Done! Copy them over to the frontend/src/assets/legal!') diff --git a/backend/src/appointment/commands/update_db.py b/backend/src/appointment/commands/update_db.py index b0150273d..922911625 100644 --- a/backend/src/appointment/commands/update_db.py +++ b/backend/src/appointment/commands/update_db.py @@ -7,7 +7,7 @@ def run(): - print("Checking if we have a fresh database...") + print('Checking if we have a fresh database...') # then, load the Alembic configuration and generate the # version table, "stamping" it with the most recent rev: @@ -15,11 +15,11 @@ def run(): from alembic.config import Config # TODO: Does this work on stage? - alembic_cfg = Config("./alembic.ini") + alembic_cfg = Config('./alembic.ini') # If we have our database url env variable set, use that instead! - if os.getenv("DATABASE_URL"): - alembic_cfg.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL")) + if os.getenv('DATABASE_URL'): + alembic_cfg.set_main_option('sqlalchemy.url', os.getenv('DATABASE_URL')) engine, _ = get_engine_and_session() @@ -31,10 +31,9 @@ def run(): # If we have no revisions, then fully create the database from the model metadata, # and set our revision number to the latest revision. Otherwise run any new migrations if len(revisions) == 0: - print("Initializing database, and setting it to the latest revision") + print('Initializing database, and setting it to the latest revision') models.Base.metadata.create_all(bind=engine) - command.stamp(alembic_cfg, "head") + command.stamp(alembic_cfg, 'head') else: - print("Database already initialized, running migrations") + print('Database already initialized, running migrations') command.upgrade(alembic_cfg, 'head') - diff --git a/backend/src/appointment/controller/apis/fxa_client.py b/backend/src/appointment/controller/apis/fxa_client.py index 173b84839..8cc041df5 100644 --- a/backend/src/appointment/controller/apis/fxa_client.py +++ b/backend/src/appointment/controller/apis/fxa_client.py @@ -25,7 +25,7 @@ def from_url(url): # Check our supported scopes scopes = response.get('scopes_supported') if 'profile' not in scopes: - logging.warning("Profile scope not found in supported scopes for fxa!") + logging.warning('Profile scope not found in supported scopes for fxa!') config = FxaConfig() config.issuer = response.get('issuer') @@ -44,7 +44,7 @@ class FxaClient: ENTRYPOINT = 'tbappointment' SCOPES = [ - "profile", + 'profile', ] config = FxaConfig() @@ -61,17 +61,25 @@ def __init__(self, client_id, client_secret, callback_url): def setup(self, subscriber_id=None, token=None): """Retrieve the openid connect urls, and setup our client connection""" - if type(token) is str: + if isinstance(token, str): token = json.loads(token) self.config = FxaConfig.from_url(os.getenv('FXA_OPEN_ID_CONFIG')) self.subscriber_id = subscriber_id - self.client = OAuth2Session(self.client_id, redirect_uri=self.callback_url, scope=self.SCOPES, - auto_refresh_url=self.config.token_url, - auto_refresh_kwargs={"client_id": self.client_id, "client_secret": self.client_secret, 'include_client_id': True}, - token=token, - token_updater=self.token_saver) + self.client = OAuth2Session( + self.client_id, + redirect_uri=self.callback_url, + scope=self.SCOPES, + auto_refresh_url=self.config.token_url, + auto_refresh_kwargs={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'include_client_id': True, + }, + token=token, + token_updater=self.token_saver, + ) def is_in_allow_list(self, db, email: str): """Check this email against our allow list""" @@ -93,22 +101,27 @@ def get_redirect_url(self, db, state, email): raise NotInAllowListException() utm_campaign = f"{self.ENTRYPOINT}_{os.getenv('APP_ENV')}" - utm_source = "login" + utm_source = 'login' try: - response = self.client.get(url=self.config.metrics_flow_url, params={ - 'entrypoint': self.ENTRYPOINT, - 'form_type': 'email', - 'utm_campaign': utm_campaign, - 'utm_source': utm_source - }) + response = self.client.get( + url=self.config.metrics_flow_url, + params={ + 'entrypoint': self.ENTRYPOINT, + 'form_type': 'email', + 'utm_campaign': utm_campaign, + 'utm_source': utm_source, + }, + ) response.raise_for_status() flow_values = response.json() except requests.HTTPError as e: # Not great, but we can still continue along.. - logging.error(f"Could not initialize metrics flow, error occurred: {e.response.status_code} - {e.response.text}") + logging.error( + f'Could not initialize metrics flow, error occurred: {e.response.status_code} - {e.response.text}' + ) flow_values = {} url, state = self.client.authorization_url( @@ -122,13 +135,15 @@ def get_redirect_url(self, db, state, email): flow_begin_time=flow_values.get('flowBeginTime'), flow_id=flow_values.get('flowId'), utm_source=utm_source, - utm_campaign=utm_campaign + utm_campaign=utm_campaign, ) return url, state def get_credentials(self, code: str): - return self.client.fetch_token(self.config.token_url, code, client_secret=self.client_secret, include_client_id=True) + return self.client.fetch_token( + self.config.token_url, code, client_secret=self.client_secret, include_client_id=True + ) def token_saver(self, token): """requests-oauth automagically calls this function when it has a new refresh token for us. @@ -141,7 +156,9 @@ def token_saver(self, token): if self.subscriber_id is None: return - repo.external_connection.update_token(next(get_db()), json.dumps(token), self.subscriber_id, models.ExternalConnectionType.fxa) + repo.external_connection.update_token( + next(get_db()), json.dumps(token), self.subscriber_id, models.ExternalConnectionType.fxa + ) def get_profile(self): """Retrieve the user's profile information""" @@ -156,11 +173,10 @@ def logout(self): raise MissingRefreshTokenException() # This route doesn't want auth! (Because we're destroying it) - resp = requests.post(self.config.destroy_url, json={ - 'refresh_token': refresh_token, - 'client_id': self.client_id, - 'client_secret': self.client_secret - }) + resp = requests.post( + self.config.destroy_url, + json={'refresh_token': refresh_token, 'client_id': self.client_id, 'client_secret': self.client_secret}, + ) resp.raise_for_status() return resp diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index e43073ed5..7de67e2c4 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -14,21 +14,21 @@ class GoogleClient: """Authenticates with Google OAuth and allows the retrieval of Google Calendar information""" SCOPES = [ - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar.events", - "https://www.googleapis.com/auth/userinfo.email", - "openid", + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/calendar.events', + 'https://www.googleapis.com/auth/userinfo.email', + 'openid', ] client: Flow | None = None def __init__(self, client_id, client_secret, project_id, callback_url): self.config = { - "web": { - "client_id": client_id, - "client_secret": client_secret, - "project_id": project_id, - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", + 'web': { + 'client_id': client_id, + 'client_secret': client_secret, + 'project_id': project_id, + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', } } @@ -49,7 +49,7 @@ def get_redirect_url(self, email): # (Url, State ID) return self.client.authorization_url( - access_type="offline", prompt="consent", login_hint=email if email else None + access_type='offline', prompt='consent', login_hint=email if email else None ) def get_credentials(self, code: str): @@ -60,11 +60,11 @@ def get_credentials(self, code: str): self.client.fetch_token(code=code) return self.client.credentials except Warning as e: - logging.error(f"[google_client.get_credentials] Google Warning: {str(e)}") + logging.error(f'[google_client.get_credentials] Google Warning: {str(e)}') # This usually is the "Scope has changed" error. raise GoogleScopeChanged() except ValueError as e: - logging.error(f"[google_client.get_credentials] Value error while fetching credentials {str(e)}") + logging.error(f'[google_client.get_credentials] Value error while fetching credentials {str(e)}') raise GoogleInvalidCredentials() def get_profile(self, token): @@ -82,7 +82,7 @@ def list_calendars(self, token): Ref: https://developers.google.com/calendar/api/v3/reference/calendarList/list""" response = {} items = [] - with build("calendar", "v3", credentials=token, cache_discovery=False) as service: + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: request = service.calendarList().list(minAccessRole='writer') while request is not None: try: @@ -90,7 +90,7 @@ def list_calendars(self, token): items += response.get('items', []) except HttpError as e: - logging.warning(f"[google_client.list_calendars] Request Error: {e.status_code}/{e.error_details}") + logging.warning(f'[google_client.list_calendars] Request Error: {e.status_code}/{e.error_details}') request = service.calendarList().list_next(request, response) @@ -116,16 +116,17 @@ def list_events(self, calendar_id, time_min, time_max, token): # Explicitly ignore workingLocation events # See: https://developers.google.com/calendar/api/v3/reference/events#eventType - event_types = [ - 'default', - 'focusTime', - 'outOfOffice' - ] + event_types = ['default', 'focusTime', 'outOfOffice'] - with build("calendar", "v3", credentials=token, cache_discovery=False) as service: + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: request = service.events().list( - calendarId=calendar_id, timeMin=time_min, timeMax=time_max, singleEvents=True, orderBy="startTime", - eventTypes=event_types, fields=fields + calendarId=calendar_id, + timeMin=time_min, + timeMax=time_max, + singleEvents=True, + orderBy='startTime', + eventTypes=event_types, + fields=fields, ) while request is not None: try: @@ -133,7 +134,7 @@ def list_events(self, calendar_id, time_min, time_max, token): items += response.get('items', []) except HttpError as e: - logging.warning(f"[google_client.list_events] Request Error: {e.status_code}/{e.error_details}") + logging.warning(f'[google_client.list_events] Request Error: {e.status_code}/{e.error_details}') request = service.events().list_next(request, response) @@ -141,11 +142,11 @@ def list_events(self, calendar_id, time_min, time_max, token): def create_event(self, calendar_id, body, token): response = None - with build("calendar", "v3", credentials=token, cache_discovery=False) as service: + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: try: response = service.events().import_(calendarId=calendar_id, body=body).execute() except HttpError as e: - logging.warning(f"[google_client.create_event] Request Error: {e.status_code}/{e.error_details}") + logging.warning(f'[google_client.create_event] Request Error: {e.status_code}/{e.error_details}') raise EventNotCreatedException() return response @@ -159,25 +160,22 @@ def sync_calendars(self, db, subscriber_id: int, token): error_occurred = False for calendar in calendars: cal = CalendarConnection( - title=calendar.get("summary"), - color=calendar.get("backgroundColor"), - user=calendar.get("id"), - password="", - url=calendar.get("id"), + title=calendar.get('summary'), + color=calendar.get('backgroundColor'), + user=calendar.get('id'), + password='', + url=calendar.get('id'), provider=CalendarProvider.google, ) # add calendar try: repo.calendar.update_or_create( - db=db, - calendar=cal, - calendar_url=calendar.get("id"), - subscriber_id=subscriber_id + db=db, calendar=cal, calendar_url=calendar.get('id'), subscriber_id=subscriber_id ) except Exception as err: logging.warning( - f"[google_client.sync_calendars] Error occurred while creating calendar. Error: {str(err)}" + f'[google_client.sync_calendars] Error occurred while creating calendar. Error: {str(err)}' ) error_occurred = True return error_occurred diff --git a/backend/src/appointment/controller/apis/zoom_client.py b/backend/src/appointment/controller/apis/zoom_client.py index aed0d35f0..23095b6a4 100644 --- a/backend/src/appointment/controller/apis/zoom_client.py +++ b/backend/src/appointment/controller/apis/zoom_client.py @@ -5,17 +5,13 @@ class ZoomClient: - OAUTH_AUTHORIZATION_URL = "https://zoom.us/oauth/authorize" - OAUTH_DEVICE_AUTHORIZATION_URL = "https://zoom.us/oauth/devicecode" - OAUTH_TOKEN_URL = "https://zoom.us/oauth/token" - OAUTH_DEVICE_VERIFY_URL = "https://zoom.us/oauth_device" - OAUTH_REQUEST_URL = "https://api.zoom.us/v2" - - SCOPES = [ - "user:read", - "user_info:read", - "meeting:write" - ] + OAUTH_AUTHORIZATION_URL = 'https://zoom.us/oauth/authorize' + OAUTH_DEVICE_AUTHORIZATION_URL = 'https://zoom.us/oauth/devicecode' + OAUTH_TOKEN_URL = 'https://zoom.us/oauth/token' + OAUTH_DEVICE_VERIFY_URL = 'https://zoom.us/oauth_device' + OAUTH_REQUEST_URL = 'https://api.zoom.us/v2' + + SCOPES = ['user:read', 'user_info:read', 'meeting:write'] client: OAuth2Session | None = None subscriber_id: int | None = None @@ -29,15 +25,23 @@ def __init__(self, client_id, client_secret, callback_url): def setup(self, subscriber_id=None, token=None): """Setup our oAuth session""" - if type(token) is str: + if isinstance(token, str): token = json.loads(token) self.subscriber_id = subscriber_id - self.client = OAuth2Session(self.client_id, redirect_uri=self.callback_url, scope=self.SCOPES, - auto_refresh_url=self.OAUTH_TOKEN_URL, - auto_refresh_kwargs={"client_id": self.client_id, "client_secret": self.client_secret, 'include_client_id': True}, - token=token, - token_updater=self.token_saver) + self.client = OAuth2Session( + self.client_id, + redirect_uri=self.callback_url, + scope=self.SCOPES, + auto_refresh_url=self.OAUTH_TOKEN_URL, + auto_refresh_kwargs={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'include_client_id': True, + }, + token=token, + token_updater=self.token_saver, + ) pass @@ -47,7 +51,9 @@ def get_redirect_url(self, state): return url, state def get_credentials(self, code: str): - return self.client.fetch_token(self.OAUTH_TOKEN_URL, code, client_secret=self.client_secret, include_client_id=True) + return self.client.fetch_token( + self.OAUTH_TOKEN_URL, code, client_secret=self.client_secret, include_client_id=True + ) def token_saver(self, token): """requests-oauth automagically calls this function when it has a new refresh token for us. @@ -61,26 +67,31 @@ def token_saver(self, token): return # get_db is a generator function, retrieve the only yield - repo.external_connection.update_token(next(get_db()), json.dumps(token), self.subscriber_id, models.ExternalConnectionType.zoom) + repo.external_connection.update_token( + next(get_db()), json.dumps(token), self.subscriber_id, models.ExternalConnectionType.zoom + ) def get_me(self): return self.client.get(f'{self.OAUTH_REQUEST_URL}/users/me').json() - def create_meeting(self, title, start_time, duration, timezone = None): + def create_meeting(self, title, start_time, duration, timezone=None): # https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate - response = self.client.post(f'{self.OAUTH_REQUEST_URL}/users/me/meetings', json={ - 'type': 2, # Scheduled Meeting - 'default_password': True, - 'duration': duration, - 'start_time': f"{start_time}Z", # Make it UTC - 'topic': title[:200], # Max 200 chars - 'settings': { - 'private_meeting': True, - 'registrants_confirmation_email': False, - 'registrants_email_notification': False, - } - }) + response = self.client.post( + f'{self.OAUTH_REQUEST_URL}/users/me/meetings', + json={ + 'type': 2, # Scheduled Meeting + 'default_password': True, + 'duration': duration, + 'start_time': f'{start_time}Z', # Make it UTC + 'topic': title[:200], # Max 200 chars + 'settings': { + 'private_meeting': True, + 'registrants_confirmation_email': False, + 'registrants_email_notification': False, + }, + }, + ) response.raise_for_status() diff --git a/backend/src/appointment/controller/auth.py b/backend/src/appointment/controller/auth.py index f6bc255be..31d6bd6b9 100644 --- a/backend/src/appointment/controller/auth.py +++ b/backend/src/appointment/controller/auth.py @@ -2,7 +2,7 @@ Handle authentification with Auth0 and get subscription data. """ -import logging + import os import hashlib import hmac @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from .apis.fxa_client import FxaClient -from ..database import repo, schemas, models +from ..database import schemas, models def logout(db: Session, subscriber: models.Subscriber, fxa_client: FxaClient | None, deny_previous_tokens=True): @@ -27,20 +27,20 @@ def logout(db: Session, subscriber: models.Subscriber, fxa_client: FxaClient | N def sign_url(url: str): """helper to sign a given url""" - secret = os.getenv("SIGNED_SECRET") + secret = os.getenv('SIGNED_SECRET') if not secret: - raise RuntimeError("Missing signed secret environment variable") + raise RuntimeError('Missing signed secret environment variable') - key = bytes(secret, "UTF-8") - message = f"{url}".encode() + key = bytes(secret, 'UTF-8') + message = f'{url}'.encode() signature = hmac.new(key, message, hashlib.sha256).hexdigest() return signature def signed_url_by_subscriber(subscriber: schemas.Subscriber): """helper to generated signed url for given subscriber""" - short_url = os.getenv("SHORT_BASE_URL") + short_url = os.getenv('SHORT_BASE_URL') base_url = f"{os.getenv('FRONTEND_URL')}/user" # If we don't have a short url, then use the default url with /user added to it @@ -49,9 +49,9 @@ def signed_url_by_subscriber(subscriber: schemas.Subscriber): # We sign with a different hash that the end-user doesn't have access to # We also need to use the default url, as short urls are currently setup as a redirect - url = f"{base_url}/{subscriber.username}/{subscriber.short_link_hash}" + url = f'{base_url}/{subscriber.username}/{subscriber.short_link_hash}' signature = sign_url(url) # We return with the signed url signature - return f"{short_url}/{subscriber.username}/{signature}" + return f'{short_url}/{subscriber.username}/{signature}' diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 436d3f082..a130d99f4 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -2,6 +2,7 @@ Handle connection to a CalDAV server. """ + import json import logging import zoneinfo @@ -41,12 +42,12 @@ def obscure_key(self, key): """Obscure part of a key with our encryption algo""" return utils.setup_encryption_engine().encrypt(key) - def get_key_body(self, only_subscriber = False): + def get_key_body(self, only_subscriber=False): parts = [self.obscure_key(self.subscriber_id)] if not only_subscriber: parts.append(self.obscure_key(self.calendar_id)) - return ":".join(parts) + return ':'.join(parts) def get_cached_events(self, key_scope): """Retrieve any cached events, else returns None if redis is not available or there's no cache.""" @@ -69,19 +70,22 @@ def put_cached_events(self, key_scope, events: list[schemas.Event], expiry=os.ge key_scope = self.obscure_key(key_scope) encrypted_events = json.dumps([event.model_dump_redis() for event in events]) - self.redis_instance.set(f'{REDIS_REMOTE_EVENTS_KEY}:{self.get_key_body()}:{key_scope}', - value=encrypted_events, ex=expiry) + self.redis_instance.set( + f'{REDIS_REMOTE_EVENTS_KEY}:{self.get_key_body()}:{key_scope}', value=encrypted_events, ex=expiry + ) return True - def bust_cached_events(self, all_calendars = False): - """Delete cached events for a specific subscriber/calendar. + def bust_cached_events(self, all_calendars=False): + """Delete cached events for a specific subscriber/calendar. Optionally pass in all_calendars to remove all cached calendar events for a specific subscriber.""" if self.redis_instance is None: return False # Scan returns a tuple like: (Cursor start, [...keys found]) - ret = self.redis_instance.scan(0, f'{REDIS_REMOTE_EVENTS_KEY}:{self.get_key_body(only_subscriber=all_calendars)}:*') + ret = self.redis_instance.scan( + 0, f'{REDIS_REMOTE_EVENTS_KEY}:{self.get_key_body(only_subscriber=all_calendars)}:*' + ) if len(ret[1]) == 0: return False @@ -94,7 +98,7 @@ def bust_cached_events(self, all_calendars = False): class GoogleConnector(BaseConnector): """Generic interface for Google Calendar REST API. - This should match CaldavConnector (except for the constructor). + This should match CaldavConnector (except for the constructor). """ def __init__( @@ -147,40 +151,46 @@ def list_calendars(self): def list_events(self, start, end): """find all events in given date range on the remote server""" - cache_scope = f"{start}_{end}" + cache_scope = f'{start}_{end}' cached_events = self.get_cached_events(cache_scope) if cached_events: return cached_events - time_min = datetime.strptime(start, DATEFMT).isoformat() + "Z" - time_max = datetime.strptime(end, DATEFMT).isoformat() + "Z" + time_min = datetime.strptime(start, DATEFMT).isoformat() + 'Z' + time_max = datetime.strptime(end, DATEFMT).isoformat() + 'Z' # We're storing google cal id in user...for now. remote_events = self.google_client.list_events(self.remote_calendar_id, time_min, time_max, self.google_token) events = [] for event in remote_events: - status = event.get("status").lower() + status = event.get('status').lower() # Ignore cancelled events - if status == "cancelled": + if status == 'cancelled': continue # Mark tentative events - attendees = event.get("attendees") or [] + attendees = event.get('attendees') or [] tentative = any( - (attendee.get("self") and attendee.get("responseStatus") == "tentative") for attendee in attendees + (attendee.get('self') and attendee.get('responseStatus') == 'tentative') for attendee in attendees ) - summary = event.get("summary", "Title not found!") - description = event.get("description", "") + summary = event.get('summary', 'Title not found!') + description = event.get('description', '') - all_day = "date" in event.get("start") + all_day = 'date' in event.get('start') - start = datetime.strptime(event.get("start")["date"], DATEFMT) if all_day else datetime.fromisoformat( - event.get("start")["dateTime"]) - end = datetime.strptime(event.get("end")["date"], DATEFMT) if all_day else datetime.fromisoformat( - event.get("end")["dateTime"]) + start = ( + datetime.strptime(event.get('start')['date'], DATEFMT) + if all_day + else datetime.fromisoformat(event.get('start')['dateTime']) + ) + end = ( + datetime.strptime(event.get('end')['date'], DATEFMT) + if all_day + else datetime.fromisoformat(event.get('end')['dateTime']) + ) events.append( schemas.Event( @@ -216,20 +226,20 @@ def create_event( description.append(l10n('join-phone', {'phone': event.location.phone})) body = { - "iCalUID": event.uuid.hex, - "summary": event.title, - "location": event.location.name, - "description": "\n".join(description), - "start": {"dateTime": event.start.isoformat()}, - "end": {"dateTime": event.end.isoformat()}, - "attendees": [ - {"displayName": organizer.name, "email": organizer_email}, - {"displayName": attendee.name, "email": attendee.email}, + 'iCalUID': event.uuid.hex, + 'summary': event.title, + 'location': event.location.name, + 'description': '\n'.join(description), + 'start': {'dateTime': event.start.isoformat()}, + 'end': {'dateTime': event.end.isoformat()}, + 'attendees': [ + {'displayName': organizer.name, 'email': organizer_email}, + {'displayName': attendee.name, 'email': attendee.email}, ], - "organizer": { - "displayName": organizer.name, - "email": self.remote_calendar_id, - } + 'organizer': { + 'displayName': organizer.name, + 'email': self.remote_calendar_id, + }, } self.google_client.create_event(calendar_id=self.remote_calendar_id, body=body, token=self.google_token) @@ -239,7 +249,7 @@ def create_event( def delete_events(self, start): """delete all events in given date range from the server - Not intended to be used in production. For cleaning purposes after testing only. + Not intended to be used in production. For cleaning purposes after testing only. """ pass @@ -263,10 +273,10 @@ def test_connection(self) -> bool: cal = self.client.calendar(url=self.url) supported_comps = cal.get_supported_components() except IndexError as ex: # Library has an issue with top level urls, probably due to caldav spec? - logging.error(f"Error testing connection {ex}") + logging.error(f'Error testing connection {ex}') return False except KeyError as ex: - logging.error(f"Error testing connection {ex}") + logging.error(f'Error testing connection {ex}') return False except requests.exceptions.RequestException: # Max retries exceeded, bad connection, missing schema, etc... return False @@ -296,7 +306,7 @@ def list_calendars(self): def list_events(self, start, end): """find all events in given date range on the remote server""" - cache_scope = f"{start}_{end}" + cache_scope = f'{start}_{end}' cached_events = self.get_cached_events(cache_scope) if cached_events: return cached_events @@ -310,14 +320,14 @@ def list_events(self, start, end): expand=True, ) for e in result: - status = e.icalendar_component["status"].lower() if "status" in e.icalendar_component else "" + status = e.icalendar_component['status'].lower() if 'status' in e.icalendar_component else '' # Ignore cancelled events - if status == "cancelled": + if status == 'cancelled': continue # Mark tentative events - tentative = status == "tentative" + tentative = status == 'tentative' title = e.vobject_instance.vevent.summary.value start = e.vobject_instance.vevent.dtstart.value @@ -333,7 +343,7 @@ def list_events(self, start, end): end=end, all_day=all_day, tentative=tentative, - description=e.icalendar_component["description"] if "description" in e.icalendar_component else "", + description=e.icalendar_component['description'] if 'description' in e.icalendar_component else '', ) ) @@ -342,11 +352,7 @@ def list_events(self, start, end): return events def create_event( - self, - event: schemas.Event, - attendee: schemas.AttendeeBase, - organizer: schemas.Subscriber, - organizer_email: str + self, event: schemas.Event, attendee: schemas.AttendeeBase, organizer: schemas.Subscriber, organizer_email: str ): """add a new event to the connected calendar""" calendar = self.client.calendar(url=self.url) @@ -370,7 +376,7 @@ def create_event( def delete_events(self, start): """delete all events in given date range from the server - Not intended to be used in production. For cleaning purposes after testing only. + Not intended to be used in production. For cleaning purposes after testing only. """ calendar = self.client.calendar(url=self.url) result = calendar.events() @@ -394,27 +400,27 @@ def create_vevent( ): """create an event in ical format for .ics file creation""" cal = Calendar() - cal.add("prodid", "-//Thunderbird Appointment//tba.dk//") - cal.add("version", "2.0") - org = vCalAddress("MAILTO:" + organizer.preferred_email) - org.params["cn"] = vText(organizer.preferred_email) - org.params["role"] = vText("CHAIR") + cal.add('prodid', '-//Thunderbird Appointment//tba.dk//') + cal.add('version', '2.0') + org = vCalAddress('MAILTO:' + organizer.preferred_email) + org.params['cn'] = vText(organizer.preferred_email) + org.params['role'] = vText('CHAIR') event = Event() - event.add("uid", appointment.uuid.hex) - event.add("summary", appointment.title) - event.add("dtstart", slot.start.replace(tzinfo=timezone.utc)) + event.add('uid', appointment.uuid.hex) + event.add('summary', appointment.title) + event.add('dtstart', slot.start.replace(tzinfo=timezone.utc)) event.add( - "dtend", + 'dtend', slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration), ) - event.add("dtstamp", datetime.now(UTC)) - event["description"] = appointment.details - event["organizer"] = org + event.add('dtstamp', datetime.now(UTC)) + event['description'] = appointment.details + event['organizer'] = org # Prefer the slot meeting link url over the appointment location url location_url = slot.meeting_link_url if slot.meeting_link_url is not None else appointment.location_url - if location_url != "" or location_url is not None: + if location_url != '' or location_url is not None: event.add('location', location_url) cal.add_component(event) @@ -430,8 +436,8 @@ def send_vevent( ): """send a booking confirmation email to attendee with .ics file attached""" invite = Attachment( - mime=("text", "calendar"), - filename="AppointmentInvite.ics", + mime=('text', 'calendar'), + filename='AppointmentInvite.ics', data=self.create_vevent(appointment, slot, organizer), ) background_tasks.add_task(send_invite_email, to=attendee.email, attachment=invite) @@ -461,8 +467,11 @@ def available_slots_from_schedule(schedule: models.Schedule) -> list[schemas.Slo farthest_booking = now + timedelta(days=1, minutes=schedule.farthest_booking) schedule_start = max([datetime.combine(schedule.start_date, start_time_local), earliest_booking]) - schedule_end = min([datetime.combine(schedule.end_date, end_time_local), - farthest_booking]) if schedule.end_date else farthest_booking + schedule_end = ( + min([datetime.combine(schedule.end_date, end_time_local), farthest_booking]) + if schedule.end_date + else farthest_booking + ) start_time = datetime.combine(now.min, start_time_local) - datetime.min end_time = datetime.combine(now.min, end_time_local) - datetime.min @@ -472,11 +481,12 @@ def available_slots_from_schedule(schedule: models.Schedule) -> list[schemas.Slo end_time += timedelta(days=1) # All user defined weekdays, falls back to working week if invalid - weekdays = schedule.weekdays if type(schedule.weekdays) is list else json.loads(schedule.weekdays) + weekdays = schedule.weekdays if isinstance(schedule.weekdays, list) else json.loads(schedule.weekdays) if not weekdays or len(weekdays) == 0: weekdays = [1, 2, 3, 4, 5] - # Difference of the start and end time. Since our times are localized we start at 0, and go until we hit the diff. + # Difference of the start and end time. + # Since our times are localized we start at 0, and go until we hit the diff. total_time = int(end_time.total_seconds()) - int(start_time.total_seconds()) slot_duration_seconds = schedule.slot_duration * 60 @@ -494,12 +504,18 @@ def available_slots_from_schedule(schedule: models.Schedule) -> list[schemas.Slo # Round up to the nearest slot duration, I'm bad at math... # Get the remainder of the slow, subtract that from our time_start, then add the slot duration back in. - time_start -= (time_start % slot_duration_seconds) + time_start -= time_start % slot_duration_seconds time_start += slot_duration_seconds date = datetime.fromordinal(ordinal) - current_datetime = datetime(year=date.year, month=date.month, day=date.day, hour=start_time_local.hour, - minute=start_time_local.minute, tzinfo=timezone) + current_datetime = datetime( + year=date.year, + month=date.month, + day=date.day, + hour=start_time_local.hour, + minute=start_time_local.minute, + tzinfo=timezone, + ) # Check if this weekday is within our schedule if current_datetime.isoweekday() in weekdays: # Generate each timeslot based on the selected duration @@ -512,10 +528,11 @@ def available_slots_from_schedule(schedule: models.Schedule) -> list[schemas.Slo return slots @staticmethod - def events_roll_up_difference(a_list: list[schemas.SlotBase], b_list: list[schemas.Event]) -> list[ - schemas.SlotBase]: + def events_roll_up_difference( + a_list: list[schemas.SlotBase], b_list: list[schemas.Event] + ) -> list[schemas.SlotBase]: """This helper rolls up all events from list A, which have a time collision with any event in list B - and returns all remaining elements from A as new list. + and returns all remaining elements from A as new list. """ def is_blocker(a_start: datetime, a_end: datetime, b_start: datetime, b_end: datetime): @@ -534,18 +551,18 @@ def is_blocker(a_start: datetime, a_end: datetime, b_start: datetime, b_end: dat # If any of the events are overlap the slot time... if any([is_blocker(slot_start, slot_end, event.start, event.end) for event in b_list]): - previous_collision_end = collisions[-1].start + timedelta(minutes=collisions[-1].duration) if len(collisions) else None + previous_collision_end = ( + collisions[-1].start + timedelta(minutes=collisions[-1].duration) if len(collisions) else None + ) # ...and the last item was a previous collision then extend the previous collision's duration if previous_collision_end and previous_collision_end.timestamp() == slot_start.timestamp(): collisions[-1].duration += slot.duration else: # ...if the last item was a normal available time, then create a new collision - collisions.append(schemas.SlotBase( - start=slot_start, - duration=slot.duration, - booking_status=BookingStatus.booked - )) + collisions.append( + schemas.SlotBase(start=slot_start, duration=slot.duration, booking_status=BookingStatus.booked) + ) else: # ...Otherwise, just append the normal available time. available_slots.append(slot) @@ -565,16 +582,17 @@ def existing_events_for_schedule( subscriber: models.Subscriber, google_client: GoogleClient, db, - redis = None + redis=None, ) -> list[schemas.Event]: - """This helper retrieves all events existing in given calendars for the scheduled date range - """ + """This helper retrieves all events existing in given calendars for the scheduled date range""" existing_events = [] # handle calendar events for calendar in calendars: if calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) + external_connection = utils.list_first( + repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google) + ) if external_connection is None or external_connection.token is None: raise RemoteCalendarConnectionError() @@ -604,8 +622,11 @@ def existing_events_for_schedule( farthest_booking = now + timedelta(minutes=schedule.farthest_booking) start = max([datetime.combine(schedule.start_date, schedule.start_time), earliest_booking]) - end = min([datetime.combine(schedule.end_date, schedule.end_time), - farthest_booking]) if schedule.end_date else farthest_booking + end = ( + min([datetime.combine(schedule.end_date, schedule.end_time), farthest_booking]) + if schedule.end_date + else farthest_booking + ) try: existing_events.extend(con.list_events(start.strftime(DATEFMT), end.strftime(DATEFMT))) @@ -615,10 +636,12 @@ def existing_events_for_schedule( # handle already requested time slots for slot in schedule.slots: - existing_events.append(schemas.Event( - title=schedule.name, - start=slot.start, - end=slot.start + timedelta(minutes=slot.duration), - )) + existing_events.append( + schemas.Event( + title=schedule.name, + start=slot.start, + end=slot.start + timedelta(minutes=slot.duration), + ) + ) return existing_events diff --git a/backend/src/appointment/controller/data.py b/backend/src/appointment/controller/data.py index 31a7862bf..1a62d8110 100644 --- a/backend/src/appointment/controller/data.py +++ b/backend/src/appointment/controller/data.py @@ -62,16 +62,18 @@ def download(db, subscriber: Subscriber): # Create an in-memory zip and append our csvs zip_buffer = BytesIO() - with ZipFile(zip_buffer, "w") as data_zip: - data_zip.writestr("attendees.csv", attendee_buffer.getvalue()) - data_zip.writestr("appointments.csv", appointment_buffer.getvalue()) - data_zip.writestr("calendar.csv", calendar_buffer.getvalue()) - data_zip.writestr("subscriber.csv", subscriber_buffer.getvalue()) - data_zip.writestr("slot.csv", slot_buffer.getvalue()) - data_zip.writestr("external_connection.csv", external_connections_buffer.getvalue()) - data_zip.writestr("schedules.csv", schedules_buffer.getvalue()) - data_zip.writestr("availability.csv", availability_buffer) - data_zip.writestr("readme.txt", l10n('account-data-readme', {'download_time': datetime.datetime.now(datetime.UTC)})) + with ZipFile(zip_buffer, 'w') as data_zip: + data_zip.writestr('attendees.csv', attendee_buffer.getvalue()) + data_zip.writestr('appointments.csv', appointment_buffer.getvalue()) + data_zip.writestr('calendar.csv', calendar_buffer.getvalue()) + data_zip.writestr('subscriber.csv', subscriber_buffer.getvalue()) + data_zip.writestr('slot.csv', slot_buffer.getvalue()) + data_zip.writestr('external_connection.csv', external_connections_buffer.getvalue()) + data_zip.writestr('schedules.csv', schedules_buffer.getvalue()) + data_zip.writestr('availability.csv', availability_buffer) + data_zip.writestr( + 'readme.txt', l10n('account-data-readme', {'download_time': datetime.datetime.now(datetime.UTC)}) + ) # Return our zip buffer return zip_buffer @@ -85,7 +87,7 @@ def delete_account(db, subscriber: Subscriber): if repo.subscriber.get(db, subscriber.id) is not None: raise AccountDeletionSubscriberFail( subscriber.id, - "There was a problem deleting your data. This incident has been logged and your data will manually be removed.", + l10n('account-delete-fail'), ) empty_check = [ @@ -93,14 +95,14 @@ def delete_account(db, subscriber: Subscriber): len(repo.slot.get_by_subscriber(db, subscriber.id)), len(repo.appointment.get_by_subscriber(db, subscriber.id)), len(repo.calendar.get_by_subscriber(db, subscriber.id)), - len(repo.schedule.get_by_subscriber(db, subscriber.id)) + len(repo.schedule.get_by_subscriber(db, subscriber.id)), ] # Check if we have any left-over subscriber data if any(empty_check) > 0: raise AccountDeletionPartialFail( subscriber.id, - "There was a problem deleting your data. This incident has been logged and your data will manually be removed.", + l10n('account-delete-fail'), ) return True diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index 12798bfa3..d9d10b0eb 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -2,6 +2,7 @@ Handle outgoing emails. """ + import logging import os import smtplib @@ -21,7 +22,6 @@ def get_jinja(): - path = 'src/appointment/templates/email' templates = Jinja2Templates(path) @@ -31,7 +31,7 @@ def get_jinja(): return templates -def get_template(template_name) -> "jinja2.Template": +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) @@ -49,10 +49,10 @@ class Mailer: def __init__( self, to: str, - sender: str = os.getenv("SERVICE_EMAIL"), - subject: str = "", - html: str = "", - plain: str = "", + sender: str = os.getenv('SERVICE_EMAIL'), + subject: str = '', + html: str = '', + plain: str = '', attachments: list[Attachment] = [], ): self.sender = sender @@ -69,7 +69,7 @@ def html(self): def text(self): """provide email body as text""" # TODO: do some real html tag stripping and sanitizing here - return self.body_plain if self.body_plain != "" else escape(self.body_html) + return self.body_plain if self.body_plain != '' else escape(self.body_html) def attachments(self): """provide all attachments as list""" @@ -79,22 +79,22 @@ def build(self): """build email header, body and attachments""" # create mail header message = MIMEMultipart('alternative') - message["Subject"] = self.subject - message["From"] = self.sender - message["To"] = self.to + message['Subject'] = self.subject + message['From'] = self.sender + message['To'] = self.to # add body as html and text parts if self.text(): - message.attach(MIMEText(self.text(), "plain")) + message.attach(MIMEText(self.text(), 'plain')) if self.html(): - message.attach(MIMEText(self.html(), "html")) + message.attach(MIMEText(self.html(), 'html')) # add attachment(s) as multimedia parts for a in self.attachments: part = MIMEBase(a.mime_main, a.mime_sub) part.set_payload(a.data) encoders.encode_base64(part) - part.add_header("Content-Disposition", f"attachment; filename={a.filename}") + part.add_header('Content-Disposition', f'attachment; filename={a.filename}') message.attach(part) return message.as_string() @@ -102,25 +102,25 @@ def build(self): def send(self): """actually send the email""" # get smtp configuration - SMTP_SECURITY = os.getenv("SMTP_SECURITY", "NONE") - SMTP_URL = os.getenv("SMTP_URL", "localhost") - SMTP_PORT = os.getenv("SMTP_PORT", 25) - SMTP_USER = os.getenv("SMTP_USER") - SMTP_PASS = os.getenv("SMTP_PASS") + SMTP_SECURITY = os.getenv('SMTP_SECURITY', 'NONE') + SMTP_URL = os.getenv('SMTP_URL', 'localhost') + SMTP_PORT = os.getenv('SMTP_PORT', 25) + SMTP_USER = os.getenv('SMTP_USER') + SMTP_PASS = os.getenv('SMTP_PASS') # check config - url = f"http://{SMTP_URL}:{SMTP_PORT}" + url = f'http://{SMTP_URL}:{SMTP_PORT}' if not validators.url(url): # url is not valid - logging.error("[mailer.send] No valid SMTP url configured: " + url) + logging.error('[mailer.send] No valid SMTP url configured: ' + url) server = None try: # if configured, create a secure SSL context - if SMTP_SECURITY == "SSL": + if SMTP_SECURITY == 'SSL': server = smtplib.SMTP_SSL(SMTP_URL, SMTP_PORT, context=ssl.create_default_context()) server.login(SMTP_USER, SMTP_PASS) - elif SMTP_SECURITY == "STARTTLS": + elif SMTP_SECURITY == 'STARTTLS': server = smtplib.SMTP(SMTP_URL, SMTP_PORT) server.starttls(context=ssl.create_default_context()) server.login(SMTP_USER, SMTP_PASS) @@ -131,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 occurred on sending email: " + str(e)) + logging.error('[mailer.send] An error occurred on sending email: ' + str(e)) finally: if server: server.quit() @@ -141,27 +141,25 @@ class InvitationMail(Mailer): def __init__(self, *args, **kwargs): """init Mailer with invitation specific defaults""" default_kwargs = { - "subject": l10n('invite-mail-subject'), - "plain": l10n('invite-mail-plain'), + 'subject': l10n('invite-mail-subject'), + 'plain': l10n('invite-mail-plain'), } super(InvitationMail, self).__init__(*args, **default_kwargs, **kwargs) def html(self): - return get_template("invite.jinja2").render() + return get_template('invite.jinja2').render() class ZoomMeetingFailedMail(Mailer): def __init__(self, appointment_title, *args, **kwargs): """init Mailer with invitation specific defaults""" - default_kwargs = { - "subject": l10n('zoom-invite-failed-subject') - } + default_kwargs = {'subject': l10n('zoom-invite-failed-subject')} super(ZoomMeetingFailedMail, self).__init__(*args, **default_kwargs, **kwargs) self.appointment_title = appointment_title def html(self): - return get_template("errors/zoom_invite_failed.jinja2").render(title=self.appointment_title) + return get_template('errors/zoom_invite_failed.jinja2').render(title=self.appointment_title) def text(self): return l10n('zoom-invite-failed-plain', {'title': self.appointment_title}) @@ -175,22 +173,23 @@ def __init__(self, confirm_url, deny_url, attendee_name, attendee_email, date, * self.date = date self.confirmUrl = confirm_url self.denyUrl = deny_url - default_kwargs = { - "subject": l10n('confirm-mail-subject') - } + default_kwargs = {'subject': l10n('confirm-mail-subject')} 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, - }) + 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( + return get_template('confirm.jinja2').render( attendee_name=self.attendee_name, attendee_email=self.attendee_email, date=self.date, @@ -204,19 +203,14 @@ def __init__(self, owner_name, date, *args, **kwargs): """init Mailer with rejection specific defaults""" self.owner_name = owner_name self.date = date - default_kwargs = { - "subject": l10n('reject-mail-subject') - } + default_kwargs = {'subject': l10n('reject-mail-subject')} super(RejectionMail, self).__init__(*args, **default_kwargs, **kwargs) def text(self): - return l10n('reject-mail-plain', { - 'owner_name': self.owner_name, - 'date': self.date - }) + return l10n('reject-mail-plain', {'owner_name': self.owner_name, 'date': self.date}) def html(self): - return get_template("rejected.jinja2").render(owner_name=self.owner_name, date=self.date) + return get_template('rejected.jinja2').render(owner_name=self.owner_name, date=self.date) class PendingRequestMail(Mailer): @@ -224,19 +218,14 @@ def __init__(self, owner_name, date, *args, **kwargs): """init Mailer with pending specific defaults""" self.owner_name = owner_name self.date = date - default_kwargs = { - "subject": l10n('pending-mail-subject') - } + default_kwargs = {'subject': l10n('pending-mail-subject')} super(PendingRequestMail, self).__init__(*args, **default_kwargs, **kwargs) def text(self): - return l10n('pending-mail-plain', { - 'owner_name': self.owner_name, - 'date': self.date - }) + return l10n('pending-mail-plain', {'owner_name': self.owner_name, 'date': self.date}) def html(self): - return get_template("pending.jinja2").render(owner_name=self.owner_name, date=self.date) + return get_template('pending.jinja2').render(owner_name=self.owner_name, date=self.date) class SupportRequestMail(Mailer): @@ -246,34 +235,41 @@ def __init__(self, requestee_name, requestee_email, topic, details, *args, **kwa 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"), *args, **default_kwargs, **kwargs) + default_kwargs = {'subject': l10n('support-mail-subject', {'topic': topic})} + super(SupportRequestMail, self).__init__(os.getenv('SUPPORT_EMAIL'), *args, **default_kwargs, **kwargs) def text(self): - return l10n('support-mail-plain', { - 'requestee_name': self.requestee_name, - 'requestee_email': self.requestee_email, - 'topic': self.topic, - 'details': self.details, - }) + return l10n( + 'support-mail-plain', + { + 'requestee_name': self.requestee_name, + 'requestee_email': self.requestee_email, + 'topic': self.topic, + 'details': self.details, + }, + ) def html(self): - return get_template("support.jinja2").render(requestee_name=self.requestee_name, requestee_email=self.requestee_email, topic=self.topic, details=self.details) + return get_template('support.jinja2').render( + requestee_name=self.requestee_name, + requestee_email=self.requestee_email, + topic=self.topic, + details=self.details, + ) class InviteAccountMail(Mailer): def __init__(self, *args, **kwargs): - default_kwargs = { - "subject": l10n('new-account-mail-subject') - } + default_kwargs = {'subject': l10n('new-account-mail-subject')} super(InviteAccountMail, self).__init__(*args, **default_kwargs, **kwargs) def text(self): - return l10n('new-account-mail-plain', { - 'homepage_url': os.getenv('FRONTEND_URL'), - }) + return l10n( + 'new-account-mail-plain', + { + 'homepage_url': os.getenv('FRONTEND_URL'), + }, + ) def html(self): - return get_template("new_account.jinja2").render(homepage_url=os.getenv('FRONTEND_URL')) + return get_template('new_account.jinja2').render(homepage_url=os.getenv('FRONTEND_URL')) diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 5727ce4e0..00e049bac 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -2,6 +2,7 @@ Definitions of database tables and their relationships. """ + import datetime import enum import os @@ -16,11 +17,11 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') def random_slug(): - return "".join(str(uuid.uuid4()).split("-")) + return ''.join(str(uuid.uuid4()).split('-')) class SubscriberLevel(enum.Enum): @@ -83,6 +84,7 @@ class InviteStatus(enum.Enum): @as_declarative() class Base: """Base model, contains anything we want to be on every model.""" + @declared_attr def __tablename__(cls): return cls.__name__.lower() @@ -100,6 +102,7 @@ def get_columns(self) -> list: class HasSoftDelete: """Mixing in a column to support deletion without removing the record""" + time_deleted = Column(DateTime, nullable=True) @property @@ -109,31 +112,35 @@ def is_deleted(self): class Subscriber(HasSoftDelete, Base): - __tablename__ = "subscribers" + __tablename__ = 'subscribers' id = Column(Integer, primary_key=True, index=True) - username = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True) + username = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), unique=True, index=True) # Encrypted (here) and hashed (by the associated hashing functions in routes/auth) - password = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) + password = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=False) # Use subscriber.preferred_email for any email, or other user-facing presence. - email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True) - secondary_email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), nullable=True, index=True) + email = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), unique=True, index=True) + secondary_email = Column( + StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), nullable=True, index=True + ) - name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + name = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True) - timezone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - avatar_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False) + timezone = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) + avatar_url = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048), index=False) - short_link_hash = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) + short_link_hash = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=False) # Only accept the times greater than the one specified in the `iat` claim of the jwt token - minimum_valid_iat_time = Column('minimum_valid_iat_time', StringEncryptedType(DateTime, secret, AesEngine, "pkcs5", length=255)) + minimum_valid_iat_time = Column( + 'minimum_valid_iat_time', StringEncryptedType(DateTime, secret, AesEngine, 'pkcs5', length=255) + ) - calendars = relationship("Calendar", cascade="all,delete", back_populates="owner") - slots = relationship("Slot", cascade="all,delete", back_populates="subscriber") - external_connections = relationship("ExternalConnections", cascade="all,delete", back_populates="owner") - invite: Mapped["Invite"] = relationship("Invite", back_populates="subscriber", uselist=False) + calendars = relationship('Calendar', cascade='all,delete', back_populates='owner') + slots = relationship('Slot', cascade='all,delete', back_populates='subscriber') + external_connections = relationship('ExternalConnections', cascade='all,delete', back_populates='owner') + invite: Mapped['Invite'] = relationship('Invite', back_populates='subscriber', uselist=False) def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConnections': """Retrieves the first found external connection by type or returns None if not found""" @@ -146,171 +153,189 @@ def preferred_email(self): class Calendar(Base): - __tablename__ = "calendars" + __tablename__ = 'calendars' id = Column(Integer, primary_key=True, index=True) - owner_id = Column(Integer, ForeignKey("subscribers.id")) + owner_id = Column(Integer, ForeignKey('subscribers.id')) provider = Column(Enum(CalendarProvider), default=CalendarProvider.caldav) - title = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - color = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=32), index=True) - url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False) - user = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - password = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + title = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) + color = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=32), index=True) + url = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048), index=False) + user = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) + password = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255)) connected = Column(Boolean, index=True, default=False) connected_at = Column(DateTime) - owner: Mapped[Subscriber] = relationship("Subscriber", back_populates="calendars") - appointments: Mapped[list["Appointment"]] = relationship("Appointment", cascade="all,delete", back_populates="calendar") - schedules: Mapped[list["Schedule"]] = relationship("Schedule", cascade="all,delete", back_populates="calendar") + owner: Mapped[Subscriber] = relationship('Subscriber', back_populates='calendars') + appointments: Mapped[list['Appointment']] = relationship( + 'Appointment', cascade='all,delete', back_populates='calendar' + ) + schedules: Mapped[list['Schedule']] = relationship('Schedule', cascade='all,delete', back_populates='calendar') class Appointment(Base): - __tablename__ = "appointments" + __tablename__ = 'appointments' id = Column(Integer, primary_key=True, index=True) uuid = Column(UUIDType(native=False), default=uuid.uuid4(), index=True) - calendar_id = Column(Integer, ForeignKey("calendars.id")) + calendar_id = Column(Integer, ForeignKey('calendars.id')) duration = Column(Integer) - title = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) + title = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255)) location_type = Column(Enum(LocationType), default=LocationType.inperson) location_suggestions = Column(String(255)) location_selected = Column(Integer) - location_name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - location_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) - location_phone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - details = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - slug = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True) + location_name = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255)) + location_url = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048)) + location_phone = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255)) + details = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255)) + slug = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), unique=True, index=True) keep_open = Column(Boolean) status: AppointmentStatus = Column(Enum(AppointmentStatus), default=AppointmentStatus.draft) # What (if any) meeting link will we generate once the meeting is booked - meeting_link_provider = Column(StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), default=MeetingLinkProviderType.none, index=False) + meeting_link_provider = Column( + StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, 'pkcs5', length=255), + default=MeetingLinkProviderType.none, + index=False, + ) - calendar: Mapped[Calendar] = relationship("Calendar", back_populates="appointments") - slots: Mapped[list['Slot']] = relationship("Slot", cascade="all,delete", back_populates="appointment") + calendar: Mapped[Calendar] = relationship('Calendar', back_populates='appointments') + slots: Mapped[list['Slot']] = relationship('Slot', cascade='all,delete', back_populates='appointment') class Attendee(Base): - __tablename__ = "attendees" + __tablename__ = 'attendees' id = Column(Integer, primary_key=True, index=True) - email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + email = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) + name = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) timezone = Column(String(255), index=True) - slots: Mapped[list['Slot']] = relationship("Slot", cascade="all,delete", back_populates="attendee") + slots: Mapped[list['Slot']] = relationship('Slot', cascade='all,delete', back_populates='attendee') class Slot(Base): - __tablename__ = "slots" + __tablename__ = 'slots' id = Column(Integer, primary_key=True, index=True) - appointment_id = Column(Integer, ForeignKey("appointments.id")) - schedule_id = Column(Integer, ForeignKey("schedules.id")) - attendee_id = Column(Integer, ForeignKey("attendees.id")) - subscriber_id = Column(Integer, ForeignKey("subscribers.id")) + appointment_id = Column(Integer, ForeignKey('appointments.id')) + schedule_id = Column(Integer, ForeignKey('schedules.id')) + attendee_id = Column(Integer, ForeignKey('attendees.id')) + subscriber_id = Column(Integer, ForeignKey('subscribers.id')) time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) start = Column(DateTime) duration = Column(Integer) # provider specific id we can use to query against their service - meeting_link_id = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=1024), index=False) + meeting_link_id = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=1024), index=False) # meeting link override for a appointment or schedule's location url - meeting_link_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) + meeting_link_url = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048)) # columns for availability bookings - booking_tkn = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=512), index=False) + booking_tkn = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=512), index=False) booking_expires_at = Column(DateTime) booking_status = Column(Enum(BookingStatus), default=BookingStatus.none) - appointment: Mapped[Appointment] = relationship("Appointment", back_populates="slots") - schedule: Mapped['Schedule'] = relationship("Schedule", back_populates="slots") + appointment: Mapped[Appointment] = relationship('Appointment', back_populates='slots') + schedule: Mapped['Schedule'] = relationship('Schedule', back_populates='slots') - attendee: Mapped[Attendee] = relationship("Attendee", cascade="all,delete", back_populates="slots") - subscriber: Mapped[Subscriber] = relationship("Subscriber", back_populates="slots") + attendee: Mapped[Attendee] = relationship('Attendee', cascade='all,delete', back_populates='slots') + subscriber: Mapped[Subscriber] = relationship('Subscriber', back_populates='slots') class Schedule(Base): - __tablename__ = "schedules" + __tablename__ = 'schedules' id: int = Column(Integer, primary_key=True, index=True) - calendar_id: int = Column(Integer, ForeignKey("calendars.id")) + calendar_id: int = Column(Integer, ForeignKey('calendars.id')) active: bool = Column(Boolean, index=True, default=True) - name: str = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + name: str = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) location_type: LocationType = Column(Enum(LocationType), default=LocationType.inperson) - location_url: str = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) - details: str = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255)) - start_date: datetime.date = Column(StringEncryptedType(Date, secret, AesEngine, "pkcs5", length=255), index=True) - end_date: datetime.date = Column(StringEncryptedType(Date, secret, AesEngine, "pkcs5", length=255), index=True) - start_time: datetime.time = Column(StringEncryptedType(Time, secret, AesEngine, "pkcs5", length=255), index=True) - end_time: datetime.time = Column(StringEncryptedType(Time, secret, AesEngine, "pkcs5", length=255), index=True) + location_url: str = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048)) + details: str = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255)) + start_date: datetime.date = Column(StringEncryptedType(Date, secret, AesEngine, 'pkcs5', length=255), index=True) + end_date: datetime.date = Column(StringEncryptedType(Date, secret, AesEngine, 'pkcs5', length=255), index=True) + start_time: datetime.time = Column(StringEncryptedType(Time, secret, AesEngine, 'pkcs5', length=255), index=True) + end_time: datetime.time = Column(StringEncryptedType(Time, secret, AesEngine, 'pkcs5', length=255), index=True) earliest_booking: int = Column(Integer, default=1440) # in minutes, defaults to 24 hours farthest_booking: int = Column(Integer, default=20160) # in minutes, defaults to 2 weeks - weekdays: str | dict = Column(JSON, default="[1,2,3,4,5]") # list of ISO weekdays, Mo-Su => 1-7 + weekdays: str | dict = Column(JSON, default='[1,2,3,4,5]') # list of ISO weekdays, Mo-Su => 1-7 slot_duration: int = Column(Integer, default=30) # defaults to 30 minutes # What (if any) meeting link will we generate once the meeting is booked - meeting_link_provider: MeetingLinkProviderType = Column(StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), default=MeetingLinkProviderType.none, index=False) - - calendar: Mapped[Calendar] = relationship("Calendar", back_populates="schedules") - availabilities: Mapped[list["Availability"]] = relationship("Availability", cascade="all,delete", back_populates="schedule") - slots: Mapped[list[Slot]] = relationship("Slot", cascade="all,delete", back_populates="schedule") + meeting_link_provider: MeetingLinkProviderType = Column( + StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, 'pkcs5', length=255), + default=MeetingLinkProviderType.none, + index=False, + ) + + calendar: Mapped[Calendar] = relationship('Calendar', back_populates='schedules') + availabilities: Mapped[list['Availability']] = relationship( + 'Availability', cascade='all,delete', back_populates='schedule' + ) + slots: Mapped[list[Slot]] = relationship('Slot', cascade='all,delete', back_populates='schedule') @property def start_time_local(self) -> datetime.time: """Start Time in the Schedule's Calendar's Owner's timezone""" - time_of_save = self.time_updated.replace(hour=self.start_time.hour, minute=self.start_time.minute, second=0, tzinfo=datetime.timezone.utc) + time_of_save = self.time_updated.replace( + hour=self.start_time.hour, minute=self.start_time.minute, second=0, tzinfo=datetime.timezone.utc + ) return time_of_save.astimezone(zoneinfo.ZoneInfo(self.calendar.owner.timezone)).time() @property def end_time_local(self) -> datetime.time: """End Time in the Schedule's Calendar's Owner's timezone""" - time_of_save = self.time_updated.replace(hour=self.end_time.hour, minute=self.end_time.minute, second=0, tzinfo=datetime.timezone.utc) + time_of_save = self.time_updated.replace( + hour=self.end_time.hour, minute=self.end_time.minute, second=0, tzinfo=datetime.timezone.utc + ) return time_of_save.astimezone(zoneinfo.ZoneInfo(self.calendar.owner.timezone)).time() class Availability(Base): """This table will be used as soon as the application provides custom availability - in addition to the general availability + in addition to the general availability """ - __tablename__ = "availabilities" + __tablename__ = 'availabilities' id = Column(Integer, primary_key=True, index=True) - schedule_id = Column(Integer, ForeignKey("schedules.id")) - day_of_week = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - start_time = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - end_time = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + schedule_id = Column(Integer, ForeignKey('schedules.id')) + day_of_week = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) + start_time = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) + end_time = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) # Can't book if it's less than X minutes before start time: - min_time_before_meeting = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + min_time_before_meeting = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) slot_duration = Column(Integer) # Size of the Slot that can be booked. - schedule: Mapped[Schedule] = relationship("Schedule", back_populates="availabilities") + schedule: Mapped[Schedule] = relationship('Schedule', back_populates='availabilities') class ExternalConnections(Base): """This table holds all external service connections to a subscriber.""" - __tablename__ = "external_connections" + + __tablename__ = 'external_connections' id = Column(Integer, primary_key=True, index=True) - owner_id = Column(Integer, ForeignKey("subscribers.id")) - name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) + owner_id = Column(Integer, ForeignKey('subscribers.id')) + name = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=False) type = Column(Enum(ExternalConnectionType), index=True) - type_id = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) - token = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False) - owner: Mapped[Subscriber] = relationship("Subscriber", back_populates="external_connections") + type_id = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True) + token = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048), index=False) + owner: Mapped[Subscriber] = relationship('Subscriber', back_populates='external_connections') class Invite(Base): """This table holds all invite codes for code based sign-ups.""" - __tablename__ = "invites" + + __tablename__ = 'invites' id = Column(Integer, primary_key=True, index=True) - subscriber_id = Column(Integer, ForeignKey("subscribers.id")) - code = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) + subscriber_id = Column(Integer, ForeignKey('subscribers.id')) + code = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=False) status = Column(Enum(InviteStatus), index=True) - subscriber: Mapped["Subscriber"] = relationship("Subscriber", back_populates="invite", single_parent=True) + subscriber: Mapped['Subscriber'] = relationship('Subscriber', back_populates='invite', single_parent=True) @property def is_used(self) -> bool: diff --git a/backend/src/appointment/database/repo/__init__.py b/backend/src/appointment/database/repo/__init__.py index 8bcd7e2d2..b095da42e 100644 --- a/backend/src/appointment/database/repo/__init__.py +++ b/backend/src/appointment/database/repo/__init__.py @@ -1 +1 @@ -from . import appointment, attendee, calendar, external_connection, invite, schedule, slot, subscriber +from . import appointment, attendee, calendar, external_connection, invite, schedule, slot, subscriber # noqa: F401 diff --git a/backend/src/appointment/database/repo/appointment.py b/backend/src/appointment/database/repo/appointment.py index 1ff231bc0..e401a4917 100644 --- a/backend/src/appointment/database/repo/appointment.py +++ b/backend/src/appointment/database/repo/appointment.py @@ -1,6 +1,6 @@ """Module: repo.appointment -Repository providing CRUD functions for appointment database models. +Repository providing CRUD functions for appointment database models. """ from sqlalchemy.orm import Session @@ -18,7 +18,7 @@ def create(db: Session, appointment: schemas.AppointmentFull, slots: list[schema return db_appointment -def get(db: Session, appointment_id: int) -> models.Appointment|None: +def get(db: Session, appointment_id: int) -> models.Appointment | None: """retrieve appointment by id (private)""" if appointment_id: return db.get(models.Appointment, appointment_id) diff --git a/backend/src/appointment/database/repo/attendee.py b/backend/src/appointment/database/repo/attendee.py index 82e8ae017..ce9401dd6 100644 --- a/backend/src/appointment/database/repo/attendee.py +++ b/backend/src/appointment/database/repo/attendee.py @@ -1,6 +1,6 @@ """Module: repo.attendee -Repository providing CRUD functions for attendee database models. +Repository providing CRUD functions for attendee database models. """ from sqlalchemy.orm import Session diff --git a/backend/src/appointment/database/repo/calendar.py b/backend/src/appointment/database/repo/calendar.py index f5ab6cba1..df245a978 100644 --- a/backend/src/appointment/database/repo/calendar.py +++ b/backend/src/appointment/database/repo/calendar.py @@ -1,6 +1,6 @@ """Module: repo.calendar -Repository providing CRUD functions for calendar database models. +Repository providing CRUD functions for calendar database models. """ from datetime import datetime @@ -57,7 +57,7 @@ def create(db: Session, calendar: schemas.CalendarConnection, subscriber_id: int subscriber_calendar_urls = [c.url for c in subscriber_calendars] # check if subscriber already holds this calendar by url if db_calendar.url in subscriber_calendar_urls: - raise HTTPException(status_code=403, detail="Calendar already exists") + raise HTTPException(status_code=403, detail='Calendar already exists') # add new calendar db.add(db_calendar) db.commit() @@ -71,9 +71,9 @@ def update(db: Session, calendar: schemas.CalendarConnection, calendar_id: int): # list of all attributes that must never be updated # # because they have dedicated update functions for security reasons - ignore = ["connected", "connected_at"] + ignore = ['connected', 'connected_at'] # list of all attributes that will keep their current value if None is passed - keep_if_none = ["password"] + keep_if_none = ['password'] for key, value in calendar: # skip update, if attribute is ignored or current value should be kept if given value is falsey/empty @@ -97,7 +97,7 @@ def update_connection(db: Session, is_connected: bool, calendar_id: int): limit = repo.subscriber.get_connections_limit(db=db, subscriber_id=db_calendar.owner_id) if limit > 0 and len(connected_calendars) >= limit: raise HTTPException( - status_code=403, detail="Allowed number of connected calendars has been reached for this subscription" + status_code=403, detail='Allowed number of connected calendars has been reached for this subscription' ) if not db_calendar.connected: db_calendar.connected_at = datetime.now() diff --git a/backend/src/appointment/database/repo/external_connection.py b/backend/src/appointment/database/repo/external_connection.py index 27e0c99c6..5bc9a83ce 100644 --- a/backend/src/appointment/database/repo/external_connection.py +++ b/backend/src/appointment/database/repo/external_connection.py @@ -1,11 +1,10 @@ """Module: repo.external_connection -Repository providing CRUD functions for external_connection database models. +Repository providing CRUD functions for external_connection database models. """ - from sqlalchemy.orm import Session -from .. import models, repo +from .. import models from ..schemas import ExternalConnection @@ -14,16 +13,16 @@ def create(db: Session, external_connection: ExternalConnection): - db_external_connection = models.ExternalConnections( - **external_connection.dict() - ) + db_external_connection = models.ExternalConnections(**external_connection.dict()) db.add(db_external_connection) db.commit() db.refresh(db_external_connection) return db_external_connection -def update_token(db: Session, token: str, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None = None): +def update_token( + db: Session, token: str, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None = None +): db_results = get_by_type(db, subscriber_id, type, type_id) if db_results is None or len(db_results) == 0: return None @@ -46,7 +45,9 @@ def delete_by_type(db: Session, subscriber_id: int, type: models.ExternalConnect return True -def get_by_type(db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None = None) -> list[models.ExternalConnections] | None: +def get_by_type( + db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None = None +) -> list[models.ExternalConnections] | None: """Return a subscribers external connections by type, and optionally type id""" query = ( db.query(models.ExternalConnections) diff --git a/backend/src/appointment/database/repo/invite.py b/backend/src/appointment/database/repo/invite.py index baa0edd00..201ceeff4 100644 --- a/backend/src/appointment/database/repo/invite.py +++ b/backend/src/appointment/database/repo/invite.py @@ -1,6 +1,6 @@ """Module: repo.invite -Repository providing CRUD functions for invite database models. +Repository providing CRUD functions for invite database models. """ import uuid diff --git a/backend/src/appointment/database/repo/schedule.py b/backend/src/appointment/database/repo/schedule.py index f91381b67..c2cb850ef 100644 --- a/backend/src/appointment/database/repo/schedule.py +++ b/backend/src/appointment/database/repo/schedule.py @@ -1,6 +1,6 @@ """Module: repo.schedule -Repository providing CRUD functions for schedule database models. +Repository providing CRUD functions for schedule database models. """ from sqlalchemy.orm import Session diff --git a/backend/src/appointment/database/repo/slot.py b/backend/src/appointment/database/repo/slot.py index 83beaf3fb..810ceda9e 100644 --- a/backend/src/appointment/database/repo/slot.py +++ b/backend/src/appointment/database/repo/slot.py @@ -1,10 +1,10 @@ """Module: repo.slot -Repository providing CRUD functions for slot database models. +Repository providing CRUD functions for slot database models. """ from sqlalchemy.orm import Session -from .. import models, schemas, repo +from .. import models, schemas """ SLOT repository functions @@ -59,11 +59,11 @@ def exists_on_schedule(db: Session, slot: schemas.SlotBase, schedule_id: int): """check if given slot already exists for schedule of given id""" db_slot = ( db.query(models.Slot) - .filter(models.Slot.schedule_id == schedule_id) - .filter(models.Slot.start == slot.start) - .filter(models.Slot.duration == slot.duration) - .filter(models.Slot.booking_status != models.BookingStatus.none) - .first() + .filter(models.Slot.schedule_id == schedule_id) + .filter(models.Slot.start == slot.start) + .filter(models.Slot.duration == slot.duration) + .filter(models.Slot.booking_status != models.BookingStatus.none) + .first() ) return db_slot is not None @@ -103,7 +103,7 @@ def update(db: Session, slot_id: int, attendee: schemas.Attendee): # update slot db_slot = get(db, slot_id) # TODO: additionally handle subscriber_id here for already logged in users - setattr(db_slot, "attendee_id", db_attendee.id) + setattr(db_slot, 'attendee_id', db_attendee.id) db.commit() return db_attendee diff --git a/backend/src/appointment/database/repo/subscriber.py b/backend/src/appointment/database/repo/subscriber.py index 32fe2662e..97809bb20 100644 --- a/backend/src/appointment/database/repo/subscriber.py +++ b/backend/src/appointment/database/repo/subscriber.py @@ -1,6 +1,6 @@ """Module: repo.subscriber -Repository providing CRUD functions for subscriber database models. +Repository providing CRUD functions for subscriber database models. """ import re @@ -123,7 +123,7 @@ def verify_link(db: Session, url: str): Return subscriber if valid. """ # Look for a followed by an optional signature that ends the string - pattern = r"[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$" + pattern = r'[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$' match = re.findall(pattern, url) if match is None or len(match) == 0: @@ -137,13 +137,13 @@ def verify_link(db: Session, url: str): signature = None if len(match) > 1: signature = match[1] - clean_url = clean_url.replace(signature, "") + clean_url = clean_url.replace(signature, '') subscriber = get_by_username(db, username) if not subscriber: return False - clean_url_with_short_link = clean_url + f"{subscriber.short_link_hash}" + clean_url_with_short_link = clean_url + f'{subscriber.short_link_hash}' signed_signature = sign_url(clean_url_with_short_link) # Verify the signature matches the incoming one diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 9092423b2..4509d948b 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -2,6 +2,7 @@ Definitions of valid data shapes for database and query models. """ + import json from uuid import UUID from datetime import datetime, date, time @@ -118,6 +119,7 @@ class Config: class AppointmentWithCalendarOut(Appointment): """For /me/appointments""" + calendar_title: str calendar_color: str @@ -125,7 +127,7 @@ class AppointmentWithCalendarOut(Appointment): class AppointmentOut(AppointmentBase): id: int | None = None owner_name: str | None = None - slots: list[SlotBase|SlotOut] = [] + slots: list[SlotBase | SlotOut] = [] slot_duration: int @@ -170,7 +172,7 @@ class ScheduleBase(BaseModel): class Config: json_encoders = { - time: lambda t: t.strftime("%H:%M"), + time: lambda t: t.strftime('%H:%M'), } @@ -187,6 +189,7 @@ class Config: class ScheduleValidationIn(ScheduleBase): """ScheduleBase but with specific fields overridden to add validation.""" + slot_duration: Annotated[int, Field(ge=10, default=30)] @@ -236,7 +239,6 @@ class Invite(BaseModel): time_updated: datetime | None = None - """ SUBSCRIBER model schemas """ @@ -316,6 +318,7 @@ class Event(BaseModel): """Ideally this would just be a mixin, but I'm having issues figuring out a good static constructor that will work for anything.""" + def model_dump_redis(self): """Dumps our event into an encrypted json blob for redis""" values_json = self.model_dump_json() @@ -330,7 +333,7 @@ def model_load_redis(encrypted_blob): values = json.loads(values_json) return Event(**values) - + class FileDownload(BaseModel): name: str @@ -375,4 +378,4 @@ class TokenData(BaseModel): class SendInviteEmailIn(BaseModel): - email: str = Field(title="Email", min_length=1) + email: str = Field(title='Email', min_length=1) diff --git a/backend/src/appointment/defines.py b/backend/src/appointment/defines.py index 49559bf74..4654c64e4 100644 --- a/backend/src/appointment/defines.py +++ b/backend/src/appointment/defines.py @@ -1,7 +1,7 @@ SUPPORTED_LOCALES = ['en', 'de'] FALLBACK_LOCALE = 'en' -DATEFMT = "%Y-%m-%d" +DATEFMT = '%Y-%m-%d' # list of redis keys REDIS_REMOTE_EVENTS_KEY = 'rmt_events' diff --git a/backend/src/appointment/dependencies/auth.py b/backend/src/appointment/dependencies/auth.py index 8d0058946..fe2a05f7b 100644 --- a/backend/src/appointment/dependencies/auth.py +++ b/backend/src/appointment/dependencies/auth.py @@ -2,25 +2,25 @@ from typing import Annotated import sentry_sdk -from fastapi import Depends, Request, HTTPException, Body +from fastapi import Depends, Body from fastapi.security import OAuth2PasswordBearer import jwt from sqlalchemy.orm import Session -from ..database import repo, schemas, models +from ..database import repo, models from ..dependencies.database import get_db from ..exceptions import validation from ..exceptions.validation import InvalidTokenException, InvalidPermissionLevelException -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl='token', auto_error=False) def get_user_from_token(db, token: str): try: payload = jwt.decode(token, os.getenv('JWT_SECRET'), algorithms=[os.getenv('JWT_ALGO')]) - sub = payload.get("sub") - iat = payload.get("iat") + sub = payload.get('sub') + iat = payload.get('iat') if sub is None: raise InvalidTokenException() except jwt.exceptions.InvalidTokenError: @@ -30,12 +30,16 @@ def get_user_from_token(db, token: str): subscriber = repo.subscriber.get(db, int(id)) # Token has been expired by us - temp measure to avoid spinning a refresh system, or a deny list for this issue - if any([ - subscriber is None, - subscriber.is_deleted, - subscriber and subscriber.minimum_valid_iat_time and not iat, - subscriber and subscriber.minimum_valid_iat_time and subscriber.minimum_valid_iat_time.timestamp() > int(iat) - ]): + if any( + [ + subscriber is None, + subscriber.is_deleted, + subscriber and subscriber.minimum_valid_iat_time and not iat, + subscriber + and subscriber.minimum_valid_iat_time + and subscriber.minimum_valid_iat_time.timestamp() > int(iat), + ] + ): raise InvalidTokenException() return subscriber @@ -56,9 +60,11 @@ def get_subscriber( # Associate user id with users if os.getenv('SENTRY_DSN'): - sentry_sdk.set_user({ - 'id': user.id, - }) + sentry_sdk.set_user( + { + 'id': user.id, + } + ) return user @@ -68,7 +74,7 @@ def get_admin_subscriber( ): """Retrieve the subscriber and check if they're an admin""" # check admin allow list - admin_emails = os.getenv("APP_ADMIN_ALLOW_LIST") + admin_emails = os.getenv('APP_ADMIN_ALLOW_LIST') # Raise an error if we don't have any admin emails specified if not admin_emails or not user: diff --git a/backend/src/appointment/dependencies/database.py b/backend/src/appointment/dependencies/database.py index 67d5ecc62..91bde0e98 100644 --- a/backend/src/appointment/dependencies/database.py +++ b/backend/src/appointment/dependencies/database.py @@ -6,11 +6,11 @@ def get_engine_and_session(): - database_url = os.getenv("DATABASE_URL") + database_url = os.getenv('DATABASE_URL') connect_args = {} - if "sqlite://" in database_url: - connect_args = {"check_same_thread": False} + if 'sqlite://' in database_url: + connect_args = {'check_same_thread': False} engine = create_engine(database_url, connect_args=connect_args) session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/src/appointment/dependencies/fxa.py b/backend/src/appointment/dependencies/fxa.py index 65192bf90..18f37d1fb 100644 --- a/backend/src/appointment/dependencies/fxa.py +++ b/backend/src/appointment/dependencies/fxa.py @@ -3,7 +3,8 @@ import datetime from fastapi import Request, Depends -#from jose import jwt, jwk + +# from jose import jwt, jwk import jwt from ..controller.apis.fxa_client import FxaClient @@ -18,25 +19,25 @@ def get_webhook_auth(request: Request, fxa_client: FxaClient = Depends(get_fxa_c """Handles decoding and verification of an incoming SET (See: https://mozilla.github.io/ecosystem-platform/relying-parties/tutorials/integration-with-fxa#webhook-events)""" auth_header = request.headers.get('authorization') if not auth_header: - logging.error("FXA webhook event with no authorization.") + logging.error('FXA webhook event with no authorization.') return None header_type, header_token = auth_header.split(' ') if header_type != 'Bearer': - logging.error(f"Error decoding token. Type == {header_type}, which is not Bearer!") + logging.error(f'Error decoding token. Type == {header_type}, which is not Bearer!') return None fxa_client.setup() public_jwks = fxa_client.get_jwk() if not public_jwks: - logging.error("No public jwks available.") + logging.error('No public jwks available.') return None headers = jwt.get_unverified_header(header_token) if 'kid' not in headers: - logging.error("Error decoding token. Key ID is missing from headers.") + logging.error('Error decoding token. Key ID is missing from headers.') return None jwk_pem = None @@ -52,7 +53,9 @@ def get_webhook_auth(request: Request, fxa_client: FxaClient = Depends(get_fxa_c # Amount of time over what the iat is issued for to allow # We were having millisecond timing issues, so this is set to a few seconds to cover for that. leeway = datetime.timedelta(seconds=5) - decoded_jwt = jwt.decode(header_token, key=jwk_pem, audience=fxa_client.client_id, algorithms='RS256', leeway=leeway) + decoded_jwt = jwt.decode( + header_token, key=jwk_pem, audience=fxa_client.client_id, algorithms='RS256', leeway=leeway + ) # Final verification if decoded_jwt.get('iss') != fxa_client.config.issuer: diff --git a/backend/src/appointment/dependencies/google.py b/backend/src/appointment/dependencies/google.py index b26439444..f6280aaa9 100644 --- a/backend/src/appointment/dependencies/google.py +++ b/backend/src/appointment/dependencies/google.py @@ -5,10 +5,10 @@ _google_client = GoogleClient( - os.getenv("GOOGLE_AUTH_CLIENT_ID"), - os.getenv("GOOGLE_AUTH_SECRET"), - os.getenv("GOOGLE_AUTH_PROJECT_ID"), - os.getenv("GOOGLE_AUTH_CALLBACK"), + os.getenv('GOOGLE_AUTH_CLIENT_ID'), + os.getenv('GOOGLE_AUTH_SECRET'), + os.getenv('GOOGLE_AUTH_PROJECT_ID'), + os.getenv('GOOGLE_AUTH_CALLBACK'), ) @@ -18,6 +18,6 @@ def get_google_client() -> 'GoogleClient': _google_client.setup() except Exception as e: # google client setup was not possible - logging.error(f"[routes.google] Google Client could not be setup, bad credentials?\nError: {str(e)}") + logging.error(f'[routes.google] Google Client could not be setup, bad credentials?\nError: {str(e)}') return _google_client diff --git a/backend/src/appointment/dependencies/zoom.py b/backend/src/appointment/dependencies/zoom.py index 852ce07c7..4e1393319 100644 --- a/backend/src/appointment/dependencies/zoom.py +++ b/backend/src/appointment/dependencies/zoom.py @@ -8,15 +8,11 @@ from ..database.models import Subscriber, ExternalConnectionType -def get_zoom_client( - subscriber: Subscriber = Depends(get_subscriber) -): +def get_zoom_client(subscriber: Subscriber = Depends(get_subscriber)): """Returns a zoom client instance. This is a stateful dependency, and requires a new instance per request""" try: _zoom_client = ZoomClient( - os.getenv("ZOOM_AUTH_CLIENT_ID"), - os.getenv("ZOOM_AUTH_SECRET"), - os.getenv("ZOOM_AUTH_CALLBACK") + os.getenv('ZOOM_AUTH_CLIENT_ID'), os.getenv('ZOOM_AUTH_SECRET'), os.getenv('ZOOM_AUTH_CALLBACK') ) # Grab our zoom connection if it's available, we only support one zoom connection...hopefully @@ -25,7 +21,7 @@ def get_zoom_client( _zoom_client.setup(subscriber.id, token) except Exception as e: - logging.error(f"[routes.zoom] Zoom Client could not be setup, bad credentials?\nError: {str(e)}") + logging.error(f'[routes.zoom] Zoom Client could not be setup, bad credentials?\nError: {str(e)}') raise e return _zoom_client diff --git a/backend/src/appointment/exceptions/account_api.py b/backend/src/appointment/exceptions/account_api.py index 01bddc59a..bfad66d2c 100644 --- a/backend/src/appointment/exceptions/account_api.py +++ b/backend/src/appointment/exceptions/account_api.py @@ -2,14 +2,14 @@ class AccountDeletionException(Exception): - def __init__(self, subscriber_id, message=""): + def __init__(self, subscriber_id, message=''): super().__init__(message) self.subscriber_id = subscriber_id self.message = message # TODO: These fails are important to follow up on manually. # We'll need to raise this in our eventual error reporting service, or email. - logging.error(f"Account deletion error for subscriber {subscriber_id}!") + logging.error(f'Account deletion error for subscriber {subscriber_id}!') class AccountDeletionPartialFail(AccountDeletionException): diff --git a/backend/src/appointment/exceptions/calendar.py b/backend/src/appointment/exceptions/calendar.py index b3c42f0cf..174009ffd 100644 --- a/backend/src/appointment/exceptions/calendar.py +++ b/backend/src/appointment/exceptions/calendar.py @@ -1,3 +1,4 @@ class EventNotCreatedException(Exception): """Raise if an event cannot be created on a remote calendar""" + pass diff --git a/backend/src/appointment/exceptions/fxa_api.py b/backend/src/appointment/exceptions/fxa_api.py index 6335b5459..52eda7c69 100644 --- a/backend/src/appointment/exceptions/fxa_api.py +++ b/backend/src/appointment/exceptions/fxa_api.py @@ -1,6 +1,8 @@ class NotInAllowListException(Exception): """Is raised when a given email is not in the allow list""" + pass + class MissingRefreshTokenException(Exception): pass diff --git a/backend/src/appointment/exceptions/google_api.py b/backend/src/appointment/exceptions/google_api.py index 5a6a3bc38..d39325c87 100644 --- a/backend/src/appointment/exceptions/google_api.py +++ b/backend/src/appointment/exceptions/google_api.py @@ -16,6 +16,7 @@ class GoogleInvalidCredentials(Exception): class APIGoogleRefreshError(APIException): """Raise when you need to signal to the end-user that they need to re-connect to Google.""" + id_code = 'GOOGLE_REFRESH_ERROR' status_code = 401 diff --git a/backend/src/appointment/exceptions/validation.py b/backend/src/appointment/exceptions/validation.py index b7cac0361..61ebc2945 100644 --- a/backend/src/appointment/exceptions/validation.py +++ b/backend/src/appointment/exceptions/validation.py @@ -6,15 +6,20 @@ class APIException(HTTPException): """Base exception for all custom API exceptions Custom messages are defined in a function, because l10n needs context set before use.""" + id_code = 'UNKNOWN' status_code = 500 def __init__(self, **kwargs): - super().__init__(status_code=self.status_code, detail={ - 'id': self.id_code, - 'message': self.get_msg(), - 'status': self.status_code, - }, **kwargs) + super().__init__( + status_code=self.status_code, + detail={ + 'id': self.id_code, + 'message': self.get_msg(), + 'status': self.status_code, + }, + **kwargs, + ) def get_msg(self): return l10n('unknown-error') @@ -22,6 +27,7 @@ def get_msg(self): class InvalidPermissionLevelException(APIException): """Raise when the subscribers permission level is too low for the action""" + id_code = 'INVALID_PERMISSION_LEVEL' status_code = 401 @@ -31,6 +37,7 @@ def get_msg(self): class InvalidTokenException(APIException): """Raise when the subscriber could not be parsed from the auth token""" + id_code = 'INVALID_TOKEN' status_code = 401 @@ -40,6 +47,7 @@ def get_msg(self): class InvalidLinkException(APIException): """Raise when subscriber.verify_link fails""" + id_code = 'INVALID_LINK' status_code = 400 @@ -49,14 +57,17 @@ def get_msg(self): class SubscriberNotFoundException(APIException): """Raise when the subscriber is not found during route validation""" + id_code = 'SUBSCRIBER_NOT_FOUND' 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""" + id_code = 'CALENDAR_NOT_FOUND' status_code = 404 @@ -66,6 +77,7 @@ def get_msg(self): class CalendarNotAuthorizedException(APIException): """Raise when the calendar is owned by someone else during route validation""" + id_code = 'CALENDAR_NOT_AUTH' status_code = 403 @@ -75,6 +87,7 @@ def get_msg(self): class CalendarNotConnectedException(APIException): """Raise when the calendar is owned by someone else during route validation""" + id_code = 'CALENDAR_NOT_CONNECTED' status_code = 403 @@ -84,6 +97,7 @@ def get_msg(self): class AppointmentNotFoundException(APIException): """Raise when the appointment is not found during route validation""" + id_code = 'APPOINTMENT_NOT_FOUND' status_code = 404 @@ -93,6 +107,7 @@ def get_msg(self): class AppointmentNotAuthorizedException(APIException): """Raise when the appointment is owned by someone else during route validation""" + id_code = 'APPOINTMENT_NOT_AUTH' status_code = 403 @@ -102,6 +117,7 @@ def get_msg(self): class ScheduleNotFoundException(APIException): """Raise when the schedule is not found during route validation""" + id_code = 'SCHEDULE_NOT_FOUND' status_code = 404 @@ -111,15 +127,17 @@ def get_msg(self): class ScheduleNotActive(APIException): """Raise when the schedule is not active""" + id_code = 'SCHEDULE_NOT_ACTIVE' status_code = 404 def get_msg(self): return l10n('schedule-not-active') - + class ScheduleNotAuthorizedException(APIException): """Raise when the schedule is owned by someone else during route validation""" + id_code = 'SCHEDULE_NOT_AUTH' status_code = 403 @@ -129,6 +147,7 @@ def get_msg(self): class SlotNotFoundException(APIException): """Raise when a timeslot is not found during route validation""" + id_code = 'SLOT_NOT_FOUND' status_code = 404 @@ -138,6 +157,7 @@ def get_msg(self): class SlotAlreadyTakenException(APIException): """Raise when a timeslot is already taken during route validation""" + id_code = 'SLOT_ALREADY_TAKEN' status_code = 403 @@ -147,6 +167,7 @@ def get_msg(self): class SlotNotAuthorizedException(APIException): """Raise when a slot is owned by someone else during route validation""" + id_code = 'SLOT_NOT_AUTH' status_code = 403 @@ -156,6 +177,7 @@ def get_msg(self): class ZoomNotConnectedException(APIException): """Raise if the user requires a zoom connection during route validation""" + id_code = 'ZOOM_NOT_CONNECTED' status_code = 400 @@ -181,6 +203,7 @@ def get_msg(self): class InviteCodeNotFoundException(APIException): """Raise when the invite code is not found during route validation""" + id_code = 'INVITE_CODE_NOT_FOUND' status_code = 404 @@ -190,6 +213,7 @@ def get_msg(self): class InviteCodeNotAvailableException(APIException): """Raise when the invite code is not available anymore during route validation""" + id_code = 'INVITE_CODE_NOT_AVAILABLE' status_code = 403 @@ -199,6 +223,7 @@ def get_msg(self): class CreateSubscriberFailedException(APIException): """Raise when a subscriber failed to be created""" + id_code = 'CREATE_SUBSCRIBER_FAILED' status_code = 400 @@ -208,6 +233,7 @@ def get_msg(self): class CreateSubscriberAlreadyExistsException(APIException): """Raise when a subscriber failed to be created""" + id_code = 'CREATE_SUBSCRIBER_ALREADY_EXISTS' status_code = 400 @@ -217,6 +243,7 @@ def get_msg(self): class SubscriberAlreadyDeletedException(APIException): """Raise when a subscriber failed to be marked deleted because they already are""" + id_code = 'SUBSCRIBER_ALREADY_DELETED' status_code = 400 @@ -226,6 +253,7 @@ def get_msg(self): class SubscriberAlreadyEnabledException(APIException): """Raise when a subscriber failed to be marked undeleted because they already are""" + id_code = 'SUBSCRIBER_ALREADY_ENABLED' status_code = 400 @@ -235,6 +263,7 @@ def get_msg(self): class SubscriberSelfDeleteException(APIException): """Raise when a subscriber tries to delete themselves where not allowed""" + id_code = 'SUBSCRIBER_SELF_DELETE' status_code = 403 diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index 362cec2de..8c9308332 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -2,6 +2,7 @@ Boot application, init database, authenticate user and provide all API endpoints. """ + from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.starlette import StarletteIntegration from starlette.middleware.sessions import SessionMiddleware @@ -11,6 +12,7 @@ from .defines import APP_ENV_DEV, APP_ENV_TEST, APP_ENV_STAGE, APP_ENV_PROD from .middleware.l10n import L10n from .middleware.SanitizeMiddleware import SanitizeMiddleware + # Ignore "Module level import not at top of file" # ruff: noqa: E402 from .secrets import normalize_secrets @@ -42,33 +44,32 @@ def _common_setup(): normalize_secrets() # init logging - level = os.getenv("LOG_LEVEL", "ERROR") - use_log_stream = os.getenv("LOG_USE_STREAM", False) + level = os.getenv('LOG_LEVEL', 'ERROR') + use_log_stream = os.getenv('LOG_USE_STREAM', False) log_config = { - "format": "%(asctime)s %(levelname)-8s %(message)s", - "level": getattr(logging, level), - "datefmt": "%Y-%m-%d %H:%M:%S", + 'format': '%(asctime)s %(levelname)-8s %(message)s', + 'level': getattr(logging, level), + 'datefmt': '%Y-%m-%d %H:%M:%S', } if use_log_stream: - log_config["stream"] = sys.stdout + log_config['stream'] = sys.stdout else: - log_config["filename"] = "appointment.log" + log_config['filename'] = 'appointment.log' logging.basicConfig(**log_config) - logging.debug("Logger started!") - - if os.getenv("SENTRY_DSN") != "" and os.getenv("SENTRY_DSN") is not None: + logging.debug('Logger started!') + if os.getenv('SENTRY_DSN') != '' and os.getenv('SENTRY_DSN') is not None: release_string = None release_version = os.getenv('RELEASE_VERSION') if release_version: - release_string = f"appointment-backend@{release_version}" + release_string = f'appointment-backend@{release_version}' sample_rate = 0 profile_traces_max = 0 - environment = os.getenv("APP_ENV", APP_ENV_STAGE) + environment = os.getenv('APP_ENV', APP_ENV_STAGE) if environment == APP_ENV_STAGE: profile_traces_max = 0.25 @@ -89,20 +90,16 @@ def traces_sampler(sampling_context): return profile_traces_max sentry_sdk.init( - dsn=os.getenv("SENTRY_DSN"), + dsn=os.getenv('SENTRY_DSN'), sample_rate=sample_rate, environment=environment, release=release_string, integrations=[ - StarletteIntegration( - transaction_style="endpoint" - ), - FastApiIntegration( - transaction_style="endpoint" - ), + StarletteIntegration(transaction_style='endpoint'), + FastApiIntegration(transaction_style='endpoint'), ], profiles_sampler=traces_sampler, - traces_sampler=traces_sampler + traces_sampler=traces_sampler, ) @@ -131,37 +128,29 @@ def server(): # init app app = FastAPI(openapi_url=openapi_url) - app.add_middleware( - RawContextMiddleware, - plugins=( - L10n(), - ) - ) + app.add_middleware(RawContextMiddleware, plugins=(L10n(),)) # strip html tags from input requests app.add_middleware(SanitizeMiddleware) - app.add_middleware( - SessionMiddleware, - secret_key=os.getenv("SESSION_SECRET") - ) + app.add_middleware(SessionMiddleware, secret_key=os.getenv('SESSION_SECRET')) # allow requests from own frontend running on a different port app.add_middleware( CORSMiddleware, # Work around for now :) allow_origins=[ - os.getenv("FRONTEND_URL", "http://localhost:8080"), - "https://stage.appointment.day", # Temp for now! - "https://accounts.google.com", - "https://www.googleapis.com/auth/calendar", + os.getenv('FRONTEND_URL', 'http://localhost:8080'), + 'https://stage.appointment.day', # Temp for now! + 'https://accounts.google.com', + 'https://www.googleapis.com/auth/calendar', ], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=['*'], + allow_headers=['*'], ) - @app.middleware("http") + @app.middleware('http') async def warn_about_deprecated_routes(request: Request, call_next): """Warn about clients using deprecated routes""" response = await call_next(request) @@ -171,7 +160,7 @@ async def warn_about_deprecated_routes(request: Request, call_next): logging.warning(f"Use of deprecated route: `{request.scope['route'].path}`!") elif app_env == APP_ENV_TEST: # Stale test runtime error - #raise RuntimeError(f"Test uses deprecated route: `{request.scope['route'].path}`!") + # raise RuntimeError(f"Test uses deprecated route: `{request.scope['route'].path}`!") # Just log for this PR, we'll fix it another PR. logging.error(f"Test uses deprecated route: `{request.scope['route'].path}`!") return response @@ -182,18 +171,17 @@ async def catch_google_refresh_errors(request, exc): """Catch google refresh errors, and use our error instead.""" return await http_exception_handler(request, APIGoogleRefreshError()) - # Mix in our extra routes app.include_router(api.router) app.include_router(auth.router) # Special case! - app.include_router(account.router, prefix="/account") - app.include_router(google.router, prefix="/google") - app.include_router(schedule.router, prefix="/schedule") - app.include_router(invite.router, prefix="/invite") - app.include_router(subscriber.router, prefix="/subscriber") - app.include_router(webhooks.router, prefix="/webhooks") - if os.getenv("ZOOM_API_ENABLED"): - app.include_router(zoom.router, prefix="/zoom") + app.include_router(account.router, prefix='/account') + app.include_router(google.router, prefix='/google') + app.include_router(schedule.router, prefix='/schedule') + app.include_router(invite.router, prefix='/invite') + app.include_router(subscriber.router, prefix='/subscriber') + app.include_router(webhooks.router, prefix='/webhooks') + if os.getenv('ZOOM_API_ENABLED'): + app.include_router(zoom.router, prefix='/zoom') return app @@ -210,5 +198,5 @@ def cli(): app = typer.Typer(pretty_exceptions_enable=False) # We don't have too many commands, so just dump them under main for now. - app.add_typer(commands.router, name="main") + app.add_typer(commands.router, name='main') app() diff --git a/backend/src/appointment/middleware/SanitizeMiddleware.py b/backend/src/appointment/middleware/SanitizeMiddleware.py index 65cc8fd1c..6b19951bb 100644 --- a/backend/src/appointment/middleware/SanitizeMiddleware.py +++ b/backend/src/appointment/middleware/SanitizeMiddleware.py @@ -1,26 +1,21 @@ import json -import logging import nh3 from starlette.types import ASGIApp, Scope, Receive, Send from ..utils import is_json class SanitizeMiddleware: - def __init__(self, app: ASGIApp) -> None: self.app = app - @staticmethod def sanitize_str(value: str) -> str: - return nh3.clean(value, tags={""}) if isinstance(value, str) else value - + return nh3.clean(value, tags={''}) if isinstance(value, str) else value @staticmethod def sanitize_dict(dict_value: str) -> str: return {key: __class__.sanitize_str(value) for key, value in dict_value.items()} - @staticmethod def sanitize_list(list_values: list) -> list: for index, value in enumerate(list_values): @@ -30,14 +25,13 @@ def sanitize_list(list_values: list) -> list: list_values[index] = __class__.sanitize_str(value) return list_values - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if "method" not in scope or scope["method"] in ("GET", "HEAD", "OPTIONS"): + if 'method' not in scope or scope['method'] in ('GET', 'HEAD', 'OPTIONS'): return await self.app(scope, receive, send) async def sanitize_request_body(): message = await receive() - body = message.get("body") + body = message.get('body') if not body or not isinstance(body, bytes): return message if is_json(body): @@ -49,7 +43,7 @@ async def sanitize_request_body(): json_body[key] = __class__.sanitize_list(value) else: json_body[key] = __class__.sanitize_str(value) - message["body"] = bytes(json.dumps(json_body), encoding="utf-8") + message['body'] = bytes(json.dumps(json_body), encoding='utf-8') return message diff --git a/backend/src/appointment/middleware/l10n.py b/backend/src/appointment/middleware/l10n.py index 0053b87f9..b8d3302a1 100644 --- a/backend/src/appointment/middleware/l10n.py +++ b/backend/src/appointment/middleware/l10n.py @@ -1,5 +1,3 @@ -import os - from starlette_context.plugins import Plugin from fastapi import Request from fluent.runtime import FluentLocalization, FluentResourceLoader @@ -9,6 +7,7 @@ class L10n(Plugin): """Provides fluent's format_value function via context['l10n']""" + key = 'l10n' def parse_accept_language(self, accept_language_header): @@ -38,14 +37,12 @@ def get_fluent(self, accept_languages): if FALLBACK_LOCALE not in supported_locales: supported_locales.append(FALLBACK_LOCALE) - base_url = "src/appointment/l10n" + base_url = 'src/appointment/l10n' - loader = FluentResourceLoader(f"{base_url}/{{locale}}") - fluent = FluentLocalization(supported_locales, ["main.ftl", "email.ftl"], loader) + 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 - ): + async def process_request(self, request: Request): return self.get_fluent(request.headers.get('accept-language', FALLBACK_LOCALE)) diff --git a/backend/src/appointment/migrations/env.py b/backend/src/appointment/migrations/env.py index 83b69abb4..9f7d6270a 100644 --- a/backend/src/appointment/migrations/env.py +++ b/backend/src/appointment/migrations/env.py @@ -7,6 +7,7 @@ from alembic import context from appointment.defines import APP_ENV_DEV + # This is ran from src/ so ignore the errors from appointment.secrets import normalize_secrets @@ -36,14 +37,14 @@ # ... etc. # Catch any errors that may run during migrations -if os.getenv("SENTRY_DSN") != "" or os.getenv("SENTRY_DSN") is not None: +if os.getenv('SENTRY_DSN') != '' or os.getenv('SENTRY_DSN') is not None: sentry_sdk.init( - dsn=os.getenv("SENTRY_DSN"), + dsn=os.getenv('SENTRY_DSN'), # Set traces_sample_rate to 1.0 to capture 100% # of transactions for performance monitoring. # We recommend adjusting this value in production, traces_sample_rate=1.0, - environment=os.getenv("APP_ENV", APP_ENV_DEV), + environment=os.getenv('APP_ENV', APP_ENV_DEV), ) @@ -59,12 +60,12 @@ def run_migrations_offline() -> None: script output. """ - url = os.getenv("DATABASE_URL") or config.get_main_option("sqlalchemy.url") + url = os.getenv('DATABASE_URL') or config.get_main_option('sqlalchemy.url') context.configure( url=url, target_metadata=target_metadata, literal_binds=True, - dialect_opts={"paramstyle": "named"}, + dialect_opts={'paramstyle': 'named'}, ) with context.begin_transaction(): @@ -79,12 +80,12 @@ def run_migrations_online() -> None: """ # If we have our database url env variable set, use that instead! - if os.getenv("DATABASE_URL"): - config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL")) + if os.getenv('DATABASE_URL'): + config.set_main_option('sqlalchemy.url', os.getenv('DATABASE_URL')) connectable = engine_from_config( config.get_section(config.config_ini_section), - prefix="sqlalchemy.", + prefix='sqlalchemy.', poolclass=pool.NullPool, ) diff --git a/backend/src/appointment/migrations/versions/2023_04_05_1703-eb50007f7a21_change_subscriber_table.py b/backend/src/appointment/migrations/versions/2023_04_05_1703-eb50007f7a21_change_subscriber_table.py index a537ae688..3f1b306da 100644 --- a/backend/src/appointment/migrations/versions/2023_04_05_1703-eb50007f7a21_change_subscriber_table.py +++ b/backend/src/appointment/migrations/versions/2023_04_05_1703-eb50007f7a21_change_subscriber_table.py @@ -1,10 +1,11 @@ """change subscriber table Revision ID: eb50007f7a21 -Revises: +Revises: Create Date: 2023-04-05 17:03:56.183728 """ + import os from alembic import op import sqlalchemy as sa @@ -13,11 +14,11 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. -revision = "9614c3875c5e" +revision = '9614c3875c5e' down_revision = None branch_labels = None depends_on = None @@ -25,12 +26,12 @@ def secret(): def upgrade() -> None: op.alter_column( - "subscribers", - "timezone", - type_=StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), + 'subscribers', + 'timezone', + type_=StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=True, ) def downgrade() -> None: - op.alter_column("subscribers", "timezone", type_=sa.Integer, index=True) + op.alter_column('subscribers', 'timezone', type_=sa.Integer, index=True) diff --git a/backend/src/appointment/migrations/versions/2023_04_25_1303-5aec90d60d85_calendar_provider.py b/backend/src/appointment/migrations/versions/2023_04_25_1303-5aec90d60d85_calendar_provider.py index aa6260baf..72bbe3dad 100644 --- a/backend/src/appointment/migrations/versions/2023_04_25_1303-5aec90d60d85_calendar_provider.py +++ b/backend/src/appointment/migrations/versions/2023_04_25_1303-5aec90d60d85_calendar_provider.py @@ -5,24 +5,25 @@ Create Date: 2023-04-25 13:03:01.556282 """ + from alembic import op import sqlalchemy as sa from database.models import CalendarProvider # revision identifiers, used by Alembic. -revision = "5aec90d60d85" -down_revision = "9614c3875c5e" +revision = '5aec90d60d85' +down_revision = '9614c3875c5e' branch_labels = None depends_on = None def upgrade() -> None: op.add_column( - "calendars", - sa.Column("provider", sa.Enum(CalendarProvider), default=CalendarProvider.caldav), + 'calendars', + sa.Column('provider', sa.Enum(CalendarProvider), default=CalendarProvider.caldav), ) def downgrade() -> None: - op.drop_column("calendars", "provider") + op.drop_column('calendars', 'provider') diff --git a/backend/src/appointment/migrations/versions/2023_04_26_1452-d9ecfcaf83a6_add_google_token.py b/backend/src/appointment/migrations/versions/2023_04_26_1452-d9ecfcaf83a6_add_google_token.py index 60ccc21a6..6d0a05848 100644 --- a/backend/src/appointment/migrations/versions/2023_04_26_1452-d9ecfcaf83a6_add_google_token.py +++ b/backend/src/appointment/migrations/versions/2023_04_26_1452-d9ecfcaf83a6_add_google_token.py @@ -5,6 +5,7 @@ Create Date: 2023-04-26 14:52:25.425491 """ + import os from alembic import op import sqlalchemy as sa @@ -13,26 +14,26 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. -revision = "d9ecfcaf83a6" -down_revision = "5aec90d60d85" +revision = 'd9ecfcaf83a6' +down_revision = '5aec90d60d85' branch_labels = None depends_on = None def upgrade() -> None: op.add_column( - "subscribers", + 'subscribers', sa.Column( - "google_tkn", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), + 'google_tkn', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048), index=False, ), ) def downgrade() -> None: - op.drop_column("subscribers", "google_tkn") + op.drop_column('subscribers', 'google_tkn') diff --git a/backend/src/appointment/migrations/versions/2023_04_27_1633-81ace90a911b_add_google_state_id.py b/backend/src/appointment/migrations/versions/2023_04_27_1633-81ace90a911b_add_google_state_id.py index 3293533e8..2ba56b8b6 100644 --- a/backend/src/appointment/migrations/versions/2023_04_27_1633-81ace90a911b_add_google_state_id.py +++ b/backend/src/appointment/migrations/versions/2023_04_27_1633-81ace90a911b_add_google_state_id.py @@ -5,6 +5,7 @@ Create Date: 2023-04-27 16:33:15.095853 """ + import os from alembic import op @@ -15,28 +16,28 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. -revision = "81ace90a911b" -down_revision = "d9ecfcaf83a6" +revision = '81ace90a911b' +down_revision = 'd9ecfcaf83a6' branch_labels = None depends_on = None def upgrade() -> None: op.add_column( - "subscribers", + 'subscribers', sa.Column( - "google_state", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), + 'google_state', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048), index=False, ), ) - op.add_column("subscribers", sa.Column("google_state_expires_at", DateTime())) + op.add_column('subscribers', sa.Column('google_state_expires_at', DateTime())) def downgrade() -> None: - op.drop_column("subscribers", "google_state") - op.drop_column("subscribers", "google_state_expires_at") + op.drop_column('subscribers', 'google_state') + op.drop_column('subscribers', 'google_state_expires_at') diff --git a/backend/src/appointment/migrations/versions/2023_06_08_2201-da069f44bca7_add_calendar_connected.py b/backend/src/appointment/migrations/versions/2023_06_08_2201-da069f44bca7_add_calendar_connected.py index 96b462730..b059389c9 100644 --- a/backend/src/appointment/migrations/versions/2023_06_08_2201-da069f44bca7_add_calendar_connected.py +++ b/backend/src/appointment/migrations/versions/2023_06_08_2201-da069f44bca7_add_calendar_connected.py @@ -5,30 +5,31 @@ Create Date: 2023-06-08 22:01:31.788967 """ + from alembic import op import sqlalchemy as sa from sqlalchemy import DateTime, false # revision identifiers, used by Alembic. -revision = "da069f44bca7" -down_revision = "81ace90a911b" +revision = 'da069f44bca7' +down_revision = '81ace90a911b' branch_labels = None depends_on = None def upgrade() -> None: op.add_column( - "calendars", + 'calendars', sa.Column( - "connected", + 'connected', sa.Boolean, index=True, server_default=false(), ), ) - op.add_column("calendars", sa.Column("connected_at", DateTime())) + op.add_column('calendars', sa.Column('connected_at', DateTime())) def downgrade() -> None: - op.drop_column("calendars", "connected") - op.drop_column("calendars", "connected_at") + op.drop_column('calendars', 'connected') + op.drop_column('calendars', 'connected_at') diff --git a/backend/src/appointment/migrations/versions/2023_06_20_2216-845089644770_add_short_link_hash.py b/backend/src/appointment/migrations/versions/2023_06_20_2216-845089644770_add_short_link_hash.py index dc37d0d72..3fad53a8c 100644 --- a/backend/src/appointment/migrations/versions/2023_06_20_2216-845089644770_add_short_link_hash.py +++ b/backend/src/appointment/migrations/versions/2023_06_20_2216-845089644770_add_short_link_hash.py @@ -5,6 +5,7 @@ Create Date: 2023-06-20 22:16:54.576754 """ + import os from alembic import op @@ -14,26 +15,26 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. -revision = "845089644770" -down_revision = "da069f44bca7" +revision = '845089644770' +down_revision = 'da069f44bca7' branch_labels = None depends_on = None def upgrade() -> None: op.add_column( - "subscribers", + 'subscribers', sa.Column( - "short_link_hash", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), + 'short_link_hash', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048), index=False, ), ) def downgrade() -> None: - op.drop_column("subscribers", "short_link_hash") + op.drop_column('subscribers', 'short_link_hash') diff --git a/backend/src/appointment/migrations/versions/2023_06_27_1108-f9660871710e_add_general_availability_tables.py b/backend/src/appointment/migrations/versions/2023_06_27_1108-f9660871710e_add_general_availability_tables.py index 287ea5975..f431f8fff 100644 --- a/backend/src/appointment/migrations/versions/2023_06_27_1108-f9660871710e_add_general_availability_tables.py +++ b/backend/src/appointment/migrations/versions/2023_06_27_1108-f9660871710e_add_general_availability_tables.py @@ -5,6 +5,7 @@ Create Date: 2023-06-27 11:08:39.853063 """ + import os from alembic import op import sqlalchemy as sa @@ -16,58 +17,58 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. -revision = "f9660871710e" -down_revision = "845089644770" +revision = 'f9660871710e' +down_revision = '845089644770' branch_labels = None depends_on = None def upgrade() -> None: op.create_table( - "schedules", - sa.Column("id", sa.Integer, primary_key=True), - sa.Column("appointment_id", sa.Integer), + 'schedules', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('appointment_id', sa.Integer), sa.Column( - "name", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), + 'name', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=False, ), - sa.Column("time_created", DateTime()), - sa.Column("time_updated", DateTime()), + sa.Column('time_created', DateTime()), + sa.Column('time_updated', DateTime()), ) op.create_table( - "availabilities", - sa.Column("id", sa.Integer, primary_key=True), - sa.Column("schedule_id", sa.Integer), + 'availabilities', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('schedule_id', sa.Integer), sa.Column( - "day_of_week", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), + 'day_of_week', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=False, ), sa.Column( - "start_time", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), + 'start_time', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), ), sa.Column( - "end_time", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), + 'end_time', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=False, ), sa.Column( - "min_time_before_meeting", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), + 'min_time_before_meeting', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=False, ), - sa.Column("slot_duration", sa.Integer), - sa.Column("time_created", DateTime()), - sa.Column("time_updated", DateTime()), + sa.Column('slot_duration', sa.Integer), + sa.Column('time_created', DateTime()), + sa.Column('time_updated', DateTime()), ) def downgrade() -> None: - op.drop_table("schedules") - op.drop_table("availabilities") + op.drop_table('schedules') + op.drop_table('availabilities') diff --git a/backend/src/appointment/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py b/backend/src/appointment/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py index de9c3ecc0..b25bbea71 100644 --- a/backend/src/appointment/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py +++ b/backend/src/appointment/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py @@ -5,6 +5,7 @@ Create Date: 2023-07-27 11:02:39.900134 """ + import os from alembic import op import sqlalchemy as sa @@ -14,46 +15,46 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. -revision = "f9c5471478d0" -down_revision = "f9660871710e" +revision = 'f9c5471478d0' +down_revision = 'f9660871710e' branch_labels = None depends_on = None def upgrade() -> None: - op.drop_column("schedules", "appointment_id") - op.add_column("schedules", sa.Column("calendar_id", sa.Integer, sa.ForeignKey("calendars.id"))) - op.add_column("schedules", sa.Column("location_type", sa.Enum(LocationType), default=LocationType.online)) + op.drop_column('schedules', 'appointment_id') + op.add_column('schedules', sa.Column('calendar_id', sa.Integer, sa.ForeignKey('calendars.id'))) + op.add_column('schedules', sa.Column('location_type', sa.Enum(LocationType), default=LocationType.online)) op.add_column( - "schedules", sa.Column("location_url", StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048)) + 'schedules', sa.Column('location_url', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048)) ) op.add_column( - "schedules", sa.Column("details", StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255)) + 'schedules', sa.Column('details', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255)) ) op.add_column( - "schedules", - sa.Column("start_date", StringEncryptedType(sa.Date, secret, AesEngine, "pkcs5", length=255), index=True), + 'schedules', + sa.Column('start_date', StringEncryptedType(sa.Date, secret, AesEngine, 'pkcs5', length=255), index=True), ) op.add_column( - "schedules", - sa.Column("end_date", StringEncryptedType(sa.Date, secret, AesEngine, "pkcs5", length=255), index=True), + 'schedules', + sa.Column('end_date', StringEncryptedType(sa.Date, secret, AesEngine, 'pkcs5', length=255), index=True), ) op.add_column( - "schedules", - sa.Column("start_time", StringEncryptedType(sa.Time, secret, AesEngine, "pkcs5", length=255), index=True), + 'schedules', + sa.Column('start_time', StringEncryptedType(sa.Time, secret, AesEngine, 'pkcs5', length=255), index=True), ) op.add_column( - "schedules", - sa.Column("end_time", StringEncryptedType(sa.Time, secret, AesEngine, "pkcs5", length=255), index=True), + 'schedules', + sa.Column('end_time', StringEncryptedType(sa.Time, secret, AesEngine, 'pkcs5', length=255), index=True), ) - op.add_column("schedules", sa.Column("earliest_booking", sa.Integer, default=1440)) - op.add_column("schedules", sa.Column("farthest_booking", sa.Integer, default=20160)) - op.add_column("schedules", sa.Column("weekdays", sa.JSON)) - op.add_column("schedules", sa.Column("slot_duration", sa.Integer, default=30)) + op.add_column('schedules', sa.Column('earliest_booking', sa.Integer, default=1440)) + op.add_column('schedules', sa.Column('farthest_booking', sa.Integer, default=20160)) + op.add_column('schedules', sa.Column('weekdays', sa.JSON)) + op.add_column('schedules', sa.Column('slot_duration', sa.Integer, default=30)) def downgrade() -> None: diff --git a/backend/src/appointment/migrations/versions/2023_09_22_1157-3789c9fd57c5_extend_schedules_table.py b/backend/src/appointment/migrations/versions/2023_09_22_1157-3789c9fd57c5_extend_schedules_table.py index 653673f7a..5ed3c8e54 100644 --- a/backend/src/appointment/migrations/versions/2023_09_22_1157-3789c9fd57c5_extend_schedules_table.py +++ b/backend/src/appointment/migrations/versions/2023_09_22_1157-3789c9fd57c5_extend_schedules_table.py @@ -5,6 +5,7 @@ Create Date: 2023-09-22 11:57:49.222824 """ + from alembic import op import sqlalchemy as sa @@ -17,8 +18,8 @@ def upgrade() -> None: - op.add_column("schedules", sa.Column("active", sa.Boolean, index=True, default=True)) + op.add_column('schedules', sa.Column('active', sa.Boolean, index=True, default=True)) def downgrade() -> None: - op.drop_column("schedules", "active") + op.drop_column('schedules', 'active') diff --git a/backend/src/appointment/migrations/versions/2023_10_19_1535-2b1d96fb4058_extend_slots_table_for_.py b/backend/src/appointment/migrations/versions/2023_10_19_1535-2b1d96fb4058_extend_slots_table_for_.py index 6855476cd..88b3ec320 100644 --- a/backend/src/appointment/migrations/versions/2023_10_19_1535-2b1d96fb4058_extend_slots_table_for_.py +++ b/backend/src/appointment/migrations/versions/2023_10_19_1535-2b1d96fb4058_extend_slots_table_for_.py @@ -5,6 +5,7 @@ Create Date: 2023-10-19 15:35:17.671137 """ + import os from alembic import op import sqlalchemy as sa @@ -15,7 +16,7 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. @@ -26,18 +27,18 @@ def secret(): def upgrade() -> None: - op.add_column("slots", sa.Column("schedule_id", sa.Integer, sa.ForeignKey("schedules.id"))) + op.add_column('slots', sa.Column('schedule_id', sa.Integer, sa.ForeignKey('schedules.id'))) op.add_column( - "slots", - sa.Column("booking_tkn", StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=512), index=False) + 'slots', + sa.Column('booking_tkn', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=512), index=False), ) - op.add_column("slots", sa.Column("booking_expires_at", DateTime())) - op.add_column("slots", sa.Column("booking_status", sa.Enum(BookingStatus), default=BookingStatus.none)) + op.add_column('slots', sa.Column('booking_expires_at', DateTime())) + op.add_column('slots', sa.Column('booking_status', sa.Enum(BookingStatus), default=BookingStatus.none)) def downgrade() -> None: - op.drop_constraint("slots_ibfk_4", "slots", type_='foreignkey') - op.drop_column("slots", "schedule_id") - op.drop_column("slots", "booking_tkn") - op.drop_column("slots", "booking_expires_at") - op.drop_column("slots", "booking_status") + op.drop_constraint('slots_ibfk_4', 'slots', type_='foreignkey') + op.drop_column('slots', 'schedule_id') + op.drop_column('slots', 'booking_tkn') + op.drop_column('slots', 'booking_expires_at') + op.drop_column('slots', 'booking_status') diff --git a/backend/src/appointment/migrations/versions/2023_11_02_2121-9a96baa7ecd5_create_external_connections_.py b/backend/src/appointment/migrations/versions/2023_11_02_2121-9a96baa7ecd5_create_external_connections_.py index d0be44707..aaab2c5c9 100644 --- a/backend/src/appointment/migrations/versions/2023_11_02_2121-9a96baa7ecd5_create_external_connections_.py +++ b/backend/src/appointment/migrations/versions/2023_11_02_2121-9a96baa7ecd5_create_external_connections_.py @@ -5,6 +5,7 @@ Create Date: 2023-11-02 21:21:24.792951 """ + import os from alembic import op @@ -16,7 +17,7 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. @@ -27,22 +28,17 @@ def secret(): def upgrade() -> None: - op.create_table('external_connections', - sa.Column('id', sa.Integer, primary_key=True, index=True), - sa.Column('owner_id', sa.Integer, ForeignKey("subscribers.id")), - sa.Column('name', - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), - index=False), - sa.Column('type', sa.Enum(ExternalConnectionType), index=True), - sa.Column('type_id', - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), - index=True), - sa.Column('token', - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), - index=False), - sa.Column('time_created', sa.DateTime, server_default=func.now()), - sa.Column('time_updated', sa.DateTime, server_default=func.now()), - ) + op.create_table( + 'external_connections', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('owner_id', sa.Integer, ForeignKey('subscribers.id')), + sa.Column('name', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=False), + sa.Column('type', sa.Enum(ExternalConnectionType), index=True), + sa.Column('type_id', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=True), + sa.Column('token', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048), index=False), + sa.Column('time_created', sa.DateTime, server_default=func.now()), + sa.Column('time_updated', sa.DateTime, server_default=func.now()), + ) def downgrade() -> None: diff --git a/backend/src/appointment/migrations/versions/2023_11_13_2225-d0c36eef5da9_add_meeting_link_provider_to_.py b/backend/src/appointment/migrations/versions/2023_11_13_2225-d0c36eef5da9_add_meeting_link_provider_to_.py index c086d9103..a5e713bdd 100644 --- a/backend/src/appointment/migrations/versions/2023_11_13_2225-d0c36eef5da9_add_meeting_link_provider_to_.py +++ b/backend/src/appointment/migrations/versions/2023_11_13_2225-d0c36eef5da9_add_meeting_link_provider_to_.py @@ -5,6 +5,7 @@ Create Date: 2023-11-13 22:25:05.485397 """ + import os from alembic import op @@ -17,7 +18,8 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') + # revision identifiers, used by Alembic. revision = 'd0c36eef5da9' @@ -27,7 +29,15 @@ def secret(): def upgrade() -> None: - op.add_column('appointments', sa.Column("meeting_link_provider", StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), index=False)) + op.add_column( + 'appointments', + sa.Column( + 'meeting_link_provider', + StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, 'pkcs5', length=255), + index=False, + ), + ) + def downgrade() -> None: op.drop_column('appointments', 'meeting_link_provider') diff --git a/backend/src/appointment/migrations/versions/2023_11_14_1907-6da5d26beef0_add_meeting_link_provider_to_.py b/backend/src/appointment/migrations/versions/2023_11_14_1907-6da5d26beef0_add_meeting_link_provider_to_.py index f35d81c46..f46e7705d 100644 --- a/backend/src/appointment/migrations/versions/2023_11_14_1907-6da5d26beef0_add_meeting_link_provider_to_.py +++ b/backend/src/appointment/migrations/versions/2023_11_14_1907-6da5d26beef0_add_meeting_link_provider_to_.py @@ -5,6 +5,7 @@ Create Date: 2023-11-14 19:07:56.496112 """ + import os from alembic import op import sqlalchemy as sa @@ -17,7 +18,8 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') + # revision identifiers, used by Alembic. revision = '6da5d26beef0' @@ -27,7 +29,14 @@ def secret(): def upgrade() -> None: - op.add_column('schedules', sa.Column("meeting_link_provider", StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), index=False)) + op.add_column( + 'schedules', + sa.Column( + 'meeting_link_provider', + StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, 'pkcs5', length=255), + index=False, + ), + ) def downgrade() -> None: diff --git a/backend/src/appointment/migrations/versions/2023_11_14_2255-7e426358642e_.py b/backend/src/appointment/migrations/versions/2023_11_14_2255-7e426358642e_.py index 5e0ea639d..b1cf1d159 100644 --- a/backend/src/appointment/migrations/versions/2023_11_14_2255-7e426358642e_.py +++ b/backend/src/appointment/migrations/versions/2023_11_14_2255-7e426358642e_.py @@ -5,6 +5,7 @@ Create Date: 2023-11-14 22:55:24.387190 """ + # revision identifiers, used by Alembic. revision = '7e426358642e' down_revision = ('2b1d96fb4058', '6da5d26beef0') diff --git a/backend/src/appointment/migrations/versions/2023_11_15_2052-14c33a37c43c_add_meeting_link_id_to_slots_.py b/backend/src/appointment/migrations/versions/2023_11_15_2052-14c33a37c43c_add_meeting_link_id_to_slots_.py index fb56d7b0c..4cbd24440 100644 --- a/backend/src/appointment/migrations/versions/2023_11_15_2052-14c33a37c43c_add_meeting_link_id_to_slots_.py +++ b/backend/src/appointment/migrations/versions/2023_11_15_2052-14c33a37c43c_add_meeting_link_id_to_slots_.py @@ -5,6 +5,7 @@ Create Date: 2023-11-15 20:52:50.545477 """ + import os from alembic import op import sqlalchemy as sa @@ -13,7 +14,8 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') + # revision identifiers, used by Alembic. revision = '14c33a37c43c' @@ -23,9 +25,19 @@ def secret(): def upgrade() -> None: - op.add_column('slots', sa.Column('meeting_link_id', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=1024), index=False)) + op.add_column( + 'slots', + sa.Column( + 'meeting_link_id', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=1024), index=False + ), + ) # A location_url override for generated meeting link urls - op.add_column('slots', sa.Column('meeting_link_url', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), index=False)) + op.add_column( + 'slots', + sa.Column( + 'meeting_link_url', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048), index=False + ), + ) def downgrade() -> None: diff --git a/backend/src/appointment/migrations/versions/2023_12_04_1807-c4a5f0df612c_add_password_to_subscriber_table.py b/backend/src/appointment/migrations/versions/2023_12_04_1807-c4a5f0df612c_add_password_to_subscriber_table.py index 78b0ca5c2..c3d4e86c5 100644 --- a/backend/src/appointment/migrations/versions/2023_12_04_1807-c4a5f0df612c_add_password_to_subscriber_table.py +++ b/backend/src/appointment/migrations/versions/2023_12_04_1807-c4a5f0df612c_add_password_to_subscriber_table.py @@ -5,6 +5,7 @@ Create Date: 2023-12-04 18:07:44.775739 """ + import os from alembic import op import sqlalchemy as sa @@ -13,7 +14,7 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. @@ -24,9 +25,11 @@ def secret(): def upgrade() -> None: - op.add_column('subscribers', sa.Column('password', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), index=False)) + op.add_column( + 'subscribers', + sa.Column('password', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=False), + ) def downgrade() -> None: op.drop_column('subscribers', 'password') - diff --git a/backend/src/appointment/migrations/versions/2023_12_05_0027-7f8b4f463f1d_update_external_connections_.py b/backend/src/appointment/migrations/versions/2023_12_05_0027-7f8b4f463f1d_update_external_connections_.py index 77c149784..653eb567b 100644 --- a/backend/src/appointment/migrations/versions/2023_12_05_0027-7f8b4f463f1d_update_external_connections_.py +++ b/backend/src/appointment/migrations/versions/2023_12_05_0027-7f8b4f463f1d_update_external_connections_.py @@ -5,8 +5,8 @@ Create Date: 2023-12-05 00:27:08.011155 """ + from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '7f8b4f463f1d' @@ -19,8 +19,12 @@ def upgrade() -> None: - op.execute(f"ALTER TABLE `external_connections` MODIFY COLUMN `type` enum({new_external_connections}) NOT NULL AFTER `name`;") + op.execute( + f'ALTER TABLE `external_connections` MODIFY COLUMN `type` enum({new_external_connections}) NOT NULL AFTER `name`;' # noqa: E501 + ) def downgrade() -> None: - op.execute(f"ALTER TABLE `external_connections` MODIFY COLUMN `type` enum({old_external_connections}) NOT NULL AFTER `name`;") + op.execute( + f'ALTER TABLE `external_connections` MODIFY COLUMN `type` enum({old_external_connections}) NOT NULL AFTER `name`;' # noqa: E501 + ) diff --git a/backend/src/appointment/migrations/versions/2023_12_05_1734-0dc429ca07f5_add_avatar_url_to_subscribers_.py b/backend/src/appointment/migrations/versions/2023_12_05_1734-0dc429ca07f5_add_avatar_url_to_subscribers_.py index 14c2e0aeb..d10330e2d 100644 --- a/backend/src/appointment/migrations/versions/2023_12_05_1734-0dc429ca07f5_add_avatar_url_to_subscribers_.py +++ b/backend/src/appointment/migrations/versions/2023_12_05_1734-0dc429ca07f5_add_avatar_url_to_subscribers_.py @@ -5,6 +5,7 @@ Create Date: 2023-12-05 17:34:38.294266 """ + import os from alembic import op @@ -14,7 +15,7 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. @@ -24,9 +25,11 @@ def secret(): depends_on = None - def upgrade() -> None: - op.add_column('subscribers', sa.Column('avatar_url', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), index=False)) + op.add_column( + 'subscribers', + sa.Column('avatar_url', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048), index=False), + ) def downgrade() -> None: diff --git a/backend/src/appointment/migrations/versions/2024_01_09_1652-ad7cc2de5ff8_add_minimum_valid_iat_time_to_subscribers.py b/backend/src/appointment/migrations/versions/2024_01_09_1652-ad7cc2de5ff8_add_minimum_valid_iat_time_to_subscribers.py index 38f14fd19..5b99d4b94 100644 --- a/backend/src/appointment/migrations/versions/2024_01_09_1652-ad7cc2de5ff8_add_minimum_valid_iat_time_to_subscribers.py +++ b/backend/src/appointment/migrations/versions/2024_01_09_1652-ad7cc2de5ff8_add_minimum_valid_iat_time_to_subscribers.py @@ -5,6 +5,7 @@ Create Date: 2024-01-09 16:52:20.941572 """ + import os from alembic import op import sqlalchemy as sa @@ -13,7 +14,7 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. @@ -24,7 +25,10 @@ def secret(): def upgrade() -> None: - op.add_column('subscribers', sa.Column('minimum_valid_iat_time', StringEncryptedType(sa.DateTime, secret, AesEngine, "pkcs5", length=255))) + op.add_column( + 'subscribers', + sa.Column('minimum_valid_iat_time', StringEncryptedType(sa.DateTime, secret, AesEngine, 'pkcs5', length=255)), + ) def downgrade() -> None: diff --git a/backend/src/appointment/migrations/versions/2024_01_10_2259-502c76bc79e0_add_time_created_and_time_.py b/backend/src/appointment/migrations/versions/2024_01_10_2259-502c76bc79e0_add_time_created_and_time_.py index b5e1672fd..f7aba4619 100644 --- a/backend/src/appointment/migrations/versions/2024_01_10_2259-502c76bc79e0_add_time_created_and_time_.py +++ b/backend/src/appointment/migrations/versions/2024_01_10_2259-502c76bc79e0_add_time_created_and_time_.py @@ -5,6 +5,7 @@ Create Date: 2024-01-10 22:59:02.194281 """ + from alembic import op import sqlalchemy as sa from sqlalchemy import func @@ -16,7 +17,14 @@ depends_on = None affected_tables = ['attendees', 'calendars', 'slots', 'subscribers'] -index_tables = ['appointments', 'availabilities', 'external_connections', 'schedules', 'slots', ] +index_tables = [ + 'appointments', + 'availabilities', + 'external_connections', + 'schedules', + 'slots', +] + def upgrade() -> None: for table in affected_tables: diff --git a/backend/src/appointment/migrations/versions/2024_01_12_1801-ea551afc14fc_merge_commit.py b/backend/src/appointment/migrations/versions/2024_01_12_1801-ea551afc14fc_merge_commit.py index c8030eb49..5366bd428 100644 --- a/backend/src/appointment/migrations/versions/2024_01_12_1801-ea551afc14fc_merge_commit.py +++ b/backend/src/appointment/migrations/versions/2024_01_12_1801-ea551afc14fc_merge_commit.py @@ -5,9 +5,6 @@ Create Date: 2024-01-12 18:01:38.962773 """ -from alembic import op -import sqlalchemy as sa - # revision identifiers, used by Alembic. revision = 'ea551afc14fc' diff --git a/backend/src/appointment/migrations/versions/2024_03_13_1621-f92bae6c27da_update_subscribers_table_to_.py b/backend/src/appointment/migrations/versions/2024_03_13_1621-f92bae6c27da_update_subscribers_table_to_.py index 73d2425c3..14234e1e2 100644 --- a/backend/src/appointment/migrations/versions/2024_03_13_1621-f92bae6c27da_update_subscribers_table_to_.py +++ b/backend/src/appointment/migrations/versions/2024_03_13_1621-f92bae6c27da_update_subscribers_table_to_.py @@ -5,6 +5,7 @@ Create Date: 2024-03-13 16:21:54.415458 """ + import os from alembic import op @@ -21,7 +22,7 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') def upgrade() -> None: @@ -32,19 +33,19 @@ def upgrade() -> None: def downgrade() -> None: op.add_column( - "subscribers", + 'subscribers', sa.Column( - "google_tkn", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), + 'google_tkn', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048), index=False, ), ) op.add_column( - "subscribers", + 'subscribers', sa.Column( - "google_state", - StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), + 'google_state', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=2048), index=False, ), ) - op.add_column("subscribers", sa.Column("google_state_expires_at", DateTime())) + op.add_column('subscribers', sa.Column('google_state_expires_at', DateTime())) diff --git a/backend/src/appointment/migrations/versions/2024_03_25_2221-bbdfad87a7fb_fix_null_timestamps.py b/backend/src/appointment/migrations/versions/2024_03_25_2221-bbdfad87a7fb_fix_null_timestamps.py index 00d0689d8..3e377b99a 100644 --- a/backend/src/appointment/migrations/versions/2024_03_25_2221-bbdfad87a7fb_fix_null_timestamps.py +++ b/backend/src/appointment/migrations/versions/2024_03_25_2221-bbdfad87a7fb_fix_null_timestamps.py @@ -5,6 +5,7 @@ Create Date: 2024-03-25 22:21:21.464528 """ + from alembic import op from sqlalchemy.orm import Session @@ -17,8 +18,16 @@ def upgrade() -> None: session = Session(op.get_bind()) - tables = ['appointments', 'attendees', 'availabilities', 'calendars', 'external_connections', 'schedules', 'slots', - 'subscribers'] + tables = [ + 'appointments', + 'attendees', + 'availabilities', + 'calendars', + 'external_connections', + 'schedules', + 'slots', + 'subscribers', + ] for table in tables: session.execute(f'UPDATE {table} SET time_created = NOW() WHERE time_created is NULL') diff --git a/backend/src/appointment/migrations/versions/2024_03_26_1721-e4c5a32de9fb_add_uuid_to_appointments_table.py b/backend/src/appointment/migrations/versions/2024_03_26_1721-e4c5a32de9fb_add_uuid_to_appointments_table.py index ac28303f0..b84296eae 100644 --- a/backend/src/appointment/migrations/versions/2024_03_26_1721-e4c5a32de9fb_add_uuid_to_appointments_table.py +++ b/backend/src/appointment/migrations/versions/2024_03_26_1721-e4c5a32de9fb_add_uuid_to_appointments_table.py @@ -5,11 +5,11 @@ Create Date: 2024-03-26 17:21:55.528828 """ + import uuid from alembic import op import sqlalchemy as sa -from sqlalchemy import func from sqlalchemy_utils import UUIDType # revision identifiers, used by Alembic. diff --git a/backend/src/appointment/migrations/versions/2024_03_26_1722-c5b9fc31b555_fill_uuid_in_appointments_table.py b/backend/src/appointment/migrations/versions/2024_03_26_1722-c5b9fc31b555_fill_uuid_in_appointments_table.py index e361817f7..1528bb668 100644 --- a/backend/src/appointment/migrations/versions/2024_03_26_1722-c5b9fc31b555_fill_uuid_in_appointments_table.py +++ b/backend/src/appointment/migrations/versions/2024_03_26_1722-c5b9fc31b555_fill_uuid_in_appointments_table.py @@ -5,6 +5,7 @@ Create Date: 2024-03-26 17:22:03.157695 """ + import uuid from alembic import op @@ -21,7 +22,9 @@ def upgrade() -> None: session = Session(op.get_bind()) - appointments: list[models.Appointment] = session.query(models.Appointment).where(models.Appointment.uuid.is_(None)).all() + appointments: list[models.Appointment] = ( + session.query(models.Appointment).where(models.Appointment.uuid.is_(None)).all() + ) for appointment in appointments: appointment.uuid = uuid.uuid4() session.add(appointment) diff --git a/backend/src/appointment/migrations/versions/2024_04_16_1241-fadd0d1ef438_create_invites_table.py b/backend/src/appointment/migrations/versions/2024_04_16_1241-fadd0d1ef438_create_invites_table.py index f9040b3a5..5d03ef469 100644 --- a/backend/src/appointment/migrations/versions/2024_04_16_1241-fadd0d1ef438_create_invites_table.py +++ b/backend/src/appointment/migrations/versions/2024_04_16_1241-fadd0d1ef438_create_invites_table.py @@ -5,6 +5,7 @@ Create Date: 2024-04-16 12:41:53.550102 """ + import os from alembic import op @@ -22,15 +23,15 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') def upgrade() -> None: op.create_table( 'invites', sa.Column('id', sa.Integer, primary_key=True, index=True), - sa.Column('subscriber_id', sa.Integer, ForeignKey("subscribers.id")), - sa.Column('code', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), index=False), + sa.Column('subscriber_id', sa.Integer, ForeignKey('subscribers.id')), + sa.Column('code', StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), index=False), sa.Column('status', sa.Enum(InviteStatus), index=True), sa.Column('time_created', sa.DateTime, server_default=func.now()), sa.Column('time_updated', sa.DateTime, server_default=func.now()), diff --git a/backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py b/backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py index 5d70c2a18..5fef9e46f 100644 --- a/backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py +++ b/backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py @@ -5,6 +5,7 @@ Create Date: 2024-04-18 08:23:55.660065 """ + from alembic import op import sqlalchemy as sa diff --git a/backend/src/appointment/migrations/versions/2024_05_28_1745-9fe08ba6f2ed_update_subscribers_add_.py b/backend/src/appointment/migrations/versions/2024_05_28_1745-9fe08ba6f2ed_update_subscribers_add_.py index 167f022e2..309684d07 100644 --- a/backend/src/appointment/migrations/versions/2024_05_28_1745-9fe08ba6f2ed_update_subscribers_add_.py +++ b/backend/src/appointment/migrations/versions/2024_05_28_1745-9fe08ba6f2ed_update_subscribers_add_.py @@ -5,6 +5,7 @@ Create Date: 2024-05-28 17:45:48.192560 """ + import os from alembic import op import sqlalchemy as sa @@ -13,7 +14,7 @@ def secret(): - return os.getenv("DB_SECRET") + return os.getenv('DB_SECRET') # revision identifiers, used by Alembic. @@ -24,7 +25,15 @@ def secret(): def upgrade() -> None: - op.add_column('subscribers', sa.Column('secondary_email', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), nullable=True, index=True)) + op.add_column( + 'subscribers', + sa.Column( + 'secondary_email', + StringEncryptedType(sa.String, secret, AesEngine, 'pkcs5', length=255), + nullable=True, + index=True, + ), + ) def downgrade() -> None: diff --git a/backend/src/appointment/migrations/versions/2024_05_31_1454-d5de8f10ab87_add_subscriber_soft_delete.py b/backend/src/appointment/migrations/versions/2024_05_31_1454-d5de8f10ab87_add_subscriber_soft_delete.py index 33da7c215..425bd0777 100644 --- a/backend/src/appointment/migrations/versions/2024_05_31_1454-d5de8f10ab87_add_subscriber_soft_delete.py +++ b/backend/src/appointment/migrations/versions/2024_05_31_1454-d5de8f10ab87_add_subscriber_soft_delete.py @@ -5,6 +5,7 @@ Create Date: 2024-05-31 14:54:23.772015 """ + from alembic import op import sqlalchemy as sa diff --git a/backend/src/appointment/routes/account.py b/backend/src/appointment/routes/account.py index 0665ac946..edb3a323a 100644 --- a/backend/src/appointment/routes/account.py +++ b/backend/src/appointment/routes/account.py @@ -20,7 +20,7 @@ router = APIRouter() -@router.get("/external-connections") +@router.get('/external-connections') def get_external_connections(subscriber: Subscriber = Depends(get_subscriber)): # This could be moved to a helper function in the future # Create a list of supported external connections @@ -30,24 +30,25 @@ def get_external_connections(subscriber: Subscriber = Depends(get_subscriber)): external_connections['Zoom'] = [] for ec in subscriber.external_connections: - external_connections[ec.type.name].append(schemas.ExternalConnectionOut(owner_id=ec.owner_id, type=ec.type.name, - type_id=ec.type_id, name=ec.name)) + external_connections[ec.type.name].append( + schemas.ExternalConnectionOut(owner_id=ec.owner_id, type=ec.type.name, type_id=ec.type_id, name=ec.name) + ) return external_connections -@router.get("/download") +@router.get('/download') def download_data(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Download your account data in zip format! Returns a streaming response with the zip buffer.""" zip_buffer = data.download(db, subscriber) return StreamingResponse( iter([zip_buffer.getvalue()]), - media_type="application/x-zip-compressed", - headers={"Content-Disposition": "attachment; filename=data.zip"}, + media_type='application/x-zip-compressed', + headers={'Content-Disposition': 'attachment; filename=data.zip'}, ) -@router.delete("/delete") +@router.delete('/delete') def delete_account(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Delete your account and all the data associated with it forever!""" try: @@ -56,14 +57,11 @@ def delete_account(db: Session = Depends(get_db), subscriber: Subscriber = Depen raise HTTPException(status_code=500, detail=e.message) -@router.get("/available-emails") +@router.get('/available-emails') def get_available_emails(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Return the list of emails they can use within Thunderbird Appointment""" google_connections = get_by_type(db, subscriber_id=subscriber.id, type=ExternalConnectionType.google) emails = {subscriber.email, *[connection.name for connection in google_connections]} - {subscriber.preferred_email} - return [ - subscriber.preferred_email, - *emails - ] + return [subscriber.preferred_email, *emails] diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index a3ea801c3..6eea77861 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -1,14 +1,8 @@ -import logging import os import secrets -from typing import Annotated import requests.exceptions -import validators from redis import Redis, RedisCluster -from requests import HTTPError -from sentry_sdk import capture_exception -from sqlalchemy.exc import SQLAlchemyError # database from sqlalchemy.orm import Session @@ -19,30 +13,28 @@ # authentication from ..controller.calendar import CalDavConnector, Tools, GoogleConnector -from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks, Query, Request -from datetime import timedelta, timezone +from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks, Request from ..controller.apis.google_client import GoogleClient from ..controller.auth import signed_url_by_subscriber from ..database.models import Subscriber, CalendarProvider, MeetingLinkProviderType, ExternalConnectionType from ..dependencies.google import get_google_client from ..dependencies.auth import get_subscriber from ..dependencies.database import get_db, get_redis -from ..dependencies.zoom import get_zoom_client from ..exceptions import validation from ..exceptions.validation import RemoteCalendarConnectionError, APIException from ..l10n import l10n -from ..tasks.emails import send_zoom_meeting_failed_email, send_support_email +from ..tasks.emails import send_support_email router = APIRouter() -@router.get("/") +@router.get('/') def health(): """Small route with no processing that will be used for health checks""" return l10n('health-ok') -@router.put("/me", response_model=schemas.SubscriberBase) +@router.put('/me', response_model=schemas.SubscriberBase) def update_me( data: schemas.SubscriberIn, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber) ): @@ -57,40 +49,41 @@ def update_me( preferred_email=me.preferred_email, name=me.name, level=me.level, - timezone=me.timezone + timezone=me.timezone, ) -@router.get("/me/calendars", response_model=list[schemas.CalendarOut]) +@router.get('/me/calendars', response_model=list[schemas.CalendarOut]) 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""" - calendars = repo.calendar.get_by_subscriber( - db, subscriber_id=subscriber.id, include_unconnected=not only_connected - ) + calendars = repo.calendar.get_by_subscriber(db, subscriber_id=subscriber.id, include_unconnected=not only_connected) return [schemas.CalendarOut(id=c.id, title=c.title, color=c.color, connected=c.connected) for c in calendars] -@router.get("/me/appointments", response_model=list[schemas.AppointmentWithCalendarOut]) +@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""" appointments = repo.appointment.get_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! appointments = map( - lambda x: schemas.AppointmentWithCalendarOut(**x.__dict__, slots=x.slots, calendar_title=x.calendar.title, - calendar_color=x.calendar.color), appointments) + lambda x: schemas.AppointmentWithCalendarOut( + **x.__dict__, slots=x.slots, calendar_title=x.calendar.title, calendar_color=x.calendar.color + ), + appointments, + ) return appointments -@router.get("/me/signature") +@router.get('/me/signature') def get_my_signature(subscriber: Subscriber = Depends(get_subscriber)): """Retrieve a subscriber's signed short link""" - return {"url": signed_url_by_subscriber(subscriber)} + return {'url': signed_url_by_subscriber(subscriber)} -@router.post("/me/signature") +@router.post('/me/signature') def refresh_signature(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Refresh a subscriber's signed short link""" repo.subscriber.update( @@ -104,7 +97,7 @@ def refresh_signature(db: Session = Depends(get_db), subscriber: Subscriber = De return True -@router.post("/verify/signature", deprecated=True) +@router.post('/verify/signature', deprecated=True) def verify_signature(url: str = Body(..., embed=True), db: Session = Depends(get_db)): """Verify a signed short link""" if repo.subscriber.verify_link(db, url): @@ -113,7 +106,7 @@ def verify_signature(url: str = Body(..., embed=True), db: Session = Depends(get raise validation.InvalidLinkException() -@router.post("/cal", response_model=schemas.CalendarOut) +@router.post('/cal', response_model=schemas.CalendarOut) def create_my_calendar( calendar: schemas.CalendarConnection, db: Session = Depends(get_db), @@ -124,7 +117,9 @@ def create_my_calendar( # Test the connection first if calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) + external_connection = utils.list_first( + repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google) + ) if external_connection is None or external_connection.token is None: raise RemoteCalendarConnectionError() @@ -161,7 +156,7 @@ def create_my_calendar( return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) -@router.get("/cal/{id}", response_model=schemas.CalendarConnectionOut) +@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""" cal = repo.calendar.get(db, calendar_id=id) @@ -182,7 +177,7 @@ def read_my_calendar(id: int, db: Session = Depends(get_db), subscriber: Subscri ) -@router.put("/cal/{id}", response_model=schemas.CalendarOut) +@router.put('/cal/{id}', response_model=schemas.CalendarOut) def update_my_calendar( id: int, calendar: schemas.CalendarConnection, @@ -199,8 +194,8 @@ def update_my_calendar( return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) -@router.post("/cal/{id}/connect", response_model=schemas.CalendarOut) -@router.post("/cal/{id}/disconnect", response_model=schemas.CalendarOut) +@router.post('/cal/{id}/connect', response_model=schemas.CalendarOut) +@router.post('/cal/{id}/disconnect', response_model=schemas.CalendarOut) def change_my_calendar_connection( request: Request, id: int, @@ -224,7 +219,7 @@ def change_my_calendar_connection( return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) -@router.delete("/cal/{id}", response_model=schemas.CalendarOut) +@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 repo.calendar.exists(db, calendar_id=id): @@ -236,7 +231,7 @@ def delete_my_calendar(id: int, db: Session = Depends(get_db), subscriber: Subsc return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) -@router.post("/rmt/calendars", response_model=list[schemas.CalendarConnectionOut]) +@router.post('/rmt/calendars', response_model=list[schemas.CalendarConnectionOut]) def read_remote_calendars( connection: schemas.CalendarConnection, google_client: GoogleClient = Depends(get_google_client), @@ -245,7 +240,9 @@ def read_remote_calendars( ): """endpoint to get calendars from a remote CalDAV server""" if connection.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) + external_connection = utils.list_first( + repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google) + ) if external_connection is None or external_connection.token is None: raise RemoteCalendarConnectionError() @@ -277,7 +274,7 @@ def read_remote_calendars( return calendars -@router.post("/rmt/sync") +@router.post('/rmt/sync') def sync_remote_calendars( db: Session = Depends(get_db), redis=Depends(get_redis), @@ -289,7 +286,8 @@ def sync_remote_calendars( # TODO: Also handle CalDAV connections external_connection = utils.list_first( - repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) + repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google) + ) if external_connection is None or external_connection.token is None: raise RemoteCalendarConnectionError() @@ -313,7 +311,7 @@ def sync_remote_calendars( return True -@router.get("/rmt/cal/{id}/{start}/{end}", response_model=list[schemas.Event]) +@router.get('/rmt/cal/{id}/{start}/{end}', response_model=list[schemas.Event]) def read_remote_events( id: int, start: str, @@ -330,7 +328,9 @@ def read_remote_events( raise validation.CalendarNotFoundException() if db_calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) + external_connection = utils.list_first( + repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google) + ) if external_connection is None or external_connection.token is None: raise RemoteCalendarConnectionError() @@ -365,7 +365,7 @@ def read_remote_events( return events -@router.post("/apmt", response_model=schemas.Appointment, deprecated=True) +@router.post('/apmt', response_model=schemas.Appointment, deprecated=True) def create_my_calendar_appointment( a_s: schemas.AppointmentSlots, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber) ): @@ -376,12 +376,15 @@ def create_my_calendar_appointment( raise validation.CalendarNotAuthorizedException() if not repo.calendar.is_connected(db, calendar_id=a_s.appointment.calendar_id): raise validation.CalendarNotConnectedException() - if a_s.appointment.meeting_link_provider == MeetingLinkProviderType.zoom and subscriber.get_external_connection(ExternalConnectionType.zoom) is None: + if ( + a_s.appointment.meeting_link_provider == MeetingLinkProviderType.zoom + and subscriber.get_external_connection(ExternalConnectionType.zoom) is None + ): raise validation.ZoomNotConnectedException() return repo.appointment.create(db=db, appointment=a_s.appointment, slots=a_s.slots) -@router.get("/apmt/{id}", response_model=schemas.Appointment, deprecated=True) +@router.get('/apmt/{id}', response_model=schemas.Appointment, deprecated=True) 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""" db_appointment = repo.appointment.get(db, appointment_id=id) @@ -394,7 +397,7 @@ def read_my_appointment(id: str, db: Session = Depends(get_db), subscriber: Subs return db_appointment -@router.put("/apmt/{id}", response_model=schemas.Appointment, deprecated=True) +@router.put('/apmt/{id}', response_model=schemas.Appointment, deprecated=True) def update_my_appointment( id: int, a_s: schemas.AppointmentSlots, @@ -412,7 +415,7 @@ def update_my_appointment( return repo.appointment.update(db=db, appointment=a_s.appointment, slots=a_s.slots, appointment_id=id) -@router.delete("/apmt/{id}", response_model=schemas.Appointment, deprecated=True) +@router.delete('/apmt/{id}', response_model=schemas.Appointment, deprecated=True) def delete_my_appointment(id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """endpoint to remove an appointment from db""" db_appointment = repo.appointment.get(db, appointment_id=id) @@ -425,7 +428,7 @@ def delete_my_appointment(id: int, db: Session = Depends(get_db), subscriber: Su return repo.appointment.delete(db=db, appointment_id=id) -@router.get("/apmt/public/{slug}", response_model=schemas.AppointmentOut, deprecated=True) +@router.get('/apmt/public/{slug}', response_model=schemas.AppointmentOut, deprecated=True) 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.appointment.get_public(db, slug=slug) @@ -438,11 +441,17 @@ def read_public_appointment(slug: str, db: Session = Depends(get_db)): schemas.SlotOut(id=sl.id, start=sl.start, duration=sl.duration, attendee_id=sl.attendee_id) for sl in a.slots ] return schemas.AppointmentOut( - id=a.id, title=a.title, details=a.details, slug=a.slug, owner_name=s.name, slots=slots, slot_duration=slots[0].duration if len(slots) > 0 else 0 + id=a.id, + title=a.title, + details=a.details, + slug=a.slug, + owner_name=s.name, + slots=slots, + slot_duration=slots[0].duration if len(slots) > 0 else 0, ) -@router.get("/apmt/serve/ics/{slug}/{slot_id}", response_model=schemas.FileDownload) +@router.get('/apmt/serve/ics/{slug}/{slot_id}', response_model=schemas.FileDownload) def public_appointment_serve_ics(slug: str, slot_id: int, db: Session = Depends(get_db)): """endpoint to serve ICS file for time slot to download""" db_appointment = repo.appointment.get_public(db, slug=slug) @@ -459,20 +468,20 @@ def public_appointment_serve_ics(slug: str, slot_id: int, db: Session = Depends( organizer = repo.subscriber.get_by_appointment(db=db, appointment_id=db_appointment.id) return schemas.FileDownload( - name="invite", - content_type="text/calendar", - data=Tools().create_vevent(appointment=db_appointment, slot=slot, organizer=organizer).decode("utf-8"), + name='invite', + content_type='text/calendar', + data=Tools().create_vevent(appointment=db_appointment, slot=slot, organizer=organizer).decode('utf-8'), ) -@router.post("/support") +@router.post('/support') def send_feedback( form_data: schemas.SupportRequest, background_tasks: BackgroundTasks, - subscriber: Subscriber = Depends(get_subscriber) + subscriber: Subscriber = Depends(get_subscriber), ): """Send a subscriber's support request to the configured support email address""" - if not os.getenv("SUPPORT_EMAIL"): + if not os.getenv('SUPPORT_EMAIL'): raise APIException() background_tasks.add_task( diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index a319b5db7..524f95142 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -1,6 +1,5 @@ import json import os -import time from datetime import timedelta, datetime, UTC from secrets import token_urlsafe from typing import Annotated @@ -37,21 +36,20 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): expire = datetime.now(UTC) + expires_delta else: expire = datetime.now(UTC) + timedelta(minutes=15) - to_encode.update({ - "exp": expire, - "iat": int(datetime.now(UTC).timestamp()) - }) + to_encode.update({'exp': expire, 'iat': int(datetime.now(UTC).timestamp())}) encoded_jwt = jwt.encode(to_encode, os.getenv('JWT_SECRET'), algorithm=os.getenv('JWT_ALGO')) return encoded_jwt -@router.get("/fxa_login") -def fxa_login(request: Request, - email: str, - timezone: str | None = None, - invite_code: str | None = None, - db: Session = Depends(get_db), - fxa_client: FxaClient = Depends(get_fxa_client)): +@router.get('/fxa_login') +def fxa_login( + request: Request, + email: str, + timezone: str | None = None, + invite_code: str | None = None, + db: Session = Depends(get_db), + fxa_client: FxaClient = Depends(get_fxa_client), +): """Request an authorization url from fxa""" if os.getenv('AUTH_SCHEME') != 'fxa': raise HTTPException(status_code=405) @@ -68,18 +66,16 @@ def fxa_login(request: Request, request.session['fxa_user_timezone'] = timezone request.session['fxa_user_invite_code'] = invite_code - return { - 'url': url - } + return {'url': url} -@router.get("/fxa") +@router.get('/fxa') def fxa_callback( request: Request, code: str, state: str, db: Session = Depends(get_db), - fxa_client: FxaClient = Depends(get_fxa_client) + fxa_client: FxaClient = Depends(get_fxa_client), ): """Auth callback from fxa. It's a bit of a journey: - We first ensure the state has not changed during the authentication process. @@ -95,9 +91,9 @@ def fxa_callback( raise HTTPException(status_code=405) if 'fxa_state' not in request.session or request.session['fxa_state'] != state: - raise HTTPException(400, "Invalid state.") + raise HTTPException(400, 'Invalid state.') if 'fxa_user_email' not in request.session or request.session['fxa_user_email'] == '': - raise HTTPException(400, "Email could not be retrieved.") + raise HTTPException(400, 'Email could not be retrieved.') email = request.session['fxa_user_email'] # We only use timezone during subscriber creation, or if their timezone is None @@ -139,11 +135,14 @@ def fxa_callback( if not repo.invite.code_is_available(db, invite_code): raise HTTPException(403, l10n('invite-code-not-valid')) - subscriber = repo.subscriber.create(db, schemas.SubscriberBase( - email=email, - username=email, - timezone=timezone, - )) + subscriber = repo.subscriber.create( + db, + schemas.SubscriberBase( + email=email, + username=email, + timezone=timezone, + ), + ) if not is_in_allow_list: # Use the invite code after we've created the new subscriber @@ -168,7 +167,7 @@ def fxa_callback( if any([profile['uid'] != ec.type_id for ec in fxa_connections]): # Ensure sentry captures the error too! if os.getenv('SENTRY_DSN') != '': - e = Exception("Invalid Credentials, incoming profile uid does not match existing profile uid") + e = Exception('Invalid Credentials, incoming profile uid does not match existing profile uid') capture_exception(e) raise HTTPException(403, l10n('invalid-credentials')) @@ -178,15 +177,15 @@ def fxa_callback( type=ExternalConnectionType.fxa, type_id=profile['uid'], owner_id=subscriber.id, - token=json.dumps(creds) + token=json.dumps(creds), ) if not fxa_subscriber: repo.external_connection.create(db, external_connection_schema) else: - repo.external_connection.update_token(db, json.dumps(creds), subscriber.id, - external_connection_schema.type, - external_connection_schema.type_id) + repo.external_connection.update_token( + db, json.dumps(creds), subscriber.id, external_connection_schema.type, external_connection_schema.type_id + ) # Update profile with fxa info data = schemas.SubscriberIn( @@ -194,7 +193,7 @@ def fxa_callback( name=subscriber.name, username=subscriber.username, email=profile['email'], - timezone=timezone if subscriber.timezone is None else None + timezone=timezone if subscriber.timezone is None else None, ) # If they're a new subscriber we should fill in some defaults! @@ -206,14 +205,12 @@ def fxa_callback( # Generate our jwt token, we only store the username on the token access_token_expires = timedelta(minutes=float(os.getenv('JWT_EXPIRE_IN_MINS'))) - access_token = create_access_token( - data={"sub": f"uid-{subscriber.id}"}, expires_delta=access_token_expires - ) + access_token = create_access_token(data={'sub': f'uid-{subscriber.id}'}, expires_delta=access_token_expires) return RedirectResponse(f"{os.getenv('FRONTEND_URL', 'http://localhost:8080')}/post-login/{access_token}") -@router.post("/token") +@router.post('/token') def token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Session = Depends(get_db), @@ -243,17 +240,18 @@ def token( # Generate our jwt token, we only store the username on the token access_token_expires = timedelta(minutes=float(os.getenv('JWT_EXPIRE_IN_MINS'))) - access_token = create_access_token( - data={"sub": f"uid-{subscriber.id}"}, expires_delta=access_token_expires - ) + access_token = create_access_token(data={'sub': f'uid-{subscriber.id}'}, expires_delta=access_token_expires) """Log a user in with the passed username and password information""" - return {"access_token": access_token, "token_type": "bearer"} + return {'access_token': access_token, 'token_type': 'bearer'} @router.get('/logout') -def logout(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), - fxa_client: FxaClient = Depends(get_fxa_client)): +def logout( + db: Session = Depends(get_db), + subscriber: Subscriber = Depends(get_subscriber), + fxa_client: FxaClient = Depends(get_fxa_client), +): """Logout a given subscriber session""" if os.getenv('AUTH_SCHEME') == 'fxa': @@ -265,7 +263,7 @@ def logout(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_s return True -@router.get("/me", response_model=schemas.SubscriberBase) +@router.get('/me', response_model=schemas.SubscriberBase) def me( subscriber: Subscriber = Depends(get_subscriber), ): @@ -277,11 +275,11 @@ def me( name=subscriber.name, level=subscriber.level, timezone=subscriber.timezone, - avatar_url=subscriber.avatar_url + avatar_url=subscriber.avatar_url, ) -@router.post("/permission-check") +@router.post('/permission-check') def permission_check(subscriber: Subscriber = Depends(get_admin_subscriber)): """Checks if they have admin permissions""" # This should already be covered, but just in case! diff --git a/backend/src/appointment/routes/commands.py b/backend/src/appointment/routes/commands.py index 2c87e4585..276446f51 100644 --- a/backend/src/appointment/routes/commands.py +++ b/backend/src/appointment/routes/commands.py @@ -1,4 +1,5 @@ """This file handles routing for console commands""" + from contextlib import contextmanager import os diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index 4b3fb7cf8..21f816378 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -19,7 +19,7 @@ router = APIRouter() -@router.get("/auth") +@router.get('/auth') def google_auth( request: Request, email: str | None = None, @@ -36,7 +36,7 @@ def google_auth( return url -@router.get("/callback") +@router.get('/callback') def google_callback( request: Request, code: str, @@ -74,12 +74,17 @@ def google_callback( if google_id is None: return google_callback_error(l10n('google-auth-fail')) - external_connection = repo.external_connection.get_by_type(db, subscriber.id, ExternalConnectionType.google, - google_id) + external_connection = repo.external_connection.get_by_type( + db, subscriber.id, ExternalConnectionType.google, google_id + ) # Create an artificial limit of one google account per account, mainly because we didn't plan for multiple accounts! - remainder = list(filter(lambda ec: ec.type_id != google_id, - repo.external_connection.get_by_type(db, subscriber.id, ExternalConnectionType.google))) + remainder = list( + filter( + lambda ec: ec.type_id != google_id, + repo.external_connection.get_by_type(db, subscriber.id, ExternalConnectionType.google), + ) + ) if len(remainder) > 0: return google_callback_error(l10n('google-only-one')) @@ -91,13 +96,14 @@ def google_callback( type=ExternalConnectionType.google, type_id=google_id, owner_id=subscriber.id, - token=creds.to_json() + token=creds.to_json(), ) repo.external_connection.create(db, external_connection_schema) else: - repo.external_connection.update_token(db, creds.to_json(), subscriber.id, - ExternalConnectionType.google, google_id) + repo.external_connection.update_token( + db, creds.to_json(), subscriber.id, ExternalConnectionType.google, google_id + ) error_occurred = google_client.sync_calendars(db, subscriber_id=subscriber.id, token=creds) @@ -113,7 +119,7 @@ def google_callback_error(error: str): return RedirectResponse(f"{os.getenv('FRONTEND_URL', 'http://localhost:8080')}/settings/calendar?error={error}") -@router.post("/disconnect") +@router.post('/disconnect') def disconnect_account( db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), @@ -133,6 +139,4 @@ def disconnect_account( # Remove their account details repo.external_connection.delete_by_type(db, subscriber.id, google_connection.type, google_connection.type_id) - - return True diff --git a/backend/src/appointment/routes/invite.py b/backend/src/appointment/routes/invite.py index fce7d20ee..7b176e5cd 100644 --- a/backend/src/appointment/routes/invite.py +++ b/backend/src/appointment/routes/invite.py @@ -1,7 +1,4 @@ -import time -from typing import Annotated - -from fastapi import APIRouter, Depends, BackgroundTasks, Request, Body +from fastapi import APIRouter, Depends, BackgroundTasks from sqlalchemy.orm import Session @@ -24,13 +21,13 @@ def get_all_invites(db: Session = Depends(get_db), _admin: Subscriber = Depends( return db.query(models.Invite).all() -@router.post("/generate/{n}", response_model=list[schemas.Invite]) +@router.post('/generate/{n}', response_model=list[schemas.Invite]) def generate_invite_codes(n: int, db: Session = Depends(get_db), _admin: Subscriber = Depends(get_admin_subscriber)): """endpoint to generate n invite codes, needs admin permissions""" return repo.invite.generate_codes(db, n) -@router.put("/revoke/{code}") +@router.put('/revoke/{code}') def revoke_invite_code(code: str, db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): """endpoint to revoke a given invite code and mark in unavailable, needs admin permissions""" if not repo.invite.code_exists(db, code): @@ -40,13 +37,13 @@ def revoke_invite_code(code: str, db: Session = Depends(get_db), admin: Subscrib return repo.invite.revoke_code(db, code) -@router.post("/send", response_model=schemas.Invite) +@router.post('/send', response_model=schemas.Invite) def send_invite_email( data: SendInviteEmailIn, background_tasks: BackgroundTasks, db: Session = Depends(get_db), # Note admin must be here to for permission reasons - _admin: Subscriber = Depends(get_admin_subscriber) + _admin: Subscriber = Depends(get_admin_subscriber), ): """With a given email address, generate a subscriber and email them, welcoming them to Thunderbird Appointment.""" email = data.email @@ -57,10 +54,13 @@ def send_invite_email( raise CreateSubscriberAlreadyExistsException() invite_code = repo.invite.generate_codes(db, 1)[0] - subscriber = repo.subscriber.create(db, schemas.SubscriberBase( - email=email, - username=email, - )) + subscriber = repo.subscriber.create( + db, + schemas.SubscriberBase( + email=email, + username=email, + ), + ) if not subscriber: raise CreateSubscriberFailedException() diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 4a71a0a5c..68974ba1b 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -1,5 +1,4 @@ - -from fastapi import APIRouter, Depends, Body, BackgroundTasks +from fastapi import APIRouter, Depends, BackgroundTasks import logging import os @@ -13,7 +12,14 @@ from ..controller.apis.google_client import GoogleClient from ..controller.auth import signed_url_by_subscriber from ..database import repo, schemas, models -from ..database.models import Subscriber, CalendarProvider, random_slug, BookingStatus, MeetingLinkProviderType, ExternalConnectionType +from ..database.models import ( + Subscriber, + CalendarProvider, + random_slug, + BookingStatus, + MeetingLinkProviderType, + ExternalConnectionType, +) from ..database.schemas import ExternalConnection from ..dependencies.auth import get_subscriber, get_subscriber_from_signed_url from ..dependencies.database import get_db, get_redis @@ -25,13 +31,17 @@ from ..exceptions import validation from ..exceptions.calendar import EventNotCreatedException from ..exceptions.validation import RemoteCalendarConnectionError, EventCouldNotBeAccepted -from ..tasks.emails import send_pending_email, send_confirmation_email, send_rejection_email, \ - send_zoom_meeting_failed_email +from ..tasks.emails import ( + send_pending_email, + send_confirmation_email, + send_rejection_email, + send_zoom_meeting_failed_email, +) router = APIRouter() -@router.post("/", response_model=schemas.Schedule) +@router.post('/', response_model=schemas.Schedule) def create_calendar_schedule( schedule: schemas.ScheduleBase, db: Session = Depends(get_db), @@ -47,13 +57,13 @@ def create_calendar_schedule( return repo.schedule.create(db=db, schedule=schedule) -@router.get("/", response_model=list[schemas.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""" return repo.schedule.get_by_subscriber(db, subscriber_id=subscriber.id) -@router.get("/{id}", response_model=schemas.Schedule, deprecated=True) +@router.get('/{id}', response_model=schemas.Schedule, deprecated=True) def read_schedule( id: int, db: Session = Depends(get_db), @@ -68,7 +78,7 @@ def read_schedule( return schedule -@router.put("/{id}", response_model=schemas.Schedule) +@router.put('/{id}', response_model=schemas.Schedule) def update_schedule( id: int, schedule: schemas.ScheduleValidationIn, @@ -82,16 +92,19 @@ def update_schedule( raise validation.CalendarNotConnectedException() if not repo.schedule.is_owned(db, schedule_id=id, subscriber_id=subscriber.id): raise validation.ScheduleNotAuthorizedException() - if schedule.meeting_link_provider == MeetingLinkProviderType.zoom and subscriber.get_external_connection(ExternalConnectionType.zoom) is None: + if ( + schedule.meeting_link_provider == MeetingLinkProviderType.zoom + and subscriber.get_external_connection(ExternalConnectionType.zoom) is None + ): raise validation.ZoomNotConnectedException() return repo.schedule.update(db=db, schedule=schedule, schedule_id=id) -@router.post("/public/availability", response_model=schemas.AppointmentOut) +@router.post('/public/availability', response_model=schemas.AppointmentOut) def read_schedule_availabilities( subscriber: Subscriber = Depends(get_subscriber_from_signed_url), db: Session = Depends(get_db), - redis = Depends(get_redis), + redis=Depends(get_redis), google_client: GoogleClient = Depends(get_google_client), ): """Returns the calculated availability for the first schedule from a subscribers public profile link""" @@ -134,18 +147,18 @@ def read_schedule_availabilities( details=schedule.details, owner_name=subscriber.name, slots=actual_slots, - slot_duration=schedule.slot_duration + slot_duration=schedule.slot_duration, ) -@router.put("/public/availability/request") +@router.put('/public/availability/request') def request_schedule_availability_slot( s_a: schemas.AvailabilitySlotAttendee, background_tasks: BackgroundTasks, subscriber: Subscriber = Depends(get_subscriber_from_signed_url), db: Session = Depends(get_db), - redis = Depends(get_redis), - google_client = Depends(get_google_client), + redis=Depends(get_redis), + google_client=Depends(get_google_client), ): """endpoint to request a time slot for a schedule via public link and send confirmation mail to owner""" @@ -173,10 +186,12 @@ def request_schedule_availability_slot( slot = schemas.SlotBase(**s_a.slot.dict()) if repo.slot.exists_on_schedule(db, slot, schedule.id): raise validation.SlotAlreadyTakenException() - + # We need to verify that the time is actually available on the remote calendar if db_calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) + external_connection = utils.list_first( + repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google) + ) if external_connection is None or external_connection.token is None: raise RemoteCalendarConnectionError() @@ -195,17 +210,19 @@ def request_schedule_availability_slot( redis_instance=redis, subscriber_id=subscriber.id, calendar_id=db_calendar.id, - url=db_calendar.url, - user=db_calendar.user, - password=db_calendar.password + url=db_calendar.url, + user=db_calendar.user, + password=db_calendar.password, ) # Ok we need to clear the cache for all calendars, because we need to recheck them. con.bust_cached_events(True) calendars = repo.calendar.get_by_subscriber(db, subscriber.id, False) - existing_remote_events = Tools.existing_events_for_schedule(schedule, calendars, subscriber, google_client, db, redis) + existing_remote_events = Tools.existing_events_for_schedule( + schedule, calendars, subscriber, google_client, db, redis + ) has_collision = Tools.events_roll_up_difference([slot], existing_remote_events) - + # If we only have booked entries in this list then it means our slot is not available. if all(evt.booking_status == BookingStatus.booked for evt in has_collision): raise validation.SlotAlreadyTakenException() @@ -221,32 +238,35 @@ def request_schedule_availability_slot( attendee = repo.slot.update(db, slot.id, s_a.attendee) # generate confirm and deny links with encoded booking token and signed owner url - url = f"{signed_url_by_subscriber(subscriber)}/confirm/{slot.id}/{token}" + url = f'{signed_url_by_subscriber(subscriber)}/confirm/{slot.id}/{token}' # human readable date in subscribers timezone # TODO: handle locale date representation - date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime("%c") - date = f"{date}, {slot.duration} minutes ({subscriber.timezone})" + date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime('%c') + date = f'{date}, {slot.duration} minutes ({subscriber.timezone})' # human readable date in attendee timezone # TODO: handle locale date representation - attendee_date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(slot.attendee.timezone)).strftime("%c") - attendee_date = f"{attendee_date}, {slot.duration} minutes ({slot.attendee.timezone})" + attendee_date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(slot.attendee.timezone)).strftime('%c') + attendee_date = f'{attendee_date}, {slot.duration} minutes ({slot.attendee.timezone})' # Create a pending appointment attendee_name = slot.attendee.name if slot.attendee.name is not None else slot.attendee.email subscriber_name = subscriber.name if subscriber.name is not None else subscriber.email - title = f"Appointment - {subscriber_name} and {attendee_name}" + title = f'Appointment - {subscriber_name} and {attendee_name}' - appointment = repo.appointment.create(db, schemas.AppointmentFull( - title=title, - details=schedule.details, - calendar_id=db_calendar.id, - duration=slot.duration, - status=models.AppointmentStatus.opened, - location_type=schedule.location_type, - location_url=schedule.location_url, - )) + appointment = repo.appointment.create( + db, + schemas.AppointmentFull( + title=title, + details=schedule.details, + calendar_id=db_calendar.id, + duration=slot.duration, + status=models.AppointmentStatus.opened, + location_type=schedule.location_type, + location_url=schedule.location_url, + ), + ) # Update the slot slot.appointment_id = appointment.id @@ -254,10 +274,14 @@ def request_schedule_availability_slot( db.commit() # Sending confirmation email to owner - background_tasks.add_task(send_confirmation_email, url=url, attendee_name=attendee.name, date=date, to=subscriber.preferred_email) + background_tasks.add_task( + send_confirmation_email, url=url, attendee_name=attendee.name, date=date, to=subscriber.preferred_email + ) # Sending pending email to attendee - background_tasks.add_task(send_pending_email, owner_name=subscriber.name, date=attendee_date, to=slot.attendee.email) + background_tasks.add_task( + send_pending_email, owner_name=subscriber.name, date=attendee_date, to=slot.attendee.email + ) # Mini version of slot, so we can grab the newly created slot id for tests return schemas.SlotOut( @@ -268,16 +292,16 @@ def request_schedule_availability_slot( ) -@router.put("/public/availability/booking", response_model=schemas.AvailabilitySlotAttendee) +@router.put('/public/availability/booking', response_model=schemas.AvailabilitySlotAttendee) def decide_on_schedule_availability_slot( data: schemas.AvailabilitySlotConfirmation, background_tasks: BackgroundTasks, db: Session = Depends(get_db), - redis = Depends(get_redis), + redis=Depends(get_redis), google_client: GoogleClient = Depends(get_google_client), ): """endpoint to react to owners decision to a request of a time slot of his public link - if confirmed: create an event in remote calendar and send invitation mail + if confirmed: create an event in remote calendar and send invitation mail """ subscriber = repo.subscriber.verify_link(db, data.owner_url) if not subscriber: @@ -317,8 +341,8 @@ def decide_on_schedule_availability_slot( if data.confirmed is False: # human readable date in subscribers timezone # TODO: handle locale date representation - date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime("%c") - date = f"{date}, {slot.duration} minutes" + date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime('%c') + date = f'{date}, {slot.duration} minutes' # send rejection information to bookee background_tasks.add_task(send_rejection_email, owner_name=subscriber.name, date=date, to=slot.attendee.email) repo.slot.delete(db, slot.id) @@ -334,10 +358,8 @@ def decide_on_schedule_availability_slot( return schemas.AvailabilitySlotAttendee( slot=schemas.SlotBase(start=slot.start, duration=slot.duration), attendee=schemas.AttendeeBase( - email=slot.attendee.email, - name=slot.attendee.name, - timezone=slot.attendee.timezone - ) + email=slot.attendee.email, name=slot.attendee.name, timezone=slot.attendee.timezone + ), ) # otherwise, confirm slot and create event @@ -346,10 +368,10 @@ def decide_on_schedule_availability_slot( attendee_name = slot.attendee.name if slot.attendee.name is not None else slot.attendee.email subscriber_name = subscriber.name if subscriber.name is not None else subscriber.email - attendees = f"{subscriber_name} and {attendee_name}" + attendees = f'{subscriber_name} and {attendee_name}' if not slot.appointment: - title = f"Appointment - {attendees}" + title = f'Appointment - {attendees}' else: title = slot.appointment.title # Update the appointment to closed @@ -359,8 +381,7 @@ def decide_on_schedule_availability_slot( if schedule.meeting_link_provider == MeetingLinkProviderType.zoom: try: zoom_client = get_zoom_client(subscriber) - response = zoom_client.create_meeting(attendees, slot.start.isoformat(), slot.duration, - subscriber.timezone) + response = zoom_client.create_meeting(attendees, slot.start.isoformat(), slot.duration, subscriber.timezone) if 'id' in response: location_url = zoom_client.get_meeting(response['id'])['join_url'] slot.meeting_link_id = response['id'] @@ -369,16 +390,18 @@ def decide_on_schedule_availability_slot( db.add(slot) db.commit() except HTTPError as err: # Not fatal, just a bummer - logging.error("Zoom meeting creation error: ", err) + logging.error('Zoom meeting creation error: ', err) # Ensure sentry captures the error too! if os.getenv('SENTRY_DSN') != '': capture_exception(err) # Notify the organizer that the meeting link could not be created! - background_tasks.add_task(send_zoom_meeting_failed_email, to=subscriber.preferred_email, appointment_title=schedule.name) + background_tasks.add_task( + send_zoom_meeting_failed_email, to=subscriber.preferred_email, appointment_title=schedule.name + ) except SQLAlchemyError as err: # Not fatal, but could make things tricky - logging.error("Failed to save the zoom meeting link to the appointment: ", err) + logging.error('Failed to save the zoom meeting link to the appointment: ', err) if os.getenv('SENTRY_DSN') != '': capture_exception(err) @@ -392,14 +415,16 @@ def decide_on_schedule_availability_slot( url=location_url, name=None, ), - uuid=slot.appointment.uuid if slot.appointment else None + uuid=slot.appointment.uuid if slot.appointment else None, ) organizer_email = subscriber.email # create remote event if calendar.provider == CalendarProvider.google: - external_connection: ExternalConnection|None = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google)) + external_connection: ExternalConnection | None = utils.list_first( + repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google) + ) if external_connection is None or external_connection.token is None: raise RemoteCalendarConnectionError() @@ -423,7 +448,7 @@ def decide_on_schedule_availability_slot( calendar_id=calendar.id, url=calendar.url, user=calendar.user, - password=calendar.password + password=calendar.password, ) try: @@ -439,8 +464,6 @@ def decide_on_schedule_availability_slot( return schemas.AvailabilitySlotAttendee( slot=schemas.SlotBase(start=slot.start, duration=slot.duration), attendee=schemas.AttendeeBase( - email=slot.attendee.email, - name=slot.attendee.name, - timezone=slot.attendee.timezone - ) + email=slot.attendee.email, name=slot.attendee.name, timezone=slot.attendee.timezone + ), ) diff --git a/backend/src/appointment/routes/subscriber.py b/backend/src/appointment/routes/subscriber.py index beac14bf4..4825aba04 100644 --- a/backend/src/appointment/routes/subscriber.py +++ b/backend/src/appointment/routes/subscriber.py @@ -1,5 +1,3 @@ -import time - from fastapi import APIRouter, Depends from sqlalchemy.orm import Session @@ -21,8 +19,10 @@ def get_all_subscriber(db: Session = Depends(get_db), _: Subscriber = Depends(ge return response -@router.put("/disable/{email}") -def disable_subscriber(email: str, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_admin_subscriber)): +@router.put('/disable/{email}') +def disable_subscriber( + email: str, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_admin_subscriber) +): """endpoint to mark a subscriber deleted by email, needs admin permissions""" subscriber_to_delete = repo.subscriber.get_by_email(db, email) if not subscriber_to_delete: @@ -36,8 +36,8 @@ def disable_subscriber(email: str, db: Session = Depends(get_db), subscriber: Su return repo.subscriber.disable(db, subscriber_to_delete) -@router.put("/enable/{email}") -def disable_subscriber(email: str, db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)): +@router.put('/enable/{email}') +def enable_subscriber(email: str, db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)): """endpoint to enable a subscriber by email, needs admin permissions""" subscriber_to_enable = repo.subscriber.get_by_email(db, email) if not subscriber_to_enable: diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 2bdf8dea6..28d775113 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -1,8 +1,7 @@ -import json import logging import requests -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from ..controller import auth, data @@ -16,17 +15,17 @@ router = APIRouter() -@router.post("/fxa-process") +@router.post('/fxa-process') def fxa_process( db: Session = Depends(get_db), decoded_token: dict = Depends(get_webhook_auth), - fxa_client: FxaClient = Depends(get_fxa_client) + fxa_client: FxaClient = Depends(get_fxa_client), ): """Main for webhooks regarding fxa""" subscriber: models.Subscriber = repo.external_connection.get_subscriber_by_fxa_uid(db, decoded_token.get('sub')) if not subscriber: - logging.warning("Webhook event received for non-existent user.") + logging.warning('Webhook event received for non-existent user.') return subscriber_external_connection = subscriber.get_external_connection(models.ExternalConnectionType.fxa) @@ -39,7 +38,7 @@ def fxa_process( # TODO: We may need a last update timestamp JUST for token field changes. token_last_updated = subscriber_external_connection.time_updated.timestamp() * 1000 if token_last_updated > event_data.get('changeTime'): - logging.info("Ignoring out of date logout request.") + logging.info('Ignoring out of date logout request.') break try: @@ -47,7 +46,7 @@ def fxa_process( except MissingRefreshTokenException: logging.warning("Subscriber doesn't have refresh token.") except requests.exceptions.HTTPError as ex: - logging.error(f"Error logging out user: {ex.response}") + logging.error(f'Error logging out user: {ex.response}') case 'https://schemas.accounts.firefox.com/event/profile-change': if event_data.get('email') is not None: # Update the subscriber's email, we do this first in case there's a problem with get_profile() @@ -58,13 +57,15 @@ def fxa_process( try: profile = fxa_client.get_profile() # Update profile with fxa info - repo.subscriber.update(db, schemas.SubscriberIn( - avatar_url=profile['avatar'], - name=subscriber.name, - username=subscriber.username - ), subscriber.id) + repo.subscriber.update( + db, + schemas.SubscriberIn( + avatar_url=profile['avatar'], name=subscriber.name, username=subscriber.username + ), + subscriber.id, + ) except Exception as ex: - logging.error(f"Error updating user: {ex}") + logging.error(f'Error updating user: {ex}') # Finally log the subscriber out try: @@ -72,12 +73,12 @@ def fxa_process( except MissingRefreshTokenException: logging.warning("Subscriber doesn't have refresh token.") except requests.exceptions.HTTPError as ex: - logging.error(f"Error logging out user: {ex.response}") + logging.error(f'Error logging out user: {ex.response}') case 'https://schemas.accounts.firefox.com/event/delete-user': try: data.delete_account(db, subscriber) except AccountDeletionSubscriberFail as ex: - logging.error(f"Account deletion webhook failed: {ex.message}") + logging.error(f'Account deletion webhook failed: {ex.message}') case _: - logging.warning(f"Ignoring event {event}") + logging.warning(f'Ignoring event {event}') diff --git a/backend/src/appointment/routes/zoom.py b/backend/src/appointment/routes/zoom.py index 6dd2c9d23..2954cbd9e 100644 --- a/backend/src/appointment/routes/zoom.py +++ b/backend/src/appointment/routes/zoom.py @@ -17,7 +17,7 @@ router = APIRouter() -@router.get("/auth") +@router.get('/auth') def zoom_auth( request: Request, subscriber: Subscriber = Depends(get_subscriber), @@ -34,7 +34,7 @@ def zoom_auth( return {'url': url} -@router.get("/callback") +@router.get('/callback') def zoom_callback( request: Request, code: str, @@ -53,7 +53,8 @@ def zoom_callback( request.session.pop('zoom_state') request.session.pop('zoom_user_id') - # Generate the zoom client instance based on our subscriber (this can't be set as a dep injection since subscriber is based on session. + # Generate the zoom client instance based on our subscriber + # This can't be set as a dep injection since subscriber is based on session. zoom_client: ZoomClient = get_zoom_client(subscriber) creds = zoom_client.get_credentials(code) @@ -66,16 +67,23 @@ def zoom_callback( type=ExternalConnectionType.zoom, type_id=zoom_user_info['id'], owner_id=subscriber.id, - token=json.dumps(creds) + token=json.dumps(creds), ) - if len(repo.external_connection.get_by_type(db, subscriber.id, external_connection_schema.type, external_connection_schema.type_id)) == 0: + if ( + len( + repo.external_connection.get_by_type( + db, subscriber.id, external_connection_schema.type, external_connection_schema.type_id + ) + ) + == 0 + ): repo.external_connection.create(db, external_connection_schema) return RedirectResponse(f"{os.getenv('FRONTEND_URL', 'http://localhost:8080')}/settings/account") -@router.post("/disconnect") +@router.post('/disconnect') def disconnect_account( db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), diff --git a/backend/src/appointment/secrets.py b/backend/src/appointment/secrets.py index 5ddef920c..59b1a1b92 100644 --- a/backend/src/appointment/secrets.py +++ b/backend/src/appointment/secrets.py @@ -4,7 +4,7 @@ def normalize_secrets(): """Normalizes AWS secrets for Appointment""" - database_secrets = os.getenv("DATABASE_SECRETS") + database_secrets = os.getenv('DATABASE_SECRETS') if database_secrets: secrets = json.loads(database_secrets) @@ -17,50 +17,50 @@ def normalize_secrets(): if f':{port}' not in host: hostname = f'{hostname}:{port}' - os.environ[ - "DATABASE_URL" - ] = f"mysql+mysqldb://{secrets['username']}:{secrets['password']}@{hostname}/appointment" + os.environ['DATABASE_URL'] = ( + f"mysql+mysqldb://{secrets['username']}:{secrets['password']}@{hostname}/appointment" + ) - database_enc_secret = os.getenv("DB_ENC_SECRET") + database_enc_secret = os.getenv('DB_ENC_SECRET') if database_enc_secret: secrets = json.loads(database_enc_secret) - os.environ["DB_SECRET"] = secrets.get("secret") + os.environ['DB_SECRET'] = secrets.get('secret') # Technically not db related...might rename this item later. - os.environ["SIGNED_SECRET"] = secrets.get("signed_secret") - os.environ["SESSION_SECRET"] = secrets.get("session_secret") - os.environ["JWT_SECRET"] = secrets.get("jwt_secret") + os.environ['SIGNED_SECRET'] = secrets.get('signed_secret') + os.environ['SESSION_SECRET'] = secrets.get('session_secret') + os.environ['JWT_SECRET'] = secrets.get('jwt_secret') - smtp_secrets = os.getenv("SMTP_SECRETS") + smtp_secrets = os.getenv('SMTP_SECRETS') if smtp_secrets: secrets = json.loads(smtp_secrets) - os.environ["SMTP_SECURITY"] = "STARTTLS" - os.environ["SMTP_URL"] = secrets.get("url") - os.environ["SMTP_PORT"] = secrets.get("port") - os.environ["SMTP_USER"] = secrets.get("username") - os.environ["SMTP_PASS"] = secrets.get("password") - os.environ["SUPPORT_EMAIL"] = secrets.get("support") + os.environ['SMTP_SECURITY'] = 'STARTTLS' + os.environ['SMTP_URL'] = secrets.get('url') + os.environ['SMTP_PORT'] = secrets.get('port') + os.environ['SMTP_USER'] = secrets.get('username') + os.environ['SMTP_PASS'] = secrets.get('password') + os.environ['SUPPORT_EMAIL'] = secrets.get('support') - google_oauth_secrets = os.getenv("GOOGLE_OAUTH_SECRETS") + google_oauth_secrets = os.getenv('GOOGLE_OAUTH_SECRETS') if google_oauth_secrets: secrets = json.loads(google_oauth_secrets) - os.environ["GOOGLE_AUTH_CLIENT_ID"] = secrets.get("client_id") - os.environ["GOOGLE_AUTH_SECRET"] = secrets.get("secret") - os.environ["GOOGLE_AUTH_PROJECT_ID"] = secrets.get("project_id") - os.environ["GOOGLE_AUTH_CALLBACK"] = secrets.get("callback_url") + os.environ['GOOGLE_AUTH_CLIENT_ID'] = secrets.get('client_id') + os.environ['GOOGLE_AUTH_SECRET'] = secrets.get('secret') + os.environ['GOOGLE_AUTH_PROJECT_ID'] = secrets.get('project_id') + os.environ['GOOGLE_AUTH_CALLBACK'] = secrets.get('callback_url') - zoom_secrets = os.getenv("ZOOM_SECRETS") + zoom_secrets = os.getenv('ZOOM_SECRETS') if zoom_secrets: secrets = json.loads(zoom_secrets) - os.environ["ZOOM_AUTH_CLIENT_ID"] = secrets.get("client_id") - os.environ["ZOOM_AUTH_SECRET"] = secrets.get("secret") + os.environ['ZOOM_AUTH_CLIENT_ID'] = secrets.get('client_id') + os.environ['ZOOM_AUTH_SECRET'] = secrets.get('secret') fxa_secrets = os.getenv('FXA_SECRETS') diff --git a/backend/src/appointment/tasks/emails.py b/backend/src/appointment/tasks/emails.py index a313df3d0..a6a68a49f 100644 --- a/backend/src/appointment/tasks/emails.py +++ b/backend/src/appointment/tasks/emails.py @@ -1,5 +1,12 @@ -from appointment.controller.mailer import PendingRequestMail, ConfirmationMail, InvitationMail, ZoomMeetingFailedMail, \ - RejectionMail, SupportRequestMail, InviteAccountMail +from appointment.controller.mailer import ( + PendingRequestMail, + ConfirmationMail, + InvitationMail, + ZoomMeetingFailedMail, + RejectionMail, + SupportRequestMail, + InviteAccountMail, +) def send_invite_email(to, attachment): @@ -9,32 +16,17 @@ def send_invite_email(to, attachment): def send_confirmation_email(url, attendee_name, attendee_email, date, to): # send confirmation mail to owner - mail = ConfirmationMail( - f"{url}/1", - f"{url}/0", - attendee_name, - attendee_email, - date, - to=to - ) + mail = ConfirmationMail(f'{url}/1', f'{url}/0', attendee_name, attendee_email, date, to=to) mail.send() def send_pending_email(owner_name, date, to): - mail = PendingRequestMail( - owner_name=owner_name, - date=date, - to=to - ) + mail = PendingRequestMail(owner_name=owner_name, date=date, to=to) mail.send() def send_rejection_email(owner_name, date, to): - mail = RejectionMail( - owner_name=owner_name, - date=date, - to=to - ) + mail = RejectionMail(owner_name=owner_name, date=date, to=to) mail.send() diff --git a/backend/src/appointment/utils.py b/backend/src/appointment/utils.py index 04e9a609c..afd93fa3d 100644 --- a/backend/src/appointment/utils.py +++ b/backend/src/appointment/utils.py @@ -27,7 +27,7 @@ def is_json(jsonstring: str): """Return true if given string is valid JSON.""" try: json.loads(jsonstring) - except ValueError as e: + except ValueError: return False return True @@ -36,8 +36,8 @@ def is_json(jsonstring: str): def setup_encryption_engine(): engine = AesEngine() # Yes we need to use protected methods to set this up. - # We could replace it with our own encryption, + # We could replace it with our own encryption, # but I wanted it to be similar to the db. engine._update_key(secret()) - engine._set_padding_mechanism("pkcs5") + engine._set_padding_mechanism('pkcs5') return engine diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 03906fd38..fafdf062b 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -20,10 +20,10 @@ from factory.schedule_factory import make_schedule # noqa: F401 from factory.slot_factory import make_appointment_slot # noqa: F401 from factory.subscriber_factory import make_subscriber, make_basic_subscriber, make_pro_subscriber # noqa: F401 -from factory.invite_factory import make_invite +from factory.invite_factory import make_invite # noqa: F401 # Load our env -load_dotenv(find_dotenv(".env.test")) +load_dotenv(find_dotenv('.env.test')) from appointment.main import server # noqa: E402 from appointment.database import models, repo, schemas # noqa: E402 @@ -33,6 +33,7 @@ def _patch_caldav_connector(monkeypatch): """Standard function to patch caldav connector""" + # Create a mock caldav connector class MockCaldavConnector: @staticmethod @@ -42,12 +43,7 @@ def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_ @staticmethod def list_calendars(self): - return [ - schemas.CalendarConnectionOut( - url=TEST_CALDAV_URL, - user=TEST_CALDAV_USER - ) - ] + return [schemas.CalendarConnectionOut(url=TEST_CALDAV_URL, user=TEST_CALDAV_USER)] @staticmethod def create_event(self, event, attendee, organizer, organizer_email): @@ -63,22 +59,25 @@ def test_connection(self): # Patch up the caldav constructor, and list_calendars 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) - monkeypatch.setattr(CalDavConnector, "delete_events", MockCaldavConnector.delete_event) - monkeypatch.setattr(CalDavConnector, "test_connection", MockCaldavConnector.test_connection) + + monkeypatch.setattr(CalDavConnector, '__init__', MockCaldavConnector.__init__) + monkeypatch.setattr(CalDavConnector, 'list_calendars', MockCaldavConnector.list_calendars) + monkeypatch.setattr(CalDavConnector, 'create_event', MockCaldavConnector.create_event) + monkeypatch.setattr(CalDavConnector, 'delete_events', MockCaldavConnector.delete_event) + monkeypatch.setattr(CalDavConnector, 'test_connection', MockCaldavConnector.test_connection) def _patch_mailer(monkeypatch): """Mocks the base mailer class to not send mail""" + class MockMailer: @staticmethod def send(self): return from appointment.controller.mailer import Mailer - monkeypatch.setattr(Mailer, "send", MockMailer.send) + + monkeypatch.setattr(Mailer, 'send', MockMailer.send) def _patch_fxa_client(monkeypatch): @@ -101,7 +100,7 @@ def get_profile(self): 'email': FXA_CLIENT_PATCH.get('subscriber_email'), 'uid': FXA_CLIENT_PATCH.get('external_connection_type_id'), 'avatar': FXA_CLIENT_PATCH.get('subscriber_avatar_url'), - 'displayName': FXA_CLIENT_PATCH.get('subscriber_display_name') + 'displayName': FXA_CLIENT_PATCH.get('subscriber_display_name'), } @staticmethod @@ -113,17 +112,18 @@ def get_jwk(self): return {} 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) - monkeypatch.setattr(FxaClient, "get_profile", MockFxaClient.get_profile) - monkeypatch.setattr(FxaClient, "logout", MockFxaClient.logout) - monkeypatch.setattr(FxaClient, "get_jwk", MockFxaClient.get_jwk) + + monkeypatch.setattr(FxaClient, 'setup', MockFxaClient.setup) + monkeypatch.setattr(FxaClient, 'get_redirect_url', MockFxaClient.get_redirect_url) + monkeypatch.setattr(FxaClient, 'get_credentials', MockFxaClient.get_credentials) + monkeypatch.setattr(FxaClient, 'get_profile', MockFxaClient.get_profile) + monkeypatch.setattr(FxaClient, 'logout', MockFxaClient.logout) + monkeypatch.setattr(FxaClient, 'get_jwk', MockFxaClient.get_jwk) @pytest.fixture() def with_db(): - engine = create_engine(os.getenv("DATABASE_URL"), connect_args={"check_same_thread": False}, poolclass=StaticPool) + engine = create_engine(os.getenv('DATABASE_URL'), connect_args={'check_same_thread': False}, poolclass=StaticPool) testing_local_session = sessionmaker(autocommit=False, autoflush=False, bind=engine) models.Base.metadata.drop_all(bind=engine) @@ -134,8 +134,8 @@ def with_db(): subscriber = models.Subscriber( username=os.getenv('TEST_USER_EMAIL'), email=os.getenv('TEST_USER_EMAIL'), - name="Test Account", - level=models.SubscriberLevel.pro + name='Test Account', + level=models.SubscriberLevel.pro, ) db.add(subscriber) db.commit() @@ -157,7 +157,7 @@ def override_get_db(): finally: db.close() - def override_get_subscriber(request : Request): + def override_get_subscriber(request: Request): if 'authorization' not in request.headers: raise InvalidTokenException @@ -186,7 +186,9 @@ def override_get_google_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!""" + """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') diff --git a/backend/test/defines.py b/backend/test/defines.py index 8b0a24f31..46d9d8631 100644 --- a/backend/test/defines.py +++ b/backend/test/defines.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta -DATEFMT = "%Y-%m-%d" +DATEFMT = '%Y-%m-%d' now = datetime.today() DAY1 = now.strftime(DATEFMT) @@ -10,7 +10,7 @@ DAY14 = (now + timedelta(days=13)).strftime(DATEFMT) # Standard headers to used for authentication requests -auth_headers = {"authorization": "Bearer testtokenplsignore"} +auth_headers = {'authorization': 'Bearer testtokenplsignore'} TEST_USER_ID = 1 TEST_CALDAV_URL = 'https://caldav.example.org/' @@ -28,6 +28,7 @@ 'subscriber_display_name': 'test2', } + def factory_has_value(val) -> bool: """For factories""" return val != FAKER_RANDOM_VALUE diff --git a/backend/test/factory/appointment_factory.py b/backend/test/factory/appointment_factory.py index 57ae6cefc..7298875cc 100644 --- a/backend/test/factory/appointment_factory.py +++ b/backend/test/factory/appointment_factory.py @@ -8,38 +8,50 @@ def make_appointment(with_db, make_caldav_calendar, make_appointment_slot): fake = Faker() - def _make_appointment(calendar_id=FAKER_RANDOM_VALUE, - title=FAKER_RANDOM_VALUE, - details=FAKER_RANDOM_VALUE, - duration=FAKER_RANDOM_VALUE, - location_url=FAKER_RANDOM_VALUE, - location_type=FAKER_RANDOM_VALUE, - location_suggestions=FAKER_RANDOM_VALUE, - location_selected=FAKER_RANDOM_VALUE, - location_name=FAKER_RANDOM_VALUE, - location_phone=FAKER_RANDOM_VALUE, - keep_open=True, - status=models.AppointmentStatus.draft, - meeting_link_provider=models.MeetingLinkProviderType.none, - slots=FAKER_RANDOM_VALUE - ): + def _make_appointment( + calendar_id=FAKER_RANDOM_VALUE, + title=FAKER_RANDOM_VALUE, + details=FAKER_RANDOM_VALUE, + duration=FAKER_RANDOM_VALUE, + location_url=FAKER_RANDOM_VALUE, + location_type=FAKER_RANDOM_VALUE, + location_suggestions=FAKER_RANDOM_VALUE, + location_selected=FAKER_RANDOM_VALUE, + location_name=FAKER_RANDOM_VALUE, + location_phone=FAKER_RANDOM_VALUE, + keep_open=True, + status=models.AppointmentStatus.draft, + meeting_link_provider=models.MeetingLinkProviderType.none, + slots=FAKER_RANDOM_VALUE, + ): with with_db() as db: - appointment = repo.appointment.create(db, schemas.AppointmentFull( - title=title if factory_has_value(title) else fake.name(), - details=details if factory_has_value(details) else fake.sentence(), - duration=duration if factory_has_value(duration) else fake.pyint(15, 60), - location_url=location_url if factory_has_value(location_url) else fake.url(), - location_type=location_type if factory_has_value(location_type) else fake.random_element( - (models.LocationType.inperson, models.LocationType.online)), - location_suggestions=location_suggestions if factory_has_value(location_suggestions) else fake.city(), - location_selected=location_selected if factory_has_value(location_selected) else fake.city(), - location_name=location_name if factory_has_value(location_name) else fake.city(), - location_phone=location_phone if factory_has_value(location_phone) else fake.phone_number(), - keep_open=keep_open, - status=status, - meeting_link_provider=meeting_link_provider, - calendar_id=calendar_id if factory_has_value(calendar_id) else make_caldav_calendar(connected=True).id - ), []) + appointment = repo.appointment.create( + db, + schemas.AppointmentFull( + title=title if factory_has_value(title) else fake.name(), + details=details if factory_has_value(details) else fake.sentence(), + duration=duration if factory_has_value(duration) else fake.pyint(15, 60), + location_url=location_url if factory_has_value(location_url) else fake.url(), + location_type=( + location_type + if factory_has_value(location_type) + else fake.random_element((models.LocationType.inperson, models.LocationType.online)) + ), + location_suggestions=( + location_suggestions if factory_has_value(location_suggestions) else fake.city() + ), + location_selected=location_selected if factory_has_value(location_selected) else fake.city(), + location_name=location_name if factory_has_value(location_name) else fake.city(), + location_phone=location_phone if factory_has_value(location_phone) else fake.phone_number(), + keep_open=keep_open, + status=status, + meeting_link_provider=meeting_link_provider, + calendar_id=( + calendar_id if factory_has_value(calendar_id) else make_caldav_calendar(connected=True).id + ), + ), + [], + ) if not factory_has_value(slots): make_appointment_slot(appointment_id=appointment.id) diff --git a/backend/test/factory/attendee_factory.py b/backend/test/factory/attendee_factory.py index 15de52a29..df2f6eebd 100644 --- a/backend/test/factory/attendee_factory.py +++ b/backend/test/factory/attendee_factory.py @@ -12,7 +12,7 @@ def _make_attendee(email=FAKER_RANDOM_VALUE, name=FAKER_RANDOM_VALUE): with with_db() as db: db_attendee = models.Attendee( email=email if factory_has_value(email) else fake.email(), - name=name if factory_has_value(name) else fake.name() + name=name if factory_has_value(name) else fake.name(), ) db.add(db_attendee) db.commit() diff --git a/backend/test/factory/calendar_factory.py b/backend/test/factory/calendar_factory.py index c038382cc..320fedcb1 100644 --- a/backend/test/factory/calendar_factory.py +++ b/backend/test/factory/calendar_factory.py @@ -8,18 +8,30 @@ def make_caldav_calendar(with_db): fake = Faker() - def _make_caldav_calendar(subscriber_id=TEST_USER_ID, url=FAKER_RANDOM_VALUE, title=FAKER_RANDOM_VALUE, color=FAKER_RANDOM_VALUE, connected=False, user=FAKER_RANDOM_VALUE, password=FAKER_RANDOM_VALUE): + def _make_caldav_calendar( + subscriber_id=TEST_USER_ID, + url=FAKER_RANDOM_VALUE, + title=FAKER_RANDOM_VALUE, + color=FAKER_RANDOM_VALUE, + connected=False, + user=FAKER_RANDOM_VALUE, + password=FAKER_RANDOM_VALUE, + ): with with_db() as db: title = title if factory_has_value(title) else fake.name() - return repo.calendar.create(db, schemas.CalendarConnection( - title=title, - color=color if factory_has_value(color) else fake.color(), - connected=connected, - provider=models.CalendarProvider.caldav, - url=url if factory_has_value(url) else fake.url(), - user=user if factory_has_value(user) else fake.name(), - password=password if factory_has_value(password) else fake.password() - ), subscriber_id) + return repo.calendar.create( + db, + schemas.CalendarConnection( + title=title, + color=color if factory_has_value(color) else fake.color(), + connected=connected, + provider=models.CalendarProvider.caldav, + url=url if factory_has_value(url) else fake.url(), + user=user if factory_has_value(user) else fake.name(), + password=password if factory_has_value(password) else fake.password(), + ), + subscriber_id, + ) return _make_caldav_calendar @@ -28,18 +40,28 @@ def _make_caldav_calendar(subscriber_id=TEST_USER_ID, url=FAKER_RANDOM_VALUE, ti def make_google_calendar(with_db): fake = Faker() - def _make_google_calendar(subscriber_id=TEST_USER_ID, title=FAKER_RANDOM_VALUE, color=FAKER_RANDOM_VALUE, id=FAKER_RANDOM_VALUE, connected=False): + def _make_google_calendar( + subscriber_id=TEST_USER_ID, + title=FAKER_RANDOM_VALUE, + color=FAKER_RANDOM_VALUE, + id=FAKER_RANDOM_VALUE, + connected=False, + ): with with_db() as db: title = title if factory_has_value(title) else fake.name() id = id if factory_has_value(id) else fake.uuid4() - return repo.calendar.create(db, schemas.CalendarConnection( - title=title, - color=color if factory_has_value(color) else fake.color(), - connected=connected, - provider=models.CalendarProvider.google, - url=id, - user=id, - password='', - ), subscriber_id) + return repo.calendar.create( + db, + schemas.CalendarConnection( + title=title, + color=color if factory_has_value(color) else fake.color(), + connected=connected, + provider=models.CalendarProvider.google, + url=id, + user=id, + password='', + ), + subscriber_id, + ) return _make_google_calendar diff --git a/backend/test/factory/external_connection_factory.py b/backend/test/factory/external_connection_factory.py index dc2b4e3b3..c693fdaf0 100644 --- a/backend/test/factory/external_connection_factory.py +++ b/backend/test/factory/external_connection_factory.py @@ -8,19 +8,33 @@ def make_external_connections(with_db): fake = Faker() - def _make_external_connections(subscriber_id, - name=FAKER_RANDOM_VALUE, - type=FAKER_RANDOM_VALUE, - type_id=FAKER_RANDOM_VALUE, - token=FAKER_RANDOM_VALUE): + def _make_external_connections( + subscriber_id, + name=FAKER_RANDOM_VALUE, + type=FAKER_RANDOM_VALUE, + type_id=FAKER_RANDOM_VALUE, + token=FAKER_RANDOM_VALUE, + ): with with_db() as db: - return repo.external_connection.create(db, schemas.ExternalConnection( - owner_id=subscriber_id, - name=name if factory_has_value(name) else fake.name(), - type=type if factory_has_value(type) else fake.random_element( - (models.ExternalConnectionType.zoom.value, models.ExternalConnectionType.google.value, models.ExternalConnectionType.fxa.value)), - type_id=type_id if factory_has_value(type_id) else fake.uuid4(), - token=token if factory_has_value(token) else fake.password(), - )) + return repo.external_connection.create( + db, + schemas.ExternalConnection( + owner_id=subscriber_id, + name=name if factory_has_value(name) else fake.name(), + type=( + type + if factory_has_value(type) + else fake.random_element( + ( + models.ExternalConnectionType.zoom.value, + models.ExternalConnectionType.google.value, + models.ExternalConnectionType.fxa.value, + ) + ) + ), + type_id=type_id if factory_has_value(type_id) else fake.uuid4(), + token=token if factory_has_value(token) else fake.password(), + ), + ) return _make_external_connections diff --git a/backend/test/factory/invite_factory.py b/backend/test/factory/invite_factory.py index 6de1a2a75..83e3a66c9 100644 --- a/backend/test/factory/invite_factory.py +++ b/backend/test/factory/invite_factory.py @@ -10,7 +10,9 @@ def make_invite(with_db): def _make_invite(subscriber_id=None, code=FAKER_RANDOM_VALUE, status=models.InviteStatus.active) -> models.Invite: with with_db() as db: - invite = models.Invite(subscriber_id=subscriber_id, status=status, code=code if factory_has_value(code) else fake.uuid4()) + invite = models.Invite( + subscriber_id=subscriber_id, status=status, code=code if factory_has_value(code) else fake.uuid4() + ) db.add(invite) db.commit() db.refresh(invite) diff --git a/backend/test/factory/schedule_factory.py b/backend/test/factory/schedule_factory.py index c53579133..50d461156 100644 --- a/backend/test/factory/schedule_factory.py +++ b/backend/test/factory/schedule_factory.py @@ -8,40 +8,49 @@ def make_schedule(with_db, make_caldav_calendar): fake = Faker() - def _make_schedule(calendar_id=FAKER_RANDOM_VALUE, - active=False, - name=FAKER_RANDOM_VALUE, - location_type=FAKER_RANDOM_VALUE, - location_url=FAKER_RANDOM_VALUE, - details=FAKER_RANDOM_VALUE, - start_date=FAKER_RANDOM_VALUE, - end_date=FAKER_RANDOM_VALUE, - start_time=FAKER_RANDOM_VALUE, - end_time=FAKER_RANDOM_VALUE, - earliest_booking=FAKER_RANDOM_VALUE, - farthest_booking=FAKER_RANDOM_VALUE, - weekdays=[1,2,3,4,5], - slot_duration=FAKER_RANDOM_VALUE, - meeting_link_provider=models.MeetingLinkProviderType.none, - ): + def _make_schedule( + calendar_id=FAKER_RANDOM_VALUE, + active=False, + name=FAKER_RANDOM_VALUE, + location_type=FAKER_RANDOM_VALUE, + location_url=FAKER_RANDOM_VALUE, + details=FAKER_RANDOM_VALUE, + start_date=FAKER_RANDOM_VALUE, + end_date=FAKER_RANDOM_VALUE, + start_time=FAKER_RANDOM_VALUE, + end_time=FAKER_RANDOM_VALUE, + earliest_booking=FAKER_RANDOM_VALUE, + farthest_booking=FAKER_RANDOM_VALUE, + weekdays=[1, 2, 3, 4, 5], + slot_duration=FAKER_RANDOM_VALUE, + meeting_link_provider=models.MeetingLinkProviderType.none, + ): with with_db() as db: - return repo.schedule.create(db, schemas.ScheduleBase( - active=active, - name=name if factory_has_value(name) else fake.name(), - location_url=location_url if factory_has_value(location_url) else fake.url(), - location_type=location_type if factory_has_value(location_type) else fake.random_element( - (models.LocationType.inperson, models.LocationType.online)), - details=details if factory_has_value(details) else fake.sentence(), - start_date=start_date if factory_has_value(start_date) else fake.date_object(), - end_date=end_date if factory_has_value(end_date) else fake.date_object(), - start_time=start_time if factory_has_value(start_time) else fake.time_object(), - end_time=end_time if factory_has_value(end_time) else fake.time_object(), - earliest_booking=earliest_booking if factory_has_value(earliest_booking) else fake.pyint(5, 15), - farthest_booking=farthest_booking if factory_has_value(farthest_booking) else fake.pyint(15, 60), - weekdays=weekdays, - slot_duration=slot_duration if factory_has_value(slot_duration) else fake.pyint(15, 60), - meeting_link_provider=meeting_link_provider, - calendar_id=calendar_id if factory_has_value(calendar_id) else make_caldav_calendar(connected=True).id - )) + return repo.schedule.create( + db, + schemas.ScheduleBase( + active=active, + name=name if factory_has_value(name) else fake.name(), + location_url=location_url if factory_has_value(location_url) else fake.url(), + location_type=( + location_type + if factory_has_value(location_type) + else fake.random_element((models.LocationType.inperson, models.LocationType.online)) + ), + details=details if factory_has_value(details) else fake.sentence(), + start_date=start_date if factory_has_value(start_date) else fake.date_object(), + end_date=end_date if factory_has_value(end_date) else fake.date_object(), + start_time=start_time if factory_has_value(start_time) else fake.time_object(), + end_time=end_time if factory_has_value(end_time) else fake.time_object(), + earliest_booking=earliest_booking if factory_has_value(earliest_booking) else fake.pyint(5, 15), + farthest_booking=farthest_booking if factory_has_value(farthest_booking) else fake.pyint(15, 60), + weekdays=weekdays, + slot_duration=slot_duration if factory_has_value(slot_duration) else fake.pyint(15, 60), + meeting_link_provider=meeting_link_provider, + calendar_id=( + calendar_id if factory_has_value(calendar_id) else make_caldav_calendar(connected=True).id + ), + ), + ) return _make_schedule diff --git a/backend/test/factory/slot_factory.py b/backend/test/factory/slot_factory.py index 09a19ac51..82b26195d 100644 --- a/backend/test/factory/slot_factory.py +++ b/backend/test/factory/slot_factory.py @@ -8,25 +8,33 @@ def make_appointment_slot(with_db): fake = Faker() - def _make_appointment_slot(appointment_id=None, - start=FAKER_RANDOM_VALUE, - duration=FAKER_RANDOM_VALUE, - attendee_id=None, - booking_tkn=None, - booking_expires_at=None, - booking_status=models.BookingStatus.none, - meeting_link_id=None, - meeting_link_url=None): + def _make_appointment_slot( + appointment_id=None, + start=FAKER_RANDOM_VALUE, + duration=FAKER_RANDOM_VALUE, + attendee_id=None, + booking_tkn=None, + booking_expires_at=None, + booking_status=models.BookingStatus.none, + meeting_link_id=None, + meeting_link_url=None, + ): with with_db() as db: - return repo.slot.add_for_appointment(db, [schemas.SlotBase( - start=start if factory_has_value(start) else fake.date_time(), - duration=duration if factory_has_value(duration) else fake.pyint(15, 60), - attendee_id=attendee_id, - booking_tkn=booking_tkn, - booking_expires_at=booking_expires_at, - booking_status=booking_status, - meeting_link_id=meeting_link_id, - meeting_link_url=meeting_link_url, - )], appointment_id) + return repo.slot.add_for_appointment( + db, + [ + schemas.SlotBase( + start=start if factory_has_value(start) else fake.date_time(), + duration=duration if factory_has_value(duration) else fake.pyint(15, 60), + attendee_id=attendee_id, + booking_tkn=booking_tkn, + booking_expires_at=booking_expires_at, + booking_status=booking_status, + meeting_link_id=meeting_link_id, + meeting_link_url=meeting_link_url, + ) + ], + appointment_id, + ) return _make_appointment_slot diff --git a/backend/test/factory/subscriber_factory.py b/backend/test/factory/subscriber_factory.py index 461dca74e..e14c398ad 100644 --- a/backend/test/factory/subscriber_factory.py +++ b/backend/test/factory/subscriber_factory.py @@ -9,15 +9,20 @@ def make_subscriber(with_db): fake = Faker() - def _make_subscriber(level, name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE, email=FAKER_RANDOM_VALUE, password=None): + def _make_subscriber( + level, name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE, email=FAKER_RANDOM_VALUE, password=None + ): with with_db() as db: - subscriber = repo.subscriber.create(db, schemas.SubscriberBase( - name=name if factory_has_value(name) else fake.name(), - username=username if factory_has_value(username) else fake.name().replace(' ', '_'), - email=email if factory_has_value(email) else fake.email(), - level=level, - timezone='America/Vancouver' - )) + subscriber = repo.subscriber.create( + db, + schemas.SubscriberBase( + name=name if factory_has_value(name) else fake.name(), + username=username if factory_has_value(username) else fake.name().replace(' ', '_'), + email=email if factory_has_value(email) else fake.email(), + level=level, + timezone='America/Vancouver', + ), + ) # If we've passed in a password then hash it and save it to the subscriber if password: ph = PasswordHasher() @@ -34,7 +39,10 @@ def _make_subscriber(level, name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE @pytest.fixture def make_pro_subscriber(make_subscriber): """Alias for make_subscriber with pro subscriber level""" - def _make_pro_subscriber(name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE, email=FAKER_RANDOM_VALUE, password=None): + + def _make_pro_subscriber( + name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE, email=FAKER_RANDOM_VALUE, password=None + ): return make_subscriber(models.SubscriberLevel.pro, name, username, email, password) return _make_pro_subscriber @@ -43,7 +51,10 @@ def _make_pro_subscriber(name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE, e @pytest.fixture def make_basic_subscriber(make_subscriber): """Alias for make_subscriber with basic subscriber level""" - def _make_basic_subscriber(name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE, email=FAKER_RANDOM_VALUE, password=None): + + def _make_basic_subscriber( + name=FAKER_RANDOM_VALUE, username=FAKER_RANDOM_VALUE, email=FAKER_RANDOM_VALUE, password=None + ): return make_subscriber(models.SubscriberLevel.basic, name, username, email, password) return _make_basic_subscriber diff --git a/backend/test/integration/test_appointment.py b/backend/test/integration/test_appointment.py index d82109161..384ce5875 100644 --- a/backend/test/integration/test_appointment.py +++ b/backend/test/integration/test_appointment.py @@ -6,66 +6,66 @@ class TestAppointment: @staticmethod def date_time_to_str(date_time): - return str(date_time).replace(" ", "T") + return str(date_time).replace(' ', 'T') def test_create_appointment_on_connected_calendar(self, with_client, make_caldav_calendar): generated_calendar = make_caldav_calendar(connected=True) response = with_client.post( - "/apmt", + '/apmt', json={ - "appointment": { - "calendar_id": generated_calendar.id, - "title": "Appointment", - "duration": 180, - "location_type": 2, - "location_name": "Location", - "location_url": "https://test.org", - "location_phone": "+123456789", - "details": "Lorem Ipsum", - "status": 2, - "keep_open": True, + 'appointment': { + 'calendar_id': generated_calendar.id, + 'title': 'Appointment', + 'duration': 180, + 'location_type': 2, + 'location_name': 'Location', + 'location_url': 'https://test.org', + 'location_phone': '+123456789', + 'details': 'Lorem Ipsum', + 'status': 2, + 'keep_open': True, }, - "slots": [ - {"start": DAY1 + " 09:00:00", "duration": 60}, - {"start": DAY2 + " 09:00:00", "duration": 15}, - {"start": DAY3 + " 09:00:00", "duration": 275}, + 'slots': [ + {'start': DAY1 + ' 09:00:00', 'duration': 60}, + {'start': DAY2 + ' 09:00:00', 'duration': 15}, + {'start': DAY3 + ' 09:00:00', 'duration': 275}, ], }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == generated_calendar.id - assert data["title"] == "Appointment" - assert data["duration"] == 180 - assert data["location_type"] == 2 - assert data["location_name"] == "Location" - assert data["location_url"] == "https://test.org" - assert data["location_phone"] == "+123456789" - assert data["details"] == "Lorem Ipsum" - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == 2 - assert data["keep_open"] - assert len(data["slots"]) == 3 - assert data["slots"][0]["start"] == DAY1 + "T09:00:00" - assert data["slots"][0]["duration"] == 60 - assert data["slots"][1]["start"] == DAY2 + "T09:00:00" - assert data["slots"][1]["duration"] == 15 - assert data["slots"][2]["start"] == DAY3 + "T09:00:00" - assert data["slots"][2]["duration"] == 275 + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == generated_calendar.id + assert data['title'] == 'Appointment' + assert data['duration'] == 180 + assert data['location_type'] == 2 + assert data['location_name'] == 'Location' + assert data['location_url'] == 'https://test.org' + assert data['location_phone'] == '+123456789' + assert data['details'] == 'Lorem Ipsum' + assert data['slug'] is not None, len(data['slug']) > 8 + assert data['status'] == 2 + assert data['keep_open'] + assert len(data['slots']) == 3 + assert data['slots'][0]['start'] == DAY1 + 'T09:00:00' + assert data['slots'][0]['duration'] == 60 + assert data['slots'][1]['start'] == DAY2 + 'T09:00:00' + assert data['slots'][1]['duration'] == 15 + assert data['slots'][2]['start'] == DAY3 + 'T09:00:00' + assert data['slots'][2]['duration'] == 275 def test_create_appointment_on_unconnected_calendar(self, with_client, make_caldav_calendar): # They're unconnected by default, but let's be explicit for the test's sake. generated_calendar = make_caldav_calendar(connected=False) response = with_client.post( - "/apmt", + '/apmt', json={ - "appointment": {"calendar_id": generated_calendar.id, "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + 'appointment': {'calendar_id': generated_calendar.id, 'title': 'a', 'duration': 30}, + 'slots': [{'start': DAY1 + ' 09:00:00', 'duration': 30}], }, headers=auth_headers, ) @@ -75,10 +75,10 @@ def test_create_appointment_on_missing_calendar(self, with_client, make_caldav_c generated_calendar = make_caldav_calendar(connected=True) response = with_client.post( - "/apmt", + '/apmt', json={ - "appointment": {"calendar_id": generated_calendar.id + 1, "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + 'appointment': {'calendar_id': generated_calendar.id + 1, 'title': 'a', 'duration': 30}, + 'slots': [{'start': DAY1 + ' 09:00:00', 'duration': 30}], }, headers=auth_headers, ) @@ -89,10 +89,10 @@ def test_create_appointment_on_foreign_calendar(self, with_client, make_caldav_c generated_calendar = make_caldav_calendar(the_other_guy.id) response = with_client.post( - "/apmt", + '/apmt', json={ - "appointment": {"calendar_id": generated_calendar.id, "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + 'appointment': {'calendar_id': generated_calendar.id, 'title': 'a', 'duration': 30}, + 'slots': [{'start': DAY1 + ' 09:00:00', 'duration': 30}], }, headers=auth_headers, ) @@ -101,56 +101,56 @@ def test_create_appointment_on_foreign_calendar(self, with_client, make_caldav_c def test_read_appointments(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.get("/me/appointments", headers=auth_headers) + response = with_client.get('/me/appointments', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 1 data = data[0] - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == generated_appointment.calendar_id - assert data["title"] == generated_appointment.title - assert data["duration"] == generated_appointment.duration - assert data["location_type"] == generated_appointment.location_type.value - assert data["location_name"] == generated_appointment.location_name - assert data["location_url"] == generated_appointment.location_url - assert data["location_phone"] == generated_appointment.location_phone - assert data["details"] == generated_appointment.details - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == generated_appointment.status.value - assert data["keep_open"] - assert len(data["slots"]) == len(generated_appointment.slots) - assert data["slots"][0]["start"] == self.date_time_to_str(generated_appointment.slots[0].start) - assert data["slots"][0]["duration"] == generated_appointment.slots[0].duration + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == generated_appointment.calendar_id + assert data['title'] == generated_appointment.title + assert data['duration'] == generated_appointment.duration + assert data['location_type'] == generated_appointment.location_type.value + assert data['location_name'] == generated_appointment.location_name + assert data['location_url'] == generated_appointment.location_url + assert data['location_phone'] == generated_appointment.location_phone + assert data['details'] == generated_appointment.details + assert data['slug'] is not None, len(data['slug']) > 8 + assert data['status'] == generated_appointment.status.value + assert data['keep_open'] + assert len(data['slots']) == len(generated_appointment.slots) + assert data['slots'][0]['start'] == self.date_time_to_str(generated_appointment.slots[0].start) + assert data['slots'][0]['duration'] == generated_appointment.slots[0].duration def test_read_existing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.get(f"/apmt/{generated_appointment.id}", headers=auth_headers) + response = with_client.get(f'/apmt/{generated_appointment.id}', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == generated_appointment.calendar_id - assert data["title"] == generated_appointment.title - assert data["duration"] == generated_appointment.duration - assert data["location_type"] == generated_appointment.location_type.value - assert data["location_name"] == generated_appointment.location_name - assert data["location_url"] == generated_appointment.location_url - assert data["location_phone"] == generated_appointment.location_phone - assert data["details"] == generated_appointment.details - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == generated_appointment.status.value - assert data["keep_open"] - assert len(data["slots"]) == len(generated_appointment.slots) - assert data["slots"][0]["start"] == self.date_time_to_str(generated_appointment.slots[0].start) - assert data["slots"][0]["duration"] == generated_appointment.slots[0].duration + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == generated_appointment.calendar_id + assert data['title'] == generated_appointment.title + assert data['duration'] == generated_appointment.duration + assert data['location_type'] == generated_appointment.location_type.value + assert data['location_name'] == generated_appointment.location_name + assert data['location_url'] == generated_appointment.location_url + assert data['location_phone'] == generated_appointment.location_phone + assert data['details'] == generated_appointment.details + assert data['slug'] is not None, len(data['slug']) > 8 + assert data['status'] == generated_appointment.status.value + assert data['keep_open'] + assert len(data['slots']) == len(generated_appointment.slots) + assert data['slots'][0]['start'] == self.date_time_to_str(generated_appointment.slots[0].start) + assert data['slots'][0]['duration'] == generated_appointment.slots[0].duration def test_read_missing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.get(f"/apmt/{generated_appointment.id + 1}", headers=auth_headers) + response = with_client.get(f'/apmt/{generated_appointment.id + 1}', headers=auth_headers) assert response.status_code == 404, response.text def test_read_foreign_appointment(self, with_client, make_appointment, make_pro_subscriber, make_caldav_calendar): @@ -158,66 +158,66 @@ def test_read_foreign_appointment(self, with_client, make_appointment, make_pro_ generated_calendar = make_caldav_calendar(the_other_guy.id) generated_appointment = make_appointment(calendar_id=generated_calendar.id) - response = with_client.get(f"/apmt/{generated_appointment.id}", headers=auth_headers) + response = with_client.get(f'/apmt/{generated_appointment.id}', headers=auth_headers) assert response.status_code == 403, response.text def test_update_existing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() response = with_client.put( - f"/apmt/{generated_appointment.id}", + f'/apmt/{generated_appointment.id}', json={ - "appointment": { - "calendar_id": 4, - "title": "Appointmentx", - "duration": 90, - "location_type": 1, - "location_name": "Locationx", - "location_url": "https://testx.org", - "location_phone": "+1234567890", - "details": "Lorem Ipsumx", - "status": 1, - "keep_open": False, + 'appointment': { + 'calendar_id': 4, + 'title': 'Appointmentx', + 'duration': 90, + 'location_type': 1, + 'location_name': 'Locationx', + 'location_url': 'https://testx.org', + 'location_phone': '+1234567890', + 'details': 'Lorem Ipsumx', + 'status': 1, + 'keep_open': False, }, - "slots": [ - {"start": DAY1 + " 11:00:00", "duration": 30}, - {"start": DAY2 + " 11:00:00", "duration": 30}, - {"start": DAY3 + " 11:00:00", "duration": 30}, + 'slots': [ + {'start': DAY1 + ' 11:00:00', 'duration': 30}, + {'start': DAY2 + ' 11:00:00', 'duration': 30}, + {'start': DAY3 + ' 11:00:00', 'duration': 30}, ], }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == 4 - assert data["title"] == "Appointmentx" - assert data["duration"] == 90 - assert data["location_type"] == 1 - assert data["location_name"] == "Locationx" - assert data["location_url"] == "https://testx.org" - assert data["location_phone"] == "+1234567890" - assert data["details"] == "Lorem Ipsumx" - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == 1 - assert not data["keep_open"] - assert len(data["slots"]) == 3 - assert data["slots"][0]["start"] == DAY1 + "T11:00:00" - assert data["slots"][0]["duration"] == 30 - assert data["slots"][1]["start"] == DAY2 + "T11:00:00" - assert data["slots"][1]["duration"] == 30 - assert data["slots"][2]["start"] == DAY3 + "T11:00:00" - assert data["slots"][2]["duration"] == 30 + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == 4 + assert data['title'] == 'Appointmentx' + assert data['duration'] == 90 + assert data['location_type'] == 1 + assert data['location_name'] == 'Locationx' + assert data['location_url'] == 'https://testx.org' + assert data['location_phone'] == '+1234567890' + assert data['details'] == 'Lorem Ipsumx' + assert data['slug'] is not None, len(data['slug']) > 8 + assert data['status'] == 1 + assert not data['keep_open'] + assert len(data['slots']) == 3 + assert data['slots'][0]['start'] == DAY1 + 'T11:00:00' + assert data['slots'][0]['duration'] == 30 + assert data['slots'][1]['start'] == DAY2 + 'T11:00:00' + assert data['slots'][1]['duration'] == 30 + assert data['slots'][2]['start'] == DAY3 + 'T11:00:00' + assert data['slots'][2]['duration'] == 30 def test_update_missing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() response = with_client.put( - f"/apmt/{generated_appointment.id + 1}", + f'/apmt/{generated_appointment.id + 1}', json={ - "appointment": {"calendar_id": "2", "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + 'appointment': {'calendar_id': '2', 'title': 'a', 'duration': 30}, + 'slots': [{'start': DAY1 + ' 09:00:00', 'duration': 30}], }, headers=auth_headers, ) @@ -229,10 +229,10 @@ def test_update_foreign_appointment(self, with_client, make_pro_subscriber, make generated_appointment = make_appointment(calendar_id=generated_calendar.id) response = with_client.put( - f"/apmt/{generated_appointment.id}", + f'/apmt/{generated_appointment.id}', json={ - "appointment": {"calendar_id": "2", "title": "a", "duration": 30}, - "slots": [{"start": DAY1 + " 09:00:00", "duration": 30}], + 'appointment': {'calendar_id': '2', 'title': 'a', 'duration': 30}, + 'slots': [{'start': DAY1 + ' 09:00:00', 'duration': 30}], }, headers=auth_headers, ) @@ -241,39 +241,39 @@ def test_update_foreign_appointment(self, with_client, make_pro_subscriber, make def test_delete_existing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.delete(f"/apmt/{generated_appointment.id}", headers=auth_headers) + response = with_client.delete(f'/apmt/{generated_appointment.id}', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == generated_appointment.calendar_id - assert data["title"] == generated_appointment.title - assert data["duration"] == generated_appointment.duration - assert data["location_type"] == generated_appointment.location_type.value - assert data["location_name"] == generated_appointment.location_name - assert data["location_url"] == generated_appointment.location_url - assert data["location_phone"] == generated_appointment.location_phone - assert data["details"] == generated_appointment.details - assert data["slug"] is not None, len(data["slug"]) > 8 - assert data["status"] == generated_appointment.status.value - assert data["keep_open"] - assert len(data["slots"]) == len(generated_appointment.slots) - assert data["slots"][0]["start"] == self.date_time_to_str(generated_appointment.slots[0].start) - assert data["slots"][0]["duration"] == generated_appointment.slots[0].duration - - response = with_client.get(f"/apmt/{generated_appointment.id}", headers=auth_headers) + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == generated_appointment.calendar_id + assert data['title'] == generated_appointment.title + assert data['duration'] == generated_appointment.duration + assert data['location_type'] == generated_appointment.location_type.value + assert data['location_name'] == generated_appointment.location_name + assert data['location_url'] == generated_appointment.location_url + assert data['location_phone'] == generated_appointment.location_phone + assert data['details'] == generated_appointment.details + assert data['slug'] is not None, len(data['slug']) > 8 + assert data['status'] == generated_appointment.status.value + assert data['keep_open'] + assert len(data['slots']) == len(generated_appointment.slots) + assert data['slots'][0]['start'] == self.date_time_to_str(generated_appointment.slots[0].start) + assert data['slots'][0]['duration'] == generated_appointment.slots[0].duration + + response = with_client.get(f'/apmt/{generated_appointment.id}', headers=auth_headers) assert response.status_code == 404, response.text - response = with_client.get("/me/appointments", headers=auth_headers) + response = with_client.get('/me/appointments', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 0 def test_delete_missing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.delete(f"/apmt/{generated_appointment.id + 1}", headers=auth_headers) + response = with_client.delete(f'/apmt/{generated_appointment.id + 1}', headers=auth_headers) assert response.status_code == 404, response.text def test_delete_foreign_appointment(self, with_client, make_pro_subscriber, make_caldav_calendar, make_appointment): @@ -281,32 +281,34 @@ def test_delete_foreign_appointment(self, with_client, make_pro_subscriber, make generated_calendar = make_caldav_calendar(the_other_guy.id) generated_appointment = make_appointment(calendar_id=generated_calendar.id) - response = with_client.delete(f"/apmt/{generated_appointment.id}", headers=auth_headers) + response = with_client.delete(f'/apmt/{generated_appointment.id}', headers=auth_headers) assert response.status_code == 403, response.text def test_read_public_existing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.get(f"/apmt/public/{generated_appointment.slug}") + response = with_client.get(f'/apmt/public/{generated_appointment.slug}') assert response.status_code == 200, response.text data = response.json() - assert "calendar_id" not in data - assert "status" not in data - assert data["title"] == generated_appointment.title - assert data["details"] == generated_appointment.details - assert data["slug"] == generated_appointment.slug - assert data["owner_name"] == generated_appointment.calendar.owner.name - assert len(data["slots"]) == len(generated_appointment.slots) - assert data["slots"][0]["start"] == self.date_time_to_str(generated_appointment.slots[0].start) - assert data["slots"][0]["duration"] == generated_appointment.slots[0].duration + assert 'calendar_id' not in data + assert 'status' not in data + assert data['title'] == generated_appointment.title + assert data['details'] == generated_appointment.details + assert data['slug'] == generated_appointment.slug + assert data['owner_name'] == generated_appointment.calendar.owner.name + assert len(data['slots']) == len(generated_appointment.slots) + assert data['slots'][0]['start'] == self.date_time_to_str(generated_appointment.slots[0].start) + assert data['slots'][0]['duration'] == generated_appointment.slots[0].duration def test_read_public_missing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.get(f"/apmt/public/{generated_appointment.slug}-that-isnt-real") + response = with_client.get(f'/apmt/public/{generated_appointment.slug}-that-isnt-real') assert response.status_code == 404, response.text - def test_read_public_appointment_after_attendee_selection(self, with_db, with_client, make_appointment, make_attendee, make_appointment_slot): + def test_read_public_appointment_after_attendee_selection( + self, with_db, with_client, make_appointment, make_attendee, make_appointment_slot + ): generated_appointment = make_appointment() generated_attendee = make_attendee() make_appointment_slot(generated_appointment.id, attendee_id=generated_attendee.id) @@ -314,65 +316,76 @@ 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 appointment.database import models + generated_appointment = db.get(models.Appointment, generated_appointment.id) # Reload slots generated_appointment.slots - response = with_client.get(f"/apmt/public/{generated_appointment.slug}") + response = with_client.get(f'/apmt/public/{generated_appointment.slug}') assert response.status_code == 200, response.text data = response.json() - assert len(data["slots"]) == len(generated_appointment.slots) - assert data["slots"][-1]["attendee_id"] == generated_attendee.id + assert len(data['slots']) == len(generated_appointment.slots) + assert data['slots'][-1]['attendee_id'] == generated_attendee.id 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!""" + """Test against a fake remote caldav, we're testing the route controller + not the actual caldav connector here! + """ from appointment.controller.calendar import CalDavConnector + generated_appointment = make_appointment() def list_events(self, start, end): end = dateutil.parser.parse(end) from appointment.database import schemas - print("list events!") - return [schemas.Event( - title=generated_appointment.title, - start=generated_appointment.slots[0].start, - end=end, - all_day=False, - description=generated_appointment.details, - calendar_title=generated_appointment.calendar.title, - calendar_color=generated_appointment.calendar.color - )] - - monkeypatch.setattr(CalDavConnector, "list_events", list_events) - - path = f"/rmt/cal/{generated_appointment.calendar_id}/" + DAY1 + "/" + DAY3 - print(f">>> {path}") + + print('list events!') + return [ + schemas.Event( + title=generated_appointment.title, + start=generated_appointment.slots[0].start, + end=end, + all_day=False, + description=generated_appointment.details, + calendar_title=generated_appointment.calendar.title, + calendar_color=generated_appointment.calendar.color, + ) + ] + + monkeypatch.setattr(CalDavConnector, 'list_events', list_events) + + path = f'/rmt/cal/{generated_appointment.calendar_id}/' + DAY1 + '/' + DAY3 + print(f'>>> {path}') response = with_client.get(path, headers=auth_headers) assert response.status_code == 200, response.text data = response.json() assert len(data) == 1 - assert data[0]["title"] == generated_appointment.title - assert data[0]["start"] == generated_appointment.slots[0].start.isoformat() - assert data[0]["end"] == dateutil.parser.parse(DAY3).isoformat() + assert data[0]['title'] == generated_appointment.title + assert data[0]['start'] == generated_appointment.slots[0].start.isoformat() + assert data[0]['end'] == dateutil.parser.parse(DAY3).isoformat() def test_get_invitation_ics_file(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.get(f"/apmt/serve/ics/{generated_appointment.slug}/{generated_appointment.slots[0].id}") + response = with_client.get(f'/apmt/serve/ics/{generated_appointment.slug}/{generated_appointment.slots[0].id}') assert response.status_code == 200, response.text data = response.json() - assert data["name"] == "invite" - assert data["content_type"] == "text/calendar" - assert "data" in data + assert data['name'] == 'invite' + assert data['content_type'] == 'text/calendar' + assert 'data' in data def test_get_invitation_ics_file_for_missing_appointment(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.get(f"/apmt/serve/ics/{generated_appointment.slug}-doesnt-exist/{generated_appointment.slots[0].id}") + response = with_client.get( + f'/apmt/serve/ics/{generated_appointment.slug}-doesnt-exist/{generated_appointment.slots[0].id}' + ) assert response.status_code == 404, response.text def test_get_invitation_ics_file_for_missing_slot(self, with_client, make_appointment): generated_appointment = make_appointment() - response = with_client.get(f"/apmt/serve/ics/{generated_appointment.slug}/{generated_appointment.slots[0].id + 1}") + response = with_client.get( + f'/apmt/serve/ics/{generated_appointment.slug}/{generated_appointment.slots[0].id + 1}' + ) assert response.status_code == 404, response.text diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index 77914b271..2a51bc23e 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -23,36 +23,31 @@ def test_permission_check_with_deleted_subscriber(self, with_client, with_db): db.delete(subscriber) db.commit() - response = with_client.post('/permission-check', - headers=auth_headers) + response = with_client.post('/permission-check', headers=auth_headers) assert response.status_code == 401, response.text def test_permission_check_with_no_admin_email(self, with_client): os.environ['APP_ADMIN_ALLOW_LIST'] = '' - response = with_client.post('/permission-check', - headers=auth_headers) + response = with_client.post('/permission-check', headers=auth_headers) assert response.status_code == 401, response.text def test_permission_check_with_wrong_admin_email(self, with_client): os.environ['APP_ADMIN_ALLOW_LIST'] = '@notexample.org' - response = with_client.post('/permission-check', - headers=auth_headers) + response = with_client.post('/permission-check', headers=auth_headers) assert response.status_code == 401, response.text def test_permission_check_with_correct_admin_email(self, with_client): os.environ['APP_ADMIN_ALLOW_LIST'] = f"@{os.getenv('TEST_USER_EMAIL').split('@')[1]}" - response = with_client.post('/permission-check', - headers=auth_headers) + response = with_client.post('/permission-check', headers=auth_headers) assert response.status_code == 200, response.text def test_permission_check_with_correct_full_admin_email(self, with_client): os.environ['APP_ADMIN_ALLOW_LIST'] = os.getenv('TEST_USER_EMAIL') - response = with_client.post('/permission-check', - headers=auth_headers) + response = with_client.post('/permission-check', headers=auth_headers) assert response.status_code == 200, response.text @@ -66,34 +61,25 @@ def test_token(self, with_db, with_client, make_pro_subscriber): # Test good credentials response = with_client.post( - "/token", - data={ - 'username': subscriber.username, - 'password': password - }, + '/token', + data={'username': subscriber.username, 'password': password}, ) assert response.status_code == 200, response.text data = response.json() - assert data["access_token"] - assert data["token_type"] == 'bearer' + assert data['access_token'] + assert data['token_type'] == 'bearer' # Test bad credentials response = with_client.post( - "/token", - data={ - 'username': subscriber.username, - 'password': bad_password - }, + '/token', + data={'username': subscriber.username, 'password': bad_password}, ) assert response.status_code == 403, response.text # Test credentials with non-existent user response = with_client.post( - "/token", - data={ - 'username': subscriber.username + "1", - 'password': password - }, + '/token', + data={'username': subscriber.username + '1', 'password': password}, ) assert response.status_code == 403, response.text @@ -102,7 +88,7 @@ class TestFXA: def test_fxa_login(self, with_client): os.environ['AUTH_SCHEME'] = 'fxa' response = with_client.get( - "/fxa_login", + '/fxa_login', params={ 'email': FXA_CLIENT_PATCH.get('subscriber_email'), }, @@ -123,20 +109,18 @@ def test_fxa_callback_with_invite(self, with_db, with_client, monkeypatch, make_ with with_db() as db: assert not repo.subscriber.get_by_email(db, FXA_CLIENT_PATCH.get('subscriber_email')) - monkeypatch.setattr('starlette.requests.HTTPConnection.session', { - 'fxa_state': state, - 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), - 'fxa_user_timezone': 'America/Vancouver', - 'fxa_user_invite_code': invite.code, - }) + monkeypatch.setattr( + 'starlette.requests.HTTPConnection.session', + { + 'fxa_state': state, + 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), + 'fxa_user_timezone': 'America/Vancouver', + 'fxa_user_invite_code': invite.code, + }, + ) response = with_client.get( - "/fxa", - params={ - 'code': FXA_CLIENT_PATCH.get('credentials_code'), - 'state': state - }, - follow_redirects=False + '/fxa', params={'code': FXA_CLIENT_PATCH.get('credentials_code'), 'state': state}, follow_redirects=False ) # This is a redirect request assert response.status_code == 307, response.text @@ -160,19 +144,17 @@ def test_fxa_callback_with_allowlist(self, with_db, with_client, monkeypatch): state = 'a1234' - monkeypatch.setattr('starlette.requests.HTTPConnection.session', { - 'fxa_state': state, - 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), - 'fxa_user_timezone': 'America/Vancouver', - }) + monkeypatch.setattr( + 'starlette.requests.HTTPConnection.session', + { + 'fxa_state': state, + 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), + 'fxa_user_timezone': 'America/Vancouver', + }, + ) response = with_client.get( - "/fxa", - params={ - 'code': FXA_CLIENT_PATCH.get('credentials_code'), - 'state': state - }, - follow_redirects=False + '/fxa', params={'code': FXA_CLIENT_PATCH.get('credentials_code'), 'state': state}, follow_redirects=False ) # This is a redirect request assert response.status_code == 307, response.text @@ -196,19 +178,17 @@ def test_fxa_callback_no_invite_or_allowlist(self, with_db, with_client, monkeyp state = 'a1234' - monkeypatch.setattr('starlette.requests.HTTPConnection.session', { - 'fxa_state': state, - 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), - 'fxa_user_timezone': 'America/Vancouver', - }) + monkeypatch.setattr( + 'starlette.requests.HTTPConnection.session', + { + 'fxa_state': state, + 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), + 'fxa_user_timezone': 'America/Vancouver', + }, + ) response = with_client.get( - "/fxa", - params={ - 'code': FXA_CLIENT_PATCH.get('credentials_code'), - 'state': state - }, - follow_redirects=False + '/fxa', params={'code': FXA_CLIENT_PATCH.get('credentials_code'), 'state': state}, follow_redirects=False ) # 404, invite code not found assert response.status_code == 404, response.text @@ -217,26 +197,24 @@ def test_fxa_callback_no_invite_or_allowlist(self, with_db, with_client, monkeyp subscriber = repo.subscriber.get_by_email(db, FXA_CLIENT_PATCH.get('subscriber_email')) assert not subscriber - def test_fxa_callback_with_allowlist(self, with_db, with_client, monkeypatch): + def test_fxa_callback_with_allowlist_again(self, with_db, with_client, monkeypatch): """Test that our callback function correctly handles the session states, and creates a new subscriber""" os.environ['AUTH_SCHEME'] = 'fxa' os.environ['FXA_ALLOW_LIST'] = '@example.org' state = 'a1234' - monkeypatch.setattr('starlette.requests.HTTPConnection.session', { - 'fxa_state': state, - 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), - 'fxa_user_timezone': 'America/Vancouver', - }) + monkeypatch.setattr( + 'starlette.requests.HTTPConnection.session', + { + 'fxa_state': state, + 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), + 'fxa_user_timezone': 'America/Vancouver', + }, + ) response = with_client.get( - "/fxa", - params={ - 'code': FXA_CLIENT_PATCH.get('credentials_code'), - 'state': state - }, - follow_redirects=False + '/fxa', params={'code': FXA_CLIENT_PATCH.get('credentials_code'), 'state': state}, follow_redirects=False ) # This is a redirect request assert response.status_code == 307, response.text @@ -250,9 +228,12 @@ def test_fxa_callback_with_allowlist(self, with_db, with_client, monkeypatch): assert fxa assert fxa.type_id == FXA_CLIENT_PATCH.get('external_connection_type_id') - def test_fxa_callback_with_mismatch_uid(self, with_db, with_client, monkeypatch, make_external_connections, - make_basic_subscriber, with_l10n): - """Test that our fxa callback will throw an invalid-credentials error if the incoming fxa uid doesn't match any existing ones.""" + def test_fxa_callback_with_mismatch_uid( + self, with_db, with_client, monkeypatch, make_external_connections, make_basic_subscriber, with_l10n + ): + """Test that our fxa callback will throw an invalid-credentials error + if the incoming fxa uid doesn't match any existing ones. + """ os.environ['AUTH_SCHEME'] = 'fxa' state = 'a1234' @@ -262,19 +243,17 @@ def test_fxa_callback_with_mismatch_uid(self, with_db, with_client, monkeypatch, mismatch_uid = f"{FXA_CLIENT_PATCH.get('external_connection_type_id')}-not-actually" make_external_connections(subscriber.id, type=models.ExternalConnectionType.fxa, type_id=mismatch_uid) - monkeypatch.setattr('starlette.requests.HTTPConnection.session', { - 'fxa_state': state, - 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), - 'fxa_user_timezone': 'America/Vancouver' - }) + monkeypatch.setattr( + 'starlette.requests.HTTPConnection.session', + { + 'fxa_state': state, + 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), + 'fxa_user_timezone': 'America/Vancouver', + }, + ) response = with_client.get( - "/fxa", - params={ - 'code': FXA_CLIENT_PATCH.get('credentials_code'), - 'state': state - }, - follow_redirects=False + '/fxa', params={'code': FXA_CLIENT_PATCH.get('credentials_code'), 'state': state}, follow_redirects=False ) # This should error out as a 403 diff --git a/backend/test/integration/test_calendar.py b/backend/test/integration/test_calendar.py index d9d4a8566..e9cd14af5 100644 --- a/backend/test/integration/test_calendar.py +++ b/backend/test/integration/test_calendar.py @@ -17,7 +17,7 @@ def get_calendar_factory(): It's ugly, but `parametrize` doesn't support fixtures...""" providers = { schemas.CalendarProvider.caldav: 'make_caldav_calendar', - schemas.CalendarProvider.google: 'make_google_calendar' + schemas.CalendarProvider.google: 'make_google_calendar', } for provider, factory_name in providers.items(): yield provider, factory_name @@ -25,8 +25,8 @@ def get_calendar_factory(): def get_mock_connector_class(): """Provide two fake connectors, the original connector (for monkeypatching), and the test data""" - test_url = "https://caldav.thunderbird.net/" - test_user = "thunderbird" + test_url = 'https://caldav.thunderbird.net/' + test_user = 'thunderbird' class MockCaldavConnector: @staticmethod @@ -37,31 +37,32 @@ def __init__(self, subscriber_id, calendar_id, redis_instance, url, user, passwo @staticmethod def list_calendars(self): return [ - schemas.CalendarConnectionOut( - provider=schemas.CalendarProvider.caldav, - url=test_url, - user=test_user - ) + schemas.CalendarConnectionOut(provider=schemas.CalendarProvider.caldav, url=test_url, user=test_user) ] class MockGoogleConnector: @staticmethod - def __init__(self, subscriber_id, calendar_id, redis_instance, db, remote_calendar_id, google_client, google_tkn: str = None): + def __init__( + self, + subscriber_id, + calendar_id, + redis_instance, + db, + remote_calendar_id, + google_client, + google_tkn: str = None, + ): pass @staticmethod def list_calendars(self): return [ - schemas.CalendarConnectionOut( - provider=schemas.CalendarProvider.google, - url=test_url, - user=test_user - ) + schemas.CalendarConnectionOut(provider=schemas.CalendarProvider.google, url=test_url, user=test_user) ] connectors = [ (MockCaldavConnector, CalDavConnector, schemas.CalendarProvider.caldav.value, test_url, test_user), - (MockGoogleConnector, GoogleConnector, schemas.CalendarProvider.google.value, test_url, test_user) + (MockGoogleConnector, GoogleConnector, schemas.CalendarProvider.google.value, test_url, test_user), ] for connector in connectors: @@ -70,330 +71,346 @@ def list_calendars(self): class TestCalendar: - @pytest.mark.parametrize("mock_connector,connector,provider,test_url,test_user", get_mock_connector_class()) - def test_read_remote_calendars(self, monkeypatch, with_client, mock_connector, connector, provider, test_url, test_user, make_external_connections): - + @pytest.mark.parametrize('mock_connector,connector,provider,test_url,test_user', get_mock_connector_class()) + def test_read_remote_calendars( + self, + monkeypatch, + with_client, + mock_connector, + connector, + provider, + test_url, + test_user, + make_external_connections, + ): # Ensure we have an external connection for google if provider == schemas.CalendarProvider.google.value: make_external_connections(TEST_USER_ID, type=schemas.ExternalConnectionType.google) # Patch up the caldav constructor, and list_calendars - monkeypatch.setattr(connector, "__init__", mock_connector.__init__) - monkeypatch.setattr(connector, "list_calendars", mock_connector.list_calendars) + monkeypatch.setattr(connector, '__init__', mock_connector.__init__) + monkeypatch.setattr(connector, 'list_calendars', mock_connector.list_calendars) response = with_client.post( - "/rmt/calendars", + '/rmt/calendars', json={ - "provider": provider, - "url": test_url, - "user": test_user, - "password": "caw", + 'provider': provider, + 'url': test_url, + 'user': test_user, + 'password': 'caw', }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) > 0 - assert any(c["url"] == test_url for c in data) - assert any(c["provider"] == provider for c in data) + assert any(c['url'] == test_url for c in data) + assert any(c['provider'] == provider for c in data) def test_read_connected_calendars_before_creation(self, with_client): - response = with_client.get("/me/calendars", headers=auth_headers) + response = with_client.get('/me/calendars', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 0 def test_read_unconnected_calendars_before_creation(self, with_client): - response = with_client.get("/me/calendars", params={"only_connected": False}, headers=auth_headers) + response = with_client.get('/me/calendars', params={'only_connected': False}, headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 0 - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_read_connected_calendars_after_creation(self, with_client, provider, factory_name, request): """Get /me/calendars and ensure the list is 0 (as it only returns connected calendars by default)""" # Get the fixture by name calendar_factory = request.getfixturevalue(factory_name) calendar_factory() - response = with_client.get("/me/calendars", headers=auth_headers) + response = with_client.get('/me/calendars', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 0 - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_read_unconnected_calendars_after_creation(self, with_client, provider, factory_name, request): """Get /me/calendars and ensure the list is 1 (as we're explicitly asking for unconnected calendars too)""" generated_calendar = request.getfixturevalue(factory_name)() - response = with_client.get("/me/calendars", params={"only_connected": False}, headers=auth_headers) + response = with_client.get('/me/calendars', params={'only_connected': False}, headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 1 calendar = data[0] - assert calendar["title"] == generated_calendar.title - assert calendar["color"] == generated_calendar.color - assert not calendar["connected"] - assert "url" not in calendar - assert "user" not in calendar - assert "password" not in calendar - - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + assert calendar['title'] == generated_calendar.title + assert calendar['color'] == generated_calendar.color + assert not calendar['connected'] + assert 'url' not in calendar + assert 'user' not in calendar + assert 'password' not in calendar + + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_read_existing_caldav_calendar(self, with_client, provider, factory_name, request): generated_calendar = request.getfixturevalue(factory_name)() - response = with_client.get("/cal/1", headers=auth_headers) + response = with_client.get('/cal/1', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert data["title"] == generated_calendar.title - assert data["color"] == generated_calendar.color - assert data["provider"] == provider.value - assert data["url"] == generated_calendar.url - assert data["user"] == generated_calendar.user - assert not data["connected"] - assert "password" not in data - - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + assert data['title'] == generated_calendar.title + assert data['color'] == generated_calendar.color + assert data['provider'] == provider.value + assert data['url'] == generated_calendar.url + assert data['user'] == generated_calendar.user + assert not data['connected'] + assert 'password' not in data + + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_read_missing_calendar(self, with_client, provider, factory_name, request): generated_calendar = request.getfixturevalue(factory_name)() # Intentionally read the wrong calendar id - response = with_client.get(f"/cal/{generated_calendar.id + 1}", headers=auth_headers) + response = with_client.get(f'/cal/{generated_calendar.id + 1}', headers=auth_headers) assert response.status_code == 404, response.text - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_read_foreign_calendar(self, with_client, make_pro_subscriber, provider, factory_name, request): """Ensure we can't read other peoples calendars""" the_other_guy = make_pro_subscriber() generated_calendar = request.getfixturevalue(factory_name)(the_other_guy.id) - response = with_client.get(f"/cal/{generated_calendar.id}", headers=auth_headers) + response = with_client.get(f'/cal/{generated_calendar.id}', headers=auth_headers) assert response.status_code == 403, response.text - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_update_foreign_calendar(self, with_client, make_pro_subscriber, provider, factory_name, request): the_other_guy = make_pro_subscriber() generated_calendar = request.getfixturevalue(factory_name)(the_other_guy.id) - response = with_client.put(f"/cal/{generated_calendar.id}", json={"title": "b", "url": "b", "user": "b", "password": "b"}, headers=auth_headers) + response = with_client.put( + f'/cal/{generated_calendar.id}', + json={'title': 'b', 'url': 'b', 'user': 'b', 'password': 'b'}, + headers=auth_headers, + ) assert response.status_code == 403, response.text - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_connect_calendar(self, with_client, provider, factory_name, request): generated_calendar = request.getfixturevalue(factory_name)() assert generated_calendar.connected is False - response = with_client.post(f"/cal/{generated_calendar.id}/connect", headers=auth_headers) + response = with_client.post(f'/cal/{generated_calendar.id}/connect', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert data["title"] == generated_calendar.title - assert data["color"] == generated_calendar.color - assert data["connected"] - assert "url" not in data - assert "user" not in data - assert "password" not in data - - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + assert data['title'] == generated_calendar.title + assert data['color'] == generated_calendar.color + assert data['connected'] + assert 'url' not in data + assert 'user' not in data + assert 'password' not in data + + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_connect_missing_calendar(self, with_client, provider, factory_name, request): generated_calendar = request.getfixturevalue(factory_name)() # Intentionally use the wrong calendar id - response = with_client.post(f"/cal/{generated_calendar.id + 1}/connect", headers=auth_headers) + response = with_client.post(f'/cal/{generated_calendar.id + 1}/connect', headers=auth_headers) assert response.status_code == 404, response.text - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_connect_foreign_calendar(self, with_client, make_pro_subscriber, provider, factory_name, request): the_other_guy = make_pro_subscriber() generated_calendar = request.getfixturevalue(factory_name)(the_other_guy.id) - response = with_client.post(f"/cal/{generated_calendar.id}/connect", headers=auth_headers) + response = with_client.post(f'/cal/{generated_calendar.id}/connect', headers=auth_headers) assert response.status_code == 403, response.text - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_read_connected_calendars_after_connection(self, with_client, provider, factory_name, request): generated_calendar = request.getfixturevalue(factory_name)(connected=True) with_client.post( - "/cal", + '/cal', json={ - "title": "Second CalDAV calendar", - "color": "#123456", - "provider": CalendarProvider.caldav.value, - "url": "test", - "user": "test", - "password": "test", + 'title': 'Second CalDAV calendar', + 'color': '#123456', + 'provider': CalendarProvider.caldav.value, + 'url': 'test', + 'user': 'test', + 'password': 'test', }, headers=auth_headers, ) - response = with_client.get("/me/calendars", headers=auth_headers) + response = with_client.get('/me/calendars', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 1 calendar = data[0] - assert calendar["title"] == generated_calendar.title - assert calendar["color"] == generated_calendar.color - assert calendar["connected"] - assert "url" not in calendar - assert "user" not in calendar - assert "password" not in calendar - - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + assert calendar['title'] == generated_calendar.title + assert calendar['color'] == generated_calendar.color + assert calendar['connected'] + assert 'url' not in calendar + assert 'user' not in calendar + assert 'password' not in calendar + + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_read_unconnected_calendars_after_connection(self, with_client, provider, factory_name, request): request.getfixturevalue(factory_name)(connected=True) request.getfixturevalue(factory_name)(connected=False) - response = with_client.get("/me/calendars", params={"only_connected": False}, headers=auth_headers) + response = with_client.get('/me/calendars', params={'only_connected': False}, headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 2 - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_delete_existing_calendar(self, with_client, provider, factory_name, request): generated_calendar = request.getfixturevalue(factory_name)(connected=True) - response = with_client.delete(f"/cal/{generated_calendar.id}", headers=auth_headers) + response = with_client.delete(f'/cal/{generated_calendar.id}', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert data["title"] == generated_calendar.title - assert data["color"] == generated_calendar.color - assert data["connected"] - assert "url" not in data - assert "user" not in data - assert "password" not in data - - response = with_client.get(f"/cal/{generated_calendar.id}", headers=auth_headers) + assert data['title'] == generated_calendar.title + assert data['color'] == generated_calendar.color + assert data['connected'] + assert 'url' not in data + assert 'user' not in data + assert 'password' not in data + + response = with_client.get(f'/cal/{generated_calendar.id}', headers=auth_headers) assert response.status_code == 404, response.text - response = with_client.get("/me/calendars", headers=auth_headers) + response = with_client.get('/me/calendars', headers=auth_headers) data = response.json() assert len(data) == 0 - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_delete_missing_calendar(self, with_client, provider, factory_name, request): generated_calendar = request.getfixturevalue(factory_name)() # Intentionally call the wrong id - response = with_client.delete(f"/cal/{generated_calendar.id + 1}", headers=auth_headers) + response = with_client.delete(f'/cal/{generated_calendar.id + 1}', headers=auth_headers) assert response.status_code == 404, response.text - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) def test_delete_foreign_calendar(self, with_client, make_pro_subscriber, provider, factory_name, request): the_other_guy = make_pro_subscriber() generated_calendar = request.getfixturevalue(factory_name)(the_other_guy.id) - response = with_client.delete(f"/cal/{generated_calendar.id}", headers=auth_headers) + response = with_client.delete(f'/cal/{generated_calendar.id}', headers=auth_headers) assert response.status_code == 403, response.text - @pytest.mark.parametrize("provider,factory_name", get_calendar_factory()) - def test_connect_more_calendars_than_tier_allows(self, with_client, with_db, make_basic_subscriber, provider, factory_name, request): + @pytest.mark.parametrize('provider,factory_name', get_calendar_factory()) + def test_connect_more_calendars_than_tier_allows( + self, with_client, with_db, make_basic_subscriber, provider, factory_name, request + ): basic_user = make_basic_subscriber() cal = {} - for i in range(1, int(os.getenv("TIER_BASIC_CALENDAR_LIMIT"))): + for i in range(1, int(os.getenv('TIER_BASIC_CALENDAR_LIMIT'))): cal[i] = request.getfixturevalue(factory_name)(basic_user.id, connected=True) - response = with_client.post(f"/cal/{cal[2].id}/connect", headers=auth_headers) + response = with_client.post(f'/cal/{cal[2].id}/connect', headers=auth_headers) assert response.status_code == 403, response.text class TestCaldav: """Tests for caldav specific functionality""" + def test_create_first_caldav_calendar(self, with_client): response = with_client.post( - "/cal", + '/cal', json={ - "title": "First CalDAV calendar", - "color": "#123456", - "provider": CalendarProvider.caldav.value, - "url": os.getenv("CALDAV_TEST_CALENDAR_URL"), - "user": os.getenv("CALDAV_TEST_USER"), - "password": os.getenv("CALDAV_TEST_PASS"), - "connected": False, + 'title': 'First CalDAV calendar', + 'color': '#123456', + 'provider': CalendarProvider.caldav.value, + 'url': os.getenv('CALDAV_TEST_CALENDAR_URL'), + 'user': os.getenv('CALDAV_TEST_USER'), + 'password': os.getenv('CALDAV_TEST_PASS'), + 'connected': False, }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["title"] == "First CalDAV calendar" - assert data["color"] == "#123456" - assert not data["connected"] - assert "url" not in data - assert "user" not in data - assert "password" not in data + assert data['title'] == 'First CalDAV calendar' + assert data['color'] == '#123456' + assert not data['connected'] + assert 'url' not in data + assert 'user' not in data + assert 'password' not in data def test_update_existing_caldav_calendar_with_password(self, with_client, with_db, make_caldav_calendar): generated_calendar = make_caldav_calendar() response = with_client.put( - f"/cal/{generated_calendar.id}", + f'/cal/{generated_calendar.id}', json={ - "title": "First modified CalDAV calendar", - "color": "#234567", - "url": os.getenv("CALDAV_TEST_CALENDAR_URL") + "x", - "user": os.getenv("CALDAV_TEST_USER") + "x", - "password": os.getenv("CALDAV_TEST_PASS") + "x", - "connected": True, + 'title': 'First modified CalDAV calendar', + 'color': '#234567', + 'url': os.getenv('CALDAV_TEST_CALENDAR_URL') + 'x', + 'user': os.getenv('CALDAV_TEST_USER') + 'x', + 'password': os.getenv('CALDAV_TEST_PASS') + 'x', + 'connected': True, }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["title"] == "First modified CalDAV calendar" - assert data["color"] == "#234567" - assert not data["connected"] - assert "url" not in data - assert "user" not in data - assert "password" not in data + assert data['title'] == 'First modified CalDAV calendar' + assert data['color'] == '#234567' + assert not data['connected'] + assert 'url' not in data + assert 'user' not in data + assert 'password' not in data query = select(models.Calendar).where(models.Calendar.id == generated_calendar.id) with with_db() as db: cal = db.scalars(query).one() - assert cal.url == os.getenv("CALDAV_TEST_CALENDAR_URL") + "x" - assert cal.user == os.getenv("CALDAV_TEST_USER") + "x" - assert cal.password == os.getenv("CALDAV_TEST_PASS") + "x" + assert cal.url == os.getenv('CALDAV_TEST_CALENDAR_URL') + 'x' + assert cal.user == os.getenv('CALDAV_TEST_USER') + 'x' + assert cal.password == os.getenv('CALDAV_TEST_PASS') + 'x' def test_update_existing_caldav_calendar_without_password(self, with_client, with_db, make_caldav_calendar): """Ensure if we put a blank password into the request that the calendar will update with an empty password.""" generated_calendar = make_caldav_calendar(password='') response = with_client.put( - f"/cal/{generated_calendar.id}", + f'/cal/{generated_calendar.id}', json={ - "title": "First modified CalDAV calendar", - "color": "#234567", - "url": os.getenv("CALDAV_TEST_CALENDAR_URL"), - "user": os.getenv("CALDAV_TEST_USER"), - "password": "", - "connected": True, + 'title': 'First modified CalDAV calendar', + 'color': '#234567', + 'url': os.getenv('CALDAV_TEST_CALENDAR_URL'), + 'user': os.getenv('CALDAV_TEST_USER'), + 'password': '', + 'connected': True, }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["title"] == "First modified CalDAV calendar" - assert data["color"] == "#234567" - assert "url" not in data - assert "user" not in data - assert "password" not in data + assert data['title'] == 'First modified CalDAV calendar' + assert data['color'] == '#234567' + assert 'url' not in data + assert 'user' not in data + assert 'password' not in data query = select(models.Calendar).where(models.Calendar.id == generated_calendar.id) with with_db() as db: cal = db.scalars(query).one() - assert cal.url == os.getenv("CALDAV_TEST_CALENDAR_URL") - assert cal.user == os.getenv("CALDAV_TEST_USER") + assert cal.url == os.getenv('CALDAV_TEST_CALENDAR_URL') + assert cal.user == os.getenv('CALDAV_TEST_USER') assert cal.password == '' diff --git a/backend/test/integration/test_general.py b/backend/test/integration/test_general.py index 2df3c1857..0bd06c105 100644 --- a/backend/test/integration/test_general.py +++ b/backend/test/integration/test_general.py @@ -4,82 +4,77 @@ class TestGeneral: def test_config(self): - assert int(os.getenv("TIER_BASIC_CALENDAR_LIMIT")) == 3 - assert int(os.getenv("TIER_PLUS_CALENDAR_LIMIT")) == 5 - assert int(os.getenv("TIER_PRO_CALENDAR_LIMIT")) == 10 - assert os.getenv("TEST_USER_EMAIL") is not None + assert int(os.getenv('TIER_BASIC_CALENDAR_LIMIT')) == 3 + assert int(os.getenv('TIER_PLUS_CALENDAR_LIMIT')) == 5 + assert int(os.getenv('TIER_PRO_CALENDAR_LIMIT')) == 10 + assert os.getenv('TEST_USER_EMAIL') is not None def test_health(self, with_client): # existing root route - response = with_client.get("/") + response = with_client.get('/') assert response.status_code == 200 assert response.json() # undefined route - response = with_client.get("/abcdefg") + 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'}) + 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'}) + 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 == 401 - response = with_client.put("/me") + response = with_client.put('/me') assert response.status_code == 401 - response = with_client.get("/me/calendars") + response = with_client.get('/me/calendars') assert response.status_code == 401 - response = with_client.get("/me/appointments") + response = with_client.get('/me/appointments') assert response.status_code == 401 - response = with_client.get("/me/signature") + response = with_client.get('/me/signature') assert response.status_code == 401 - response = with_client.post("/me/signature") + response = with_client.post('/me/signature') assert response.status_code == 401 - response = with_client.post("/cal") + response = with_client.post('/cal') assert response.status_code == 401 - response = with_client.get("/cal/1") + response = with_client.get('/cal/1') assert response.status_code == 401 - response = with_client.put("/cal/1") + response = with_client.put('/cal/1') assert response.status_code == 401 - response = with_client.post("/cal/1/connect") + response = with_client.post('/cal/1/connect') assert response.status_code == 401 - response = with_client.delete("/cal/1") + response = with_client.delete('/cal/1') assert response.status_code == 401 - response = with_client.post("/rmt/calendars") + response = with_client.post('/rmt/calendars') assert response.status_code == 401 - response = with_client.get("/rmt/cal/1/" + DAY1 + "/" + DAY5) + response = with_client.get('/rmt/cal/1/' + DAY1 + '/' + DAY5) assert response.status_code == 401 - response = with_client.post("/apmt") + response = with_client.post('/apmt') assert response.status_code == 401 - response = with_client.get("/apmt/1") + response = with_client.get('/apmt/1') assert response.status_code == 401 - response = with_client.put("/apmt/1") + response = with_client.put('/apmt/1') assert response.status_code == 401 - response = with_client.delete("/apmt/1") + response = with_client.delete('/apmt/1') assert response.status_code == 401 - response = with_client.post("/rmt/sync") + response = with_client.post('/rmt/sync') assert response.status_code == 401 - response = with_client.get("/account/download") + response = with_client.get('/account/download') assert response.status_code == 401 - response = with_client.delete("/account/delete") + response = with_client.delete('/account/delete') assert response.status_code == 401 - response = with_client.get("/google/auth") + response = with_client.get('/google/auth') assert response.status_code == 401 def test_send_feedback(self, with_client): response = with_client.post( - "/support", - json={ - 'topic': 'Hello World', - 'details': 'Hello World but longer' - }, - headers=auth_headers) + '/support', json={'topic': 'Hello World', 'details': 'Hello World but longer'}, headers=auth_headers + ) assert response.status_code == 200 - diff --git a/backend/test/integration/test_invite.py b/backend/test/integration/test_invite.py index c994107ac..dbf2add50 100644 --- a/backend/test/integration/test_invite.py +++ b/backend/test/integration/test_invite.py @@ -10,10 +10,8 @@ def test_send_invite_email_requires_admin(self, with_db, with_client): os.environ['APP_ADMIN_ALLOW_LIST'] = '@notexample.org' response = with_client.post( - "/invite/send", - json={ - "email": "beatrice@ismycat.meow" - }, + '/invite/send', + json={'email': 'beatrice@ismycat.meow'}, headers=auth_headers, ) assert response.status_code == 401, response.text @@ -24,10 +22,8 @@ def test_send_invite_email_requires_at_least_one_admin_email(self, with_db, with os.environ['APP_ADMIN_ALLOW_LIST'] = '' response = with_client.post( - "/invite/send", - json={ - "email": "beatrice@ismycat.meow" - }, + '/invite/send', + json={'email': 'beatrice@ismycat.meow'}, headers=auth_headers, ) assert response.status_code == 401, response.text @@ -44,10 +40,8 @@ def test_send_invite_email(self, with_db, with_client): assert subscriber is None response = with_client.post( - "/invite/send", - json={ - 'email': invite_email - }, + '/invite/send', + json={'email': invite_email}, headers=auth_headers, ) assert response.status_code == 200, response.text diff --git a/backend/test/integration/test_profile.py b/backend/test/integration/test_profile.py index 4a848d508..f6569e352 100644 --- a/backend/test/integration/test_profile.py +++ b/backend/test/integration/test_profile.py @@ -7,60 +7,60 @@ class TestProfile: def test_update_me(self, with_db, with_client): """Puts to `/me` for a profile update, and verifies that the data was saved in our db correctly""" response = with_client.put( - "/me", + '/me', json={ - "username": "test", - "name": "Test Account", - "timezone": "Europe/Berlin", - "secondary_email": "useme@example.org" + 'username': 'test', + 'name': 'Test Account', + 'timezone': 'Europe/Berlin', + 'secondary_email': 'useme@example.org', }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["username"] == "test" - assert data["name"] == "Test Account" - assert data["timezone"] == "Europe/Berlin" + assert data['username'] == 'test' + assert data['name'] == 'Test Account' + assert data['timezone'] == 'Europe/Berlin' # Response returns preferred_email - assert data["preferred_email"] == "useme@example.org" + assert data['preferred_email'] == 'useme@example.org' # Confirm the data was saved with with_db() as db: subscriber = repo.subscriber.get_by_email(db, os.getenv('TEST_USER_EMAIL')) - assert subscriber.username == "test" - assert subscriber.name == "Test Account" - assert subscriber.timezone == "Europe/Berlin" - assert subscriber.secondary_email == "useme@example.org" - assert subscriber.preferred_email == "useme@example.org" + assert subscriber.username == 'test' + assert subscriber.name == 'Test Account' + assert subscriber.timezone == 'Europe/Berlin' + assert subscriber.secondary_email == 'useme@example.org' + assert subscriber.preferred_email == 'useme@example.org' def test_signed_short_link(self, with_client): """Retrieves our unique short link, and ensures it exists""" - response = with_client.get("/me/signature", headers=auth_headers) + response = with_client.get('/me/signature', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert data["url"] + assert data['url'] def test_signed_short_link_refresh(self, with_client): """Refreshes our unique short link and ensures it's new, and exists""" - response = with_client.get("/me/signature", headers=auth_headers) + response = with_client.get('/me/signature', headers=auth_headers) assert response.status_code == 200, response.text - url_old = response.json()["url"] - response = with_client.post("/me/signature", headers=auth_headers) + url_old = response.json()['url'] + response = with_client.post('/me/signature', headers=auth_headers) assert response.status_code == 200, response.text assert response.json() - response = with_client.get("/me/signature", headers=auth_headers) + response = with_client.get('/me/signature', headers=auth_headers) assert response.status_code == 200, response.text - url_new = response.json()["url"] + url_new = response.json()['url'] assert url_old != url_new def test_signed_short_link_verification(self, with_client): """Tests our signed url functionality is working""" - response = with_client.get("/me/signature", headers=auth_headers) + response = with_client.get('/me/signature', headers=auth_headers) assert response.status_code == 200, response.text - url = response.json()["url"] + url = response.json()['url'] assert url - response = with_client.post("/verify/signature", json={"url": url}) + response = with_client.post('/verify/signature', json={'url': url}) assert response.status_code == 200, response.text assert response.json() - response = with_client.post("/verify/signature", json={"url": url + "evil"}) + response = with_client.post('/verify/signature', json={'url': url + 'evil'}) assert response.status_code == 400, response.text diff --git a/backend/test/integration/test_schedule.py b/backend/test/integration/test_schedule.py index e79ff108b..f6f479ce9 100644 --- a/backend/test/integration/test_schedule.py +++ b/backend/test/integration/test_schedule.py @@ -15,52 +15,52 @@ def test_create_schedule_on_connected_calendar(self, with_client, make_caldav_ca generated_calendar = make_caldav_calendar(connected=True) response = with_client.post( - "/schedule", + '/schedule', json={ - "calendar_id": generated_calendar.id, - "name": "Schedule", - "location_type": 2, - "location_url": "https://test.org", - "details": "Lorem Ipsum", - "start_date": DAY1, - "end_date": DAY14, - "start_time": "10:00", - "end_time": "18:00", - "earliest_booking": 1440, - "farthest_booking": 20160, - "weekdays": [1, 2, 3, 4, 5], - "slot_duration": 30, + 'calendar_id': generated_calendar.id, + 'name': 'Schedule', + 'location_type': 2, + 'location_url': 'https://test.org', + 'details': 'Lorem Ipsum', + 'start_date': DAY1, + 'end_date': DAY14, + 'start_time': '10:00', + 'end_time': '18:00', + 'earliest_booking': 1440, + 'farthest_booking': 20160, + 'weekdays': [1, 2, 3, 4, 5], + 'slot_duration': 30, }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == generated_calendar.id - assert data["name"] == "Schedule" - assert data["location_type"] == 2 - assert data["location_url"] == "https://test.org" - assert data["details"] == "Lorem Ipsum" - assert data["start_date"] == DAY1 - assert data["end_date"] == DAY14 - assert data["start_time"] == "10:00" - assert data["end_time"] == "18:00" - assert data["earliest_booking"] == 1440 - assert data["farthest_booking"] == 20160 - assert data["weekdays"] is not None - weekdays = data["weekdays"] + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == generated_calendar.id + assert data['name'] == 'Schedule' + assert data['location_type'] == 2 + assert data['location_url'] == 'https://test.org' + assert data['details'] == 'Lorem Ipsum' + assert data['start_date'] == DAY1 + assert data['end_date'] == DAY14 + assert data['start_time'] == '10:00' + assert data['end_time'] == '18:00' + assert data['earliest_booking'] == 1440 + assert data['farthest_booking'] == 20160 + assert data['weekdays'] is not None + weekdays = data['weekdays'] assert len(weekdays) == 5 assert weekdays == [1, 2, 3, 4, 5] - assert data["slot_duration"] == 30 + assert data['slot_duration'] == 30 def test_create_schedule_on_unconnected_calendar(self, with_client, make_caldav_calendar, make_schedule): generated_calendar = make_caldav_calendar(connected=False) generated_schedule = make_schedule(calendar_id=generated_calendar.id) response = with_client.post( - "/schedule", - json={"calendar_id": generated_schedule.calendar_id, "name": "Schedule"}, + '/schedule', + json={'calendar_id': generated_schedule.calendar_id, 'name': 'Schedule'}, headers=auth_headers, ) assert response.status_code == 403, response.text @@ -69,21 +69,22 @@ def test_create_schedule_on_missing_calendar(self, with_client, make_schedule): generated_schedule = make_schedule() response = with_client.post( - "/schedule", - json={"calendar_id": generated_schedule.id + 1, "name": "Schedule"}, + '/schedule', + json={'calendar_id': generated_schedule.id + 1, 'name': 'Schedule'}, headers=auth_headers, ) assert response.status_code == 404, response.text - def test_create_schedule_on_foreign_calendar(self, with_client, make_pro_subscriber, make_caldav_calendar, - make_schedule): + def test_create_schedule_on_foreign_calendar( + self, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule + ): the_other_guy = make_pro_subscriber() generated_calendar = make_caldav_calendar(the_other_guy.id) generated_schedule = make_schedule(calendar_id=generated_calendar.id) response = with_client.post( - "/schedule", - json={"calendar_id": generated_schedule.id, "name": "Schedule"}, + '/schedule', + json={'calendar_id': generated_schedule.id, 'name': 'Schedule'}, headers=auth_headers, ) assert response.status_code == 403, response.text @@ -91,60 +92,60 @@ def test_create_schedule_on_foreign_calendar(self, with_client, make_pro_subscri def test_read_schedules(self, with_client, make_schedule): generated_schedule = make_schedule() - response = with_client.get("/schedule", headers=auth_headers) + response = with_client.get('/schedule', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert type(data) is list + assert isinstance(data, list) assert len(data) == 1 data = data[0] - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == generated_schedule.calendar_id - assert data["name"] == generated_schedule.name - assert data["location_type"] == generated_schedule.location_type.value - assert data["location_url"] == generated_schedule.location_url - assert data["details"] == generated_schedule.details - assert data["start_date"] == generated_schedule.start_date.isoformat() - assert data["end_date"] == generated_schedule.end_date.isoformat() - assert data["start_time"] == generated_schedule.start_time.isoformat('minutes') - assert data["end_time"] == generated_schedule.end_time.isoformat('minutes') - assert data["earliest_booking"] == generated_schedule.earliest_booking - assert data["farthest_booking"] == generated_schedule.farthest_booking - assert data["weekdays"] is not None - weekdays = data["weekdays"] + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == generated_schedule.calendar_id + assert data['name'] == generated_schedule.name + assert data['location_type'] == generated_schedule.location_type.value + assert data['location_url'] == generated_schedule.location_url + assert data['details'] == generated_schedule.details + assert data['start_date'] == generated_schedule.start_date.isoformat() + assert data['end_date'] == generated_schedule.end_date.isoformat() + assert data['start_time'] == generated_schedule.start_time.isoformat('minutes') + assert data['end_time'] == generated_schedule.end_time.isoformat('minutes') + assert data['earliest_booking'] == generated_schedule.earliest_booking + assert data['farthest_booking'] == generated_schedule.farthest_booking + assert data['weekdays'] is not None + weekdays = data['weekdays'] assert len(weekdays) == len(generated_schedule.weekdays) assert weekdays == generated_schedule.weekdays - assert data["slot_duration"] == generated_schedule.slot_duration + assert data['slot_duration'] == generated_schedule.slot_duration def test_read_existing_schedule(self, with_client, make_schedule): generated_schedule = make_schedule() - response = with_client.get(f"/schedule/{generated_schedule.id}", headers=auth_headers) + response = with_client.get(f'/schedule/{generated_schedule.id}', headers=auth_headers) assert response.status_code == 200, response.text data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == generated_schedule.calendar_id - assert data["name"] == generated_schedule.name - assert data["location_type"] == generated_schedule.location_type.value - assert data["location_url"] == generated_schedule.location_url - assert data["details"] == generated_schedule.details - assert data["start_date"] == generated_schedule.start_date.isoformat() - assert data["end_date"] == generated_schedule.end_date.isoformat() - assert data["start_time"] == generated_schedule.start_time.isoformat('minutes') - assert data["end_time"] == generated_schedule.end_time.isoformat('minutes') - assert data["earliest_booking"] == generated_schedule.earliest_booking - assert data["farthest_booking"] == generated_schedule.farthest_booking - assert data["weekdays"] is not None - weekdays = data["weekdays"] + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == generated_schedule.calendar_id + assert data['name'] == generated_schedule.name + assert data['location_type'] == generated_schedule.location_type.value + assert data['location_url'] == generated_schedule.location_url + assert data['details'] == generated_schedule.details + assert data['start_date'] == generated_schedule.start_date.isoformat() + assert data['end_date'] == generated_schedule.end_date.isoformat() + assert data['start_time'] == generated_schedule.start_time.isoformat('minutes') + assert data['end_time'] == generated_schedule.end_time.isoformat('minutes') + assert data['earliest_booking'] == generated_schedule.earliest_booking + assert data['farthest_booking'] == generated_schedule.farthest_booking + assert data['weekdays'] is not None + weekdays = data['weekdays'] assert len(weekdays) == len(generated_schedule.weekdays) assert weekdays == generated_schedule.weekdays - assert data["slot_duration"] == generated_schedule.slot_duration + assert data['slot_duration'] == generated_schedule.slot_duration def test_read_missing_schedule(self, with_client, make_schedule): generated_schedule = make_schedule() - response = with_client.get(f"/schedule/{generated_schedule.id + 1}", headers=auth_headers) + response = with_client.get(f'/schedule/{generated_schedule.id + 1}', headers=auth_headers) assert response.status_code == 404, response.text def test_read_foreign_schedule(self, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule): @@ -152,75 +153,75 @@ def test_read_foreign_schedule(self, with_client, make_pro_subscriber, make_cald generated_calendar = make_caldav_calendar(the_other_guy.id) generated_schedule = make_schedule(calendar_id=generated_calendar.id) - response = with_client.get(f"/schedule/{generated_schedule.id}", headers=auth_headers) + response = with_client.get(f'/schedule/{generated_schedule.id}', headers=auth_headers) assert response.status_code == 403, response.text def test_update_existing_schedule(self, with_client, make_schedule): generated_schedule = make_schedule() response = with_client.put( - f"/schedule/{generated_schedule.id}", + f'/schedule/{generated_schedule.id}', json={ - "calendar_id": generated_schedule.calendar_id, - "name": "Schedulex", - "location_type": 1, - "location_url": "https://testx.org", - "details": "Lorem Ipsumx", - "start_date": DAY2, - "end_date": DAY5, - "start_time": "09:00", - "end_time": "17:00", - "earliest_booking": 1000, - "farthest_booking": 20000, - "weekdays": [2, 4, 6], - "slot_duration": 60, + 'calendar_id': generated_schedule.calendar_id, + 'name': 'Schedulex', + 'location_type': 1, + 'location_url': 'https://testx.org', + 'details': 'Lorem Ipsumx', + 'start_date': DAY2, + 'end_date': DAY5, + 'start_time': '09:00', + 'end_time': '17:00', + 'earliest_booking': 1000, + 'farthest_booking': 20000, + 'weekdays': [2, 4, 6], + 'slot_duration': 60, }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["time_created"] is not None - assert data["time_updated"] is not None - assert data["calendar_id"] == generated_schedule.calendar_id - assert data["name"] == "Schedulex" - assert data["location_type"] == 1 - assert data["location_url"] == "https://testx.org" - assert data["details"] == "Lorem Ipsumx" - assert data["start_date"] == DAY2 - assert data["end_date"] == DAY5 - assert data["start_time"] == "09:00" - assert data["end_time"] == "17:00" - assert data["earliest_booking"] == 1000 - assert data["farthest_booking"] == 20000 - assert data["weekdays"] is not None - weekdays = data["weekdays"] + assert data['time_created'] is not None + assert data['time_updated'] is not None + assert data['calendar_id'] == generated_schedule.calendar_id + assert data['name'] == 'Schedulex' + assert data['location_type'] == 1 + assert data['location_url'] == 'https://testx.org' + assert data['details'] == 'Lorem Ipsumx' + assert data['start_date'] == DAY2 + assert data['end_date'] == DAY5 + assert data['start_time'] == '09:00' + assert data['end_time'] == '17:00' + assert data['earliest_booking'] == 1000 + assert data['farthest_booking'] == 20000 + assert data['weekdays'] is not None + weekdays = data['weekdays'] assert len(weekdays) == 3 assert weekdays == [2, 4, 6] - assert data["slot_duration"] == 60 + assert data['slot_duration'] == 60 def test_update_existing_schedule_with_html(self, with_client, make_schedule): generated_schedule = make_schedule() response = with_client.put( - f"/schedule/{generated_schedule.id}", + f'/schedule/{generated_schedule.id}', json={ - "calendar_id": generated_schedule.calendar_id, - "name": "Schedule", - "details": "Lorem

test

Ipsum
", + 'calendar_id': generated_schedule.calendar_id, + 'name': 'Schedule', + 'details': 'Lorem

test

Ipsum
', }, headers=auth_headers, ) assert response.status_code == 200, response.text data = response.json() - assert data["name"] == "Schedule" - assert data["details"] == "Lorem test Ipsum" + assert data['name'] == 'Schedule' + assert data['details'] == 'Lorem test Ipsum' def test_update_missing_schedule(self, with_client, make_schedule): generated_schedule = make_schedule() response = with_client.put( - f"/schedule/{generated_schedule.id + 1}", - json={"calendar_id": generated_schedule.calendar_id, "name": "Schedule"}, + f'/schedule/{generated_schedule.id + 1}', + json={'calendar_id': generated_schedule.calendar_id, 'name': 'Schedule'}, headers=auth_headers, ) assert response.status_code == 404, response.text @@ -231,14 +232,15 @@ def test_update_foreign_schedule(self, with_client, make_pro_subscriber, make_ca generated_schedule = make_schedule(calendar_id=generated_calendar.id) response = with_client.put( - f"/schedule/{generated_schedule.id}", - json={"calendar_id": generated_schedule.calendar_id, "name": "Schedule"}, + f'/schedule/{generated_schedule.id}', + json={'calendar_id': generated_schedule.calendar_id, 'name': 'Schedule'}, headers=auth_headers, ) assert response.status_code == 403, response.text - def test_public_availability(self, monkeypatch, with_client, make_pro_subscriber, make_caldav_calendar, - make_schedule): + def test_public_availability( + self, monkeypatch, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule + ): class MockCaldavConnector: @staticmethod def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_id): @@ -249,8 +251,8 @@ def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_ def list_events(self, start, end): return [] - monkeypatch.setattr(CalDavConnector, "__init__", MockCaldavConnector.__init__) - monkeypatch.setattr(CalDavConnector, "list_events", MockCaldavConnector.list_events) + monkeypatch.setattr(CalDavConnector, '__init__', MockCaldavConnector.__init__) + monkeypatch.setattr(CalDavConnector, 'list_events', MockCaldavConnector.list_events) start_date = date(2024, 3, 1) start_time = time(16) @@ -268,15 +270,16 @@ def list_events(self, start, end): end_date=None, earliest_booking=1440, farthest_booking=20160, - slot_duration=30) + slot_duration=30, + ) signed_url = signed_url_by_subscriber(subscriber) # Check availability at the start of the schedule with freeze_time(start_date): response = with_client.post( - "/schedule/public/availability", - json={"url": signed_url}, + '/schedule/public/availability', + json={'url': signed_url}, headers=auth_headers, ) assert response.status_code == 200, response.text @@ -293,8 +296,8 @@ def list_events(self, start, end): # Check availability over a year from now with freeze_time(date(2025, 6, 1)): response = with_client.post( - "/schedule/public/availability", - json={"url": signed_url}, + '/schedule/public/availability', + json={'url': signed_url}, headers=auth_headers, ) assert response.status_code == 200, response.text @@ -307,8 +310,8 @@ def list_events(self, start, end): # Check availability with a start date day greater than the farthest_booking day with freeze_time(date(2025, 6, 27)): response = with_client.post( - "/schedule/public/availability", - json={"url": signed_url}, + '/schedule/public/availability', + json={'url': signed_url}, headers=auth_headers, ) assert response.status_code == 200, response.text @@ -318,9 +321,12 @@ def list_events(self, start, end): assert slots[0]['start'] == '2025-06-30T09:00:00-07:00' assert slots[-1]['start'] == '2025-07-11T16:30:00-07:00' - def test_public_availability_with_blockers(self, monkeypatch, with_client, make_pro_subscriber, - make_caldav_calendar, make_schedule): - """Test public availability route with blocked off times. Ensuring the blocked off time displays as such and is otherwise normal.""" + def test_public_availability_with_blockers( + self, monkeypatch, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule + ): + """Test public availability route with blocked off times. + Ensuring the blocked off time displays as such and is otherwise normal. + """ start_date = date(2024, 3, 3) end_date = date(2024, 3, 6) @@ -360,14 +366,15 @@ def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_ def list_events(self, start, end): return [ schemas.Event( - title="A blocker!", + title='A blocker!', start=start_end_datetimes[0], end=start_end_datetimes[1], - ) for start_end_datetimes in blocker_times + ) + for start_end_datetimes in blocker_times ] - monkeypatch.setattr(CalDavConnector, "__init__", MockCaldavConnector.__init__) - monkeypatch.setattr(CalDavConnector, "list_events", MockCaldavConnector.list_events) + monkeypatch.setattr(CalDavConnector, '__init__', MockCaldavConnector.__init__) + monkeypatch.setattr(CalDavConnector, 'list_events', MockCaldavConnector.list_events) subscriber = make_pro_subscriber() generated_calendar = make_caldav_calendar(subscriber.id, connected=True) @@ -380,15 +387,16 @@ def list_events(self, start, end): end_date=end_date, earliest_booking=1440, farthest_booking=20160, - slot_duration=30) + slot_duration=30, + ) signed_url = signed_url_by_subscriber(subscriber) with freeze_time(start_date): # Check availability at the start of the schedule response = with_client.post( - "/schedule/public/availability", - json={"url": signed_url}, + '/schedule/public/availability', + json={'url': signed_url}, headers=auth_headers, ) assert response.status_code == 200, response.text @@ -405,10 +413,15 @@ def list_events(self, start, end): assert iso in slots_dict slot = slots_dict[iso] - assert slot['booking_status'] == models.BookingStatus.none.value if expected_assert else models.BookingStatus.booked.value - - def test_request_schedule_availability_slot(self, monkeypatch, with_db, with_client, make_pro_subscriber, - make_caldav_calendar, make_schedule): + assert ( + slot['booking_status'] == models.BookingStatus.none.value + if expected_assert + else models.BookingStatus.booked.value + ) + + def test_request_schedule_availability_slot( + self, monkeypatch, with_db, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule + ): """Test that a user can request a booking from a schedule""" start_date = date(2024, 4, 1) start_time = time(9) @@ -424,15 +437,11 @@ def __init__(self, redis_instance, url, user, password, subscriber_id, calendar_ @staticmethod def list_events(self, start, end): return [ + schemas.Event(title='A blocker!', start=start_datetime, end=datetime.combine(start_date, end_time)), schemas.Event( - title="A blocker!", - start=start_datetime, - end=datetime.combine(start_date, end_time) - ), - schemas.Event( - title="A second blocker!", + title='A second blocker!', start=start_datetime + timedelta(minutes=10), - end=datetime.combine(start_date, end_time) + timedelta(minutes=20) + end=datetime.combine(start_date, end_time) + timedelta(minutes=20), ), ] @@ -440,9 +449,9 @@ def list_events(self, start, end): def bust_cached_events(self, all_calendars=False): pass - monkeypatch.setattr(CalDavConnector, "__init__", MockCaldavConnector.__init__) - monkeypatch.setattr(CalDavConnector, "list_events", MockCaldavConnector.list_events) - monkeypatch.setattr(CalDavConnector, "bust_cached_events", MockCaldavConnector.bust_cached_events) + monkeypatch.setattr(CalDavConnector, '__init__', MockCaldavConnector.__init__) + monkeypatch.setattr(CalDavConnector, 'list_events', MockCaldavConnector.list_events) + monkeypatch.setattr(CalDavConnector, 'bust_cached_events', MockCaldavConnector.bust_cached_events) subscriber = make_pro_subscriber() generated_calendar = make_caldav_calendar(subscriber.id, connected=True) @@ -455,29 +464,23 @@ def bust_cached_events(self, all_calendars=False): end_date=None, earliest_booking=1440, farthest_booking=20160, - slot_duration=30) + slot_duration=30, + ) signed_url = signed_url_by_subscriber(subscriber) slot_availability = schemas.AvailabilitySlotAttendee( - slot=schemas.SlotBase( - start=start_datetime, - duration=30 - ), - attendee=schemas.AttendeeBase( - email='hello@example.org', - name='Greg', - timezone='Europe/Berlin' - ) + slot=schemas.SlotBase(start=start_datetime, duration=30), + attendee=schemas.AttendeeBase(email='hello@example.org', name='Greg', timezone='Europe/Berlin'), ).model_dump(mode='json') # Check availability at the start of the schedule # This should throw "Slot taken" error response = with_client.put( - "/schedule/public/availability/request", + '/schedule/public/availability/request', json={ - "s_a": slot_availability, - "url": signed_url, + 's_a': slot_availability, + 'url': signed_url, }, headers=auth_headers, ) @@ -497,10 +500,10 @@ def bust_cached_events(self, all_calendars=False): # Check availability at the start of the schedule # This should work response = with_client.put( - "/schedule/public/availability/request", + '/schedule/public/availability/request', json={ - "s_a": slot_availability, - "url": signed_url, + 's_a': slot_availability, + 'url': signed_url, }, headers=auth_headers, ) @@ -523,8 +526,17 @@ class TestDecideScheduleAvailabilitySlot: start_datetime = datetime.combine(start_date, start_time) end_time = time(10) - def test_confirm(self, with_db, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule, - make_appointment, make_appointment_slot, make_attendee): + def test_confirm( + self, + with_db, + with_client, + make_pro_subscriber, + make_caldav_calendar, + make_schedule, + make_appointment, + make_appointment_slot, + make_attendee, + ): subscriber = make_pro_subscriber() generated_calendar = make_caldav_calendar(subscriber.id, connected=True) schedule = make_schedule( @@ -536,18 +548,20 @@ def test_confirm(self, with_db, with_client, make_pro_subscriber, make_caldav_ca end_date=None, earliest_booking=1440, farthest_booking=20160, - slot_duration=30) + slot_duration=30, + ) signed_url = signed_url_by_subscriber(subscriber) # Requested booking attendee = make_attendee() appointment = make_appointment(generated_calendar.id, status=models.AppointmentStatus.draft, slots=[]) - slot: models.Slot = make_appointment_slot(appointment_id=appointment.id, - attendee_id=attendee.id, - booking_status=models.BookingStatus.requested, - booking_tkn='abcd', - )[0] + slot: models.Slot = make_appointment_slot( + appointment_id=appointment.id, + attendee_id=attendee.id, + booking_status=models.BookingStatus.requested, + booking_tkn='abcd', + )[0] with with_db() as db: # Bring the db slot to our db session @@ -561,14 +575,11 @@ def test_confirm(self, with_db, with_client, make_pro_subscriber, make_caldav_ca db.commit() availability = schemas.AvailabilitySlotConfirmation( - slot_id=slot_id, - slot_token=slot.booking_tkn, - owner_url=signed_url, - confirmed=True + slot_id=slot_id, slot_token=slot.booking_tkn, owner_url=signed_url, confirmed=True ).model_dump() response = with_client.put( - "/schedule/public/availability/booking", + '/schedule/public/availability/booking', json=availability, headers=auth_headers, ) @@ -582,8 +593,17 @@ def test_confirm(self, with_db, with_client, make_pro_subscriber, make_caldav_ca assert slot.booking_status == models.BookingStatus.booked assert appointment.status == models.AppointmentStatus.closed - def test_deny(self, with_db, with_client, make_pro_subscriber, make_caldav_calendar, make_schedule, - make_appointment, make_appointment_slot, make_attendee): + def test_deny( + self, + with_db, + with_client, + make_pro_subscriber, + make_caldav_calendar, + make_schedule, + make_appointment, + make_appointment_slot, + make_attendee, + ): subscriber = make_pro_subscriber() generated_calendar = make_caldav_calendar(subscriber.id, connected=True) schedule = make_schedule( @@ -595,18 +615,20 @@ def test_deny(self, with_db, with_client, make_pro_subscriber, make_caldav_calen end_date=None, earliest_booking=1440, farthest_booking=20160, - slot_duration=30) + slot_duration=30, + ) signed_url = signed_url_by_subscriber(subscriber) # Requested booking attendee = make_attendee() appointment = make_appointment(generated_calendar.id, status=models.AppointmentStatus.draft, slots=[]) - slot: models.Slot = make_appointment_slot(appointment_id=appointment.id, - attendee_id=attendee.id, - booking_status=models.BookingStatus.requested, - booking_tkn='abcd', - )[0] + slot: models.Slot = make_appointment_slot( + appointment_id=appointment.id, + attendee_id=attendee.id, + booking_status=models.BookingStatus.requested, + booking_tkn='abcd', + )[0] with with_db() as db: # Bring the db slot to our db session @@ -620,14 +642,11 @@ def test_deny(self, with_db, with_client, make_pro_subscriber, make_caldav_calen db.commit() availability = schemas.AvailabilitySlotConfirmation( - slot_id=slot_id, - slot_token=slot.booking_tkn, - owner_url=signed_url, - confirmed=False + slot_id=slot_id, slot_token=slot.booking_tkn, owner_url=signed_url, confirmed=False ).model_dump() response = with_client.put( - "/schedule/public/availability/booking", + '/schedule/public/availability/booking', json=availability, headers=auth_headers, ) diff --git a/backend/test/integration/test_webhooks.py b/backend/test/integration/test_webhooks.py index 3faa572b1..1e89e48f2 100644 --- a/backend/test/integration/test_webhooks.py +++ b/backend/test/integration/test_webhooks.py @@ -14,16 +14,12 @@ def test_fxa_process_change_password(self, with_db, with_client, make_pro_subscr def override_get_webhook_auth(): return { - "iss": "https://accounts.firefox.com/", - "sub": FXA_USER_ID, - "aud": "REMOTE_SYSTEM", - "iat": 1565720808, - "jti": "e19ed6c5-4816-4171-aa43-56ffe80dbda1", - "events": { - "https://schemas.accounts.firefox.com/event/password-change": { - "changeTime": 1565721242227 - } - } + 'iss': 'https://accounts.firefox.com/', + 'sub': FXA_USER_ID, + 'aud': 'REMOTE_SYSTEM', + 'iat': 1565720808, + 'jti': 'e19ed6c5-4816-4171-aa43-56ffe80dbda1', + 'events': {'https://schemas.accounts.firefox.com/event/password-change': {'changeTime': 1565721242227}}, } # Override get_webhook_auth so we don't have to mock up a valid jwt token @@ -40,13 +36,15 @@ def override_get_webhook_auth(): with freeze_time('Aug 13th 2019'): # Update the external connection time to match our freeze_time with with_db() as db: - fxa_connection = repo.external_connection.get_by_type(db, subscriber_id, models.ExternalConnectionType.fxa, FXA_USER_ID)[0] + fxa_connection = repo.external_connection.get_by_type( + db, subscriber_id, models.ExternalConnectionType.fxa, FXA_USER_ID + )[0] fxa_connection.time_updated = datetime.datetime.now() db.add(fxa_connection) db.commit() response = with_client.post( - "/webhooks/fxa-process", + '/webhooks/fxa-process', ) assert response.status_code == 200, response.text @@ -57,7 +55,9 @@ def override_get_webhook_auth(): # Update the external connection time to match our current time # This will make the change password event out of date with with_db() as db: - fxa_connection = repo.external_connection.get_by_type(db, subscriber_id, models.ExternalConnectionType.fxa, FXA_USER_ID)[0] + fxa_connection = repo.external_connection.get_by_type( + db, subscriber_id, models.ExternalConnectionType.fxa, FXA_USER_ID + )[0] fxa_connection.time_updated = datetime.datetime.now() db.add(fxa_connection) db.commit() @@ -70,14 +70,16 @@ def override_get_webhook_auth(): # Finally test that minimum_valid_iat_time stays the same due to an outdated password change event response = with_client.post( - "/webhooks/fxa-process", + '/webhooks/fxa-process', ) assert response.status_code == 200, response.text with with_db() as db: subscriber = repo.subscriber.get(db, subscriber_id) assert subscriber.minimum_valid_iat_time is None - def test_fxa_process_change_primary_email(self, with_db, with_client, make_pro_subscriber, make_external_connections): + def test_fxa_process_change_primary_email( + self, with_db, with_client, make_pro_subscriber, make_external_connections + ): """Ensure the change primary email event is handled correctly""" FXA_USER_ID = 'abc-456' @@ -86,16 +88,12 @@ def test_fxa_process_change_primary_email(self, with_db, with_client, make_pro_s def override_get_webhook_auth(): return { - "iss": "https://accounts.firefox.com/", - "sub": FXA_USER_ID, - "aud": "REMOTE_SYSTEM", - "iat": 1565720808, - "jti": "e19ed6c5-4816-4171-aa43-56ffe80dbda1", - "events": { - "https://schemas.accounts.firefox.com/event/profile-change": { - "email": NEW_EMAIL - } - } + 'iss': 'https://accounts.firefox.com/', + 'sub': FXA_USER_ID, + 'aud': 'REMOTE_SYSTEM', + 'iat': 1565720808, + 'jti': 'e19ed6c5-4816-4171-aa43-56ffe80dbda1', + 'events': {'https://schemas.accounts.firefox.com/event/profile-change': {'email': NEW_EMAIL}}, } # Override get_webhook_auth so we don't have to mock up a valid jwt token @@ -113,7 +111,7 @@ def override_get_webhook_auth(): assert subscriber.name != FXA_CLIENT_PATCH.get('subscriber_display_name') response = with_client.post( - "/webhooks/fxa-process", + '/webhooks/fxa-process', ) assert response.status_code == 200, response.text @@ -129,20 +127,26 @@ def override_get_webhook_auth(): assert subscriber.name != FXA_CLIENT_PATCH.get('subscriber_display_name') assert subscriber.name == subscriber_name - def test_fxa_process_delete_user(self, with_db, with_client, make_pro_subscriber, make_external_connections, make_appointment, make_caldav_calendar): + def test_fxa_process_delete_user( + self, + with_db, + with_client, + make_pro_subscriber, + make_external_connections, + make_appointment, + make_caldav_calendar, + ): """Ensure the delete user event is handled correctly""" FXA_USER_ID = 'abc-789' def override_get_webhook_auth(): return { - "iss": "https://accounts.firefox.com/", - "sub": FXA_USER_ID, - "aud": "REMOTE_SYSTEM", - "iat": 1565720810, - "jti": "1b3d623a-300a-4ab8-9241-855c35586809", - "events": { - "https://schemas.accounts.firefox.com/event/delete-user": {} - } + 'iss': 'https://accounts.firefox.com/', + 'sub': FXA_USER_ID, + 'aud': 'REMOTE_SYSTEM', + 'iat': 1565720810, + 'jti': '1b3d623a-300a-4ab8-9241-855c35586809', + 'events': {'https://schemas.accounts.firefox.com/event/delete-user': {}}, } # Override get_webhook_auth so we don't have to mock up a valid jwt token @@ -154,7 +158,7 @@ def override_get_webhook_auth(): appointment = make_appointment(calendar_id=calendar.id) response = with_client.post( - "/webhooks/fxa-process", + '/webhooks/fxa-process', ) assert response.status_code == 200, response.text diff --git a/backend/test/integration/test_zoom.py b/backend/test/integration/test_zoom.py index d73bb0fe4..278594c0b 100644 --- a/backend/test/integration/test_zoom.py +++ b/backend/test/integration/test_zoom.py @@ -1,22 +1,17 @@ - from appointment.database import models from defines import auth_headers, TEST_USER_ID class TestZoom: def test_zoom_disconnect_without_connection(self, with_client): - response = with_client.post( - "/zoom/disconnect", - headers=auth_headers) + response = with_client.post('/zoom/disconnect', headers=auth_headers) assert response.status_code == 200 assert response.json() is False def test_zoom_disconnect_with_connection(self, with_client, make_external_connections): make_external_connections(TEST_USER_ID, type=models.ExternalConnectionType.zoom) - response = with_client.post( - "/zoom/disconnect", - headers=auth_headers) + response = with_client.post('/zoom/disconnect', headers=auth_headers) assert response.status_code == 200 assert response.json() is True @@ -26,9 +21,7 @@ def test_zoom_disconnect_updates_schedule(self, with_db, with_client, make_sched assert schedule.meeting_link_provider == models.MeetingLinkProviderType.zoom - response = with_client.post( - "/zoom/disconnect", - headers=auth_headers) + response = with_client.post('/zoom/disconnect', headers=auth_headers) assert response.status_code == 200 assert response.json() is True @@ -40,16 +33,15 @@ def test_zoom_disconnect_updates_schedule(self, with_db, with_client, make_sched assert schedule.meeting_link_provider == models.MeetingLinkProviderType.none - def test_zoom_disconnect_does_not_update_schedule_for_other_types(self, with_db, with_client, make_schedule, - make_external_connections): + def test_zoom_disconnect_does_not_update_schedule_for_other_types( + self, with_db, with_client, make_schedule, make_external_connections + ): make_external_connections(TEST_USER_ID, type=models.ExternalConnectionType.zoom) schedule = make_schedule(meeting_link_provider=models.MeetingLinkProviderType.google_meet) assert schedule.meeting_link_provider == models.MeetingLinkProviderType.google_meet - response = with_client.post( - "/zoom/disconnect", - headers=auth_headers) + response = with_client.post('/zoom/disconnect', headers=auth_headers) assert response.status_code == 200 assert response.json() is True diff --git a/backend/test/unit/test_auth_dependency.py b/backend/test/unit/test_auth_dependency.py index 4bf26a22d..2610943b9 100644 --- a/backend/test/unit/test_auth_dependency.py +++ b/backend/test/unit/test_auth_dependency.py @@ -11,7 +11,6 @@ class TestAuthDependency: - def test_get_user_from_token(self, with_db, with_l10n, make_pro_subscriber): subscriber = make_pro_subscriber() access_token_expires = datetime.timedelta(minutes=float(os.getenv('JWT_EXPIRE_IN_MINS'))) @@ -20,8 +19,8 @@ def test_get_user_from_token(self, with_db, with_l10n, make_pro_subscriber): assert subscriber.minimum_valid_iat_time is None # Create the access token and test it - with freeze_time("Jan 9th 2024"): - access_token = create_access_token(data={"sub": f"uid-{subscriber.id}"}, expires_delta=access_token_expires) + with freeze_time('Jan 9th 2024'): + access_token = create_access_token(data={'sub': f'uid-{subscriber.id}'}, expires_delta=access_token_expires) assert access_token @@ -32,7 +31,7 @@ def test_get_user_from_token(self, with_db, with_l10n, make_pro_subscriber): assert subscriber_from_token == subscriber_from_token # The access token should still be valid the next day - with freeze_time("Jan 10th 2024"): + with freeze_time('Jan 10th 2024'): with with_db() as db: subscriber_from_token = get_user_from_token(db, access_token) @@ -40,14 +39,14 @@ def test_get_user_from_token(self, with_db, with_l10n, make_pro_subscriber): assert subscriber_from_token == subscriber_from_token # Pick a time outside the token expiry window, and ensure it breaks - with freeze_time("Feb 1st 2024"): + with freeze_time('Feb 1st 2024'): with with_db() as db: # Internally raises ExpiredSignatureError, but we catch it and send a HTTPException instead. with pytest.raises(InvalidTokenException): get_user_from_token(db, access_token) # Update the subscriber to have a minimum_valid_iat_time - with freeze_time("Jan 10th 2024"): + with freeze_time('Jan 10th 2024'): with with_db() as db: # We need to pull down the subscriber in this db session, otherwise we can't save it. subscriber = repo.subscriber.get(db, subscriber.id) @@ -56,7 +55,7 @@ def test_get_user_from_token(self, with_db, with_l10n, make_pro_subscriber): db.commit() # Now the access token should be invalid - with freeze_time("Jan 9th 2024"): + with freeze_time('Jan 9th 2024'): with with_db() as db: # Internally raises ExpiredSignatureError, but we catch it and send a HTTPException instead. with pytest.raises(InvalidTokenException): diff --git a/backend/test/unit/test_cache.py b/backend/test/unit/test_cache.py index bb7c89c74..f2cab95b2 100644 --- a/backend/test/unit/test_cache.py +++ b/backend/test/unit/test_cache.py @@ -7,22 +7,17 @@ class TestEncrypt: def test_cached_events(self): """Test our model_(dump/load)_redis functions to ensure they don't leak data and work correctly.""" now = datetime.datetime.now() - title = "Private event!" - description = "This is a super secret event!" - - cached_event = Event( - title=title, - start=now, - end=now + datetime.timedelta(hours=2), - description=description - ) - + title = 'Private event!' + description = 'This is a super secret event!' + + cached_event = Event(title=title, start=now, end=now + datetime.timedelta(hours=2), description=description) + # Ensure individual accessors are not encrypted assert cached_event.title == title assert cached_event.description == description - + encrypted_blob = cached_event.model_dump_redis() - + # Ensure model_dump_redis values are encrypted assert title not in encrypted_blob assert description not in encrypted_blob diff --git a/backend/test/unit/test_calendar_tools.py b/backend/test/unit/test_calendar_tools.py index 5cd29bc50..835904cfd 100644 --- a/backend/test/unit/test_calendar_tools.py +++ b/backend/test/unit/test_calendar_tools.py @@ -29,25 +29,25 @@ def test_events_roll_up_difference(self): start=start + timedelta(minutes=duration * 3), duration=duration, booking_status=models.BookingStatus.requested, - ) + ), ] # 3x 30 minute booked slots, two of the slots should combine to one hour busy slot event_slots = [ schemas.Event( - title="Extra Busy Time", + title='Extra Busy Time', start=start, end=start + timedelta(minutes=duration), ), schemas.Event( - title="After meeting nap", + title='After meeting nap', start=start + timedelta(minutes=duration), - end=start + timedelta(minutes=duration*2), + end=start + timedelta(minutes=duration * 2), ), schemas.Event( - title="Some other appointment", - start=start + timedelta(minutes=duration*3), - end=start + timedelta(minutes=duration*4), + title='Some other appointment', + start=start + timedelta(minutes=duration * 3), + end=start + timedelta(minutes=duration * 4), ), ] diff --git a/backend/test/unit/test_data.py b/backend/test/unit/test_data.py index d3819a9ed..84bb6b8cb 100644 --- a/backend/test/unit/test_data.py +++ b/backend/test/unit/test_data.py @@ -8,7 +8,7 @@ def test_model_to_csv_buffer(self, make_pro_subscriber): """Make sure our model to csv buffer is working, scrubbers and all!""" ph = PasswordHasher() - password = "cool beans" + password = 'cool beans' subscriber = make_pro_subscriber(password=password) buffer = model_to_csv_buffer([subscriber]) @@ -23,7 +23,15 @@ def test_model_to_csv_buffer(self, make_pro_subscriber): assert subscriber.email in csv_data assert subscriber.username in csv_data - def test_delete_account(self, with_db, make_pro_subscriber, make_appointment, make_schedule, make_caldav_calendar, make_external_connections): + def test_delete_account( + self, + with_db, + make_pro_subscriber, + make_appointment, + make_schedule, + make_caldav_calendar, + make_external_connections, + ): """Test that our delete account functionality actually deletes everything""" subscriber = make_pro_subscriber() calendar = make_caldav_calendar(subscriber_id=subscriber.id) @@ -43,4 +51,4 @@ def test_delete_account(self, with_db, make_pro_subscriber, make_appointment, ma for model in models_to_check: check = db.get(model.__class__, model.id) - assert check is None, f"Ensuring {model.__class__} is None" + assert check is None, f'Ensuring {model.__class__} is None' diff --git a/backend/test/unit/test_fxa_client.py b/backend/test/unit/test_fxa_client.py index 704d2b405..c1b3eac1a 100644 --- a/backend/test/unit/test_fxa_client.py +++ b/backend/test/unit/test_fxa_client.py @@ -53,6 +53,3 @@ def test_allow_list_allows_subscriber(self, with_db, make_basic_subscriber): # They're not in the allow list, but they are a user! assert fxa_client.is_in_allow_list(db, test_email) - - -