diff --git a/README.md b/README.md index e57170d16..47b90c689 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ # Thunderbird Appointment +**Note: Thunderbird Appointment is a prototype still in active development, and is not production ready.** + Invite others to grab times on your calendar. Choose a date. Make appointments as easy as it gets. + +## Feedback and Support + +If you'd like to give feedback or need support, please see our [Topicbox](https://thunderbird.topicbox.com/groups/services). + ## Get started You can either build preconfigured docker containers (database, backend and frontend) or manually set up the application. A more detailed documentation can be found in the [docs folder](./docs/README.md). diff --git a/backend/requirements.txt b/backend/requirements.txt index f2fe90e05..4dea8c663 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,8 +18,8 @@ markdown==3.6 MarkupSafe==2.1.2 nh3==0.2.17 python-dotenv==1.0.0 -python-jose==3.3.0 python-multipart==0.0.7 +PyJWT==2.6.0 pydantic==2.5.2 sentry-sdk==1.26.0 starlette-context==0.3.6 diff --git a/backend/src/appointment/commands/create_invite_codes.py b/backend/src/appointment/commands/create_invite_codes.py index 38a3dd0d6..275f6f4b0 100644 --- a/backend/src/appointment/commands/create_invite_codes.py +++ b/backend/src/appointment/commands/create_invite_codes.py @@ -10,7 +10,7 @@ def run(n: int): _, session = get_engine_and_session() db = session() - codes = repo.generate_invite_codes(db, n) + codes = repo.invite.generate_codes(db, n) db.close() diff --git a/backend/src/appointment/controller/apis/fxa_client.py b/backend/src/appointment/controller/apis/fxa_client.py index 0230f193c..affb9b33f 100644 --- a/backend/src/appointment/controller/apis/fxa_client.py +++ b/backend/src/appointment/controller/apis/fxa_client.py @@ -135,7 +135,7 @@ def token_saver(self, token): if self.subscriber_id is None: return - repo.update_subscriber_external_connection_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""" diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index e301866bd..e43073ed5 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -169,7 +169,7 @@ def sync_calendars(self, db, subscriber_id: int, token): # add calendar try: - repo.update_or_create_subscriber_calendar( + repo.calendar.update_or_create( db=db, calendar=cal, calendar_url=calendar.get("id"), diff --git a/backend/src/appointment/controller/apis/zoom_client.py b/backend/src/appointment/controller/apis/zoom_client.py index b2770fc73..aed0d35f0 100644 --- a/backend/src/appointment/controller/apis/zoom_client.py +++ b/backend/src/appointment/controller/apis/zoom_client.py @@ -61,7 +61,7 @@ def token_saver(self, token): return # get_db is a generator function, retrieve the only yield - repo.update_subscriber_external_connection_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() diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 035fc4d9c..4b981ed64 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -567,7 +567,7 @@ def existing_events_for_schedule( # handle calendar events for calendar in calendars: if calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.get_external_connections_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() diff --git a/backend/src/appointment/controller/data.py b/backend/src/appointment/controller/data.py index ce5852de5..c3ef7b6a8 100644 --- a/backend/src/appointment/controller/data.py +++ b/backend/src/appointment/controller/data.py @@ -37,14 +37,14 @@ def model_to_csv_buffer(models): def download(db, subscriber: Subscriber): """Generate a zip file of csvs that contain a copy of the subscriber's information.""" - attendees = repo.get_attendees_by_subscriber(db, subscriber_id=subscriber.id) - appointments = repo.get_appointments_by_subscriber(db, subscriber_id=subscriber.id) - calendars = repo.get_calendars_by_subscriber(db, subscriber_id=subscriber.id) + attendees = repo.attendee.get_by_subscriber(db, subscriber_id=subscriber.id) + appointments = repo.appointment.get_by_subscriber(db, subscriber_id=subscriber.id) + calendars = repo.calendar.get_by_subscriber(db, subscriber_id=subscriber.id) subscribers = [subscriber] - slots = repo.get_slots_by_subscriber(db, subscriber_id=subscriber.id) + slots = repo.slot.get_by_subscriber(db, subscriber_id=subscriber.id) external_connections = subscriber.external_connections - schedules = repo.get_schedules_by_subscriber(db, subscriber.id) - availability = [repo.get_availability_by_schedule(db, schedule.id) for schedule in schedules] + schedules = repo.schedule.get_by_subscriber(db, subscriber.id) + availability = [repo.schedule.get_availability(db, schedule.id) for schedule in schedules] # Convert models to csv attendee_buffer = model_to_csv_buffer(attendees) @@ -79,21 +79,21 @@ def download(db, subscriber: Subscriber): def delete_account(db, subscriber: Subscriber): # Ok nuke everything (thanks cascade=all,delete) - repo.delete_subscriber(db, subscriber) + repo.subscriber.delete(db, subscriber) # Make sure we actually nuked the subscriber - if repo.get_subscriber(db, subscriber.id) is not None: + 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.", ) empty_check = [ - len(repo.get_attendees_by_subscriber(db, subscriber.id)), - len(repo.get_slots_by_subscriber(db, subscriber.id)), - len(repo.get_appointments_by_subscriber(db, subscriber.id)), - len(repo.get_calendars_by_subscriber(db, subscriber.id)), - len(repo.get_schedules_by_subscriber(db, subscriber.id)) + len(repo.attendee.get_by_subscriber(db, subscriber.id)), + 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)) ] # Check if we have any left-over subscriber data diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index d8812167b..2c58241cd 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -87,8 +87,12 @@ class Base: def __tablename__(cls): return cls.__name__.lower() - time_created = Column(DateTime, server_default=func.now(), index=True) - time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now(), index=True) + def touch(self): + """Updates the time_updated field with the current datetime. This function does not save the model!""" + self.time_updated = datetime.datetime.now() + + time_created = Column(DateTime, server_default=func.now(), default=func.now(), index=True) + time_updated = Column(DateTime, server_default=func.now(), default=func.now(), onupdate=func.now(), index=True) class Subscriber(Base): @@ -170,6 +174,7 @@ class Attendee(Base): 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) + timezone = Column(String(255), index=True) slots = relationship("Slot", cascade="all,delete", back_populates="attendee") diff --git a/backend/src/appointment/database/repo.py b/backend/src/appointment/database/repo.py deleted file mode 100644 index 28285f0e1..000000000 --- a/backend/src/appointment/database/repo.py +++ /dev/null @@ -1,694 +0,0 @@ -"""Module: repo - -Repository providing CRUD functions for all database models. -""" -import os -import re -import uuid - -from datetime import timedelta, datetime - -from fastapi import HTTPException -from sqlalchemy.orm import Session -from . import models, schemas -from .schemas import ExternalConnection -from ..controller.auth import sign_url - - -"""ATTENDEES repository functions -""" - - -def get_attendees_by_subscriber(db: Session, subscriber_id: int): - """For use with the data download. Get attendees by subscriber id.""" - # We need to walk through Calendars to attach Appointments, and Appointments to get Slots - slots = ( - db.query(models.Slot) - .join(models.Appointment) - .join(models.Calendar) - .filter(models.Calendar.owner_id == subscriber_id) - .filter(models.Appointment.calendar_id == models.Calendar.id) - .filter(models.Slot.appointment_id == models.Appointment.id) - .all() - ) - - attendee_ids = list(map(lambda slot: slot.attendee_id if slot.attendee_id is not None else None, slots)) - attendee_ids = filter(lambda attendee: attendee is not None, attendee_ids) - return db.query(models.Attendee).filter(models.Attendee.id.in_(attendee_ids)).all() - - -def delete_attendees_by_subscriber(db: Session, subscriber_id: int): - """Delete all attendees by subscriber""" - attendees = get_attendees_by_subscriber(db, subscriber_id) - - for attendee in attendees: - db.delete(attendee) - db.commit() - - return True - - -""" SUBSCRIBERS repository functions -""" - - -def get_subscriber(db: Session, subscriber_id: int) -> models.Subscriber | None: - """retrieve subscriber by id""" - return db.get(models.Subscriber, subscriber_id) - - -def get_subscriber_by_email(db: Session, email: str): - """retrieve subscriber by email""" - return db.query(models.Subscriber).filter(models.Subscriber.email == email).first() - - -def get_subscriber_by_username(db: Session, username: str): - """retrieve subscriber by username""" - return db.query(models.Subscriber).filter(models.Subscriber.username == username).first() - - -def get_subscriber_by_appointment(db: Session, appointment_id: int): - """retrieve appointment by subscriber username and appointment slug (public)""" - if appointment_id: - return ( - db.query(models.Subscriber) - .join(models.Calendar) - .join(models.Appointment) - .filter(models.Appointment.id == appointment_id) - .first() - ) - return None - - -def get_subscriber_by_google_state(db: Session, state: str): - """retrieve subscriber by google state, you'll have to manually check the google_state_expire_at!""" - if state is None: - return None - return db.query(models.Subscriber).filter(models.Subscriber.google_state == state).first() - - -def create_subscriber(db: Session, subscriber: schemas.SubscriberBase): - """create new subscriber""" - db_subscriber = models.Subscriber(**subscriber.dict()) - db.add(db_subscriber) - db.commit() - db.refresh(db_subscriber) - return db_subscriber - - -def update_subscriber(db: Session, data: schemas.SubscriberIn, subscriber_id: int): - """update all subscriber attributes, they can edit themselves""" - db_subscriber = get_subscriber(db, subscriber_id) - for key, value in data: - if value is not None: - setattr(db_subscriber, key, value) - db.commit() - db.refresh(db_subscriber) - return db_subscriber - - -def delete_subscriber(db: Session, subscriber: models.Subscriber): - """Delete a subscriber by subscriber id""" - db.delete(subscriber) - db.commit() - return True - - -def get_connections_limit(db: Session, subscriber_id: int): - """return the number of allowed connections for given subscriber or -1 for unlimited connections""" - # db_subscriber = get_subscriber(db, subscriber_id) - # mapping = { - # models.SubscriberLevel.basic: int(os.getenv("TIER_BASIC_CALENDAR_LIMIT")), - # models.SubscriberLevel.plus: int(os.getenv("TIER_PLUS_CALENDAR_LIMIT")), - # models.SubscriberLevel.pro: int(os.getenv("TIER_PRO_CALENDAR_LIMIT")), - # models.SubscriberLevel.admin: -1, - # } - # return mapping[db_subscriber.level] - - # No limit right now! - return -1 - - -def verify_subscriber_link(db: Session, url: str): - """Check if a given url is a valid signed subscriber profile link - Return subscriber if valid. - """ - # Look for a followed by an optional signature that ends the string - pattern = r"[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$" - match = re.findall(pattern, url) - - if match is None or len(match) == 0: - return False - - # Flatten - match = match[0] - clean_url = url - - username = match[0] - signature = None - if len(match) > 1: - signature = match[1] - clean_url = clean_url.replace(signature, "") - - subscriber = get_subscriber_by_username(db, username) - if not subscriber: - return False - - 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 - if signed_signature == signature: - return subscriber - return False - - -""" CALENDAR repository functions -""" - - -def calendar_exists(db: Session, calendar_id: int): - """true if calendar of given id exists""" - return True if db.get(models.Calendar, calendar_id) is not None else False - - -def calendar_is_owned(db: Session, calendar_id: int, subscriber_id: int): - """check if calendar belongs to subscriber""" - return ( - db.query(models.Calendar) - .filter(models.Calendar.id == calendar_id, models.Calendar.owner_id == subscriber_id) - .first() - is not None - ) - - -def get_calendar(db: Session, calendar_id: int): - """retrieve calendar by id""" - return db.get(models.Calendar, calendar_id) - - -def calendar_is_connected(db: Session, calendar_id: int): - """true if calendar of given id exists""" - return get_calendar(db, calendar_id).connected - - -def get_calendar_by_url(db: Session, url: str): - """retrieve calendar by calendar url""" - return db.query(models.Calendar).filter(models.Calendar.url == url).first() - - -def get_calendars_by_subscriber(db: Session, subscriber_id: int, include_unconnected: bool = True): - """retrieve list of calendars by owner id""" - query = db.query(models.Calendar).filter(models.Calendar.owner_id == subscriber_id) - - if not include_unconnected: - query = query.filter(models.Calendar.connected == 1) - - return query.all() - - -def create_subscriber_calendar(db: Session, calendar: schemas.CalendarConnection, subscriber_id: int): - """create new calendar for owner, if not already existing""" - db_calendar = models.Calendar(**calendar.dict(), owner_id=subscriber_id) - subscriber_calendars = get_calendars_by_subscriber(db, subscriber_id) - 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") - # add new calendar - db.add(db_calendar) - db.commit() - db.refresh(db_calendar) - return db_calendar - - -def update_subscriber_calendar(db: Session, calendar: schemas.CalendarConnection, calendar_id: int): - """update existing calendar by id""" - db_calendar = get_calendar(db, calendar_id) - - # list of all attributes that must never be updated - # # because they have dedicated update functions for security reasons - ignore = ["connected", "connected_at"] - # list of all attributes that will keep their current value if None is passed - 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 - if key in ignore or (key in keep_if_none and (not value or len(str(value)) == 0)): - continue - - setattr(db_calendar, key, value) - - db.commit() - db.refresh(db_calendar) - return db_calendar - - -def update_subscriber_calendar_connection(db: Session, is_connected: bool, calendar_id: int): - """Updates the connected status of a calendar""" - db_calendar = get_calendar(db, calendar_id) - # check subscription limitation on connecting - if is_connected: - subscriber_calendars = get_calendars_by_subscriber(db, db_calendar.owner_id) - connected_calendars = [calendar for calendar in subscriber_calendars if calendar.connected] - limit = 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" - ) - if not db_calendar.connected: - db_calendar.connected_at = datetime.now() - elif db_calendar.connected and is_connected is False: - db_calendar.connected_at = None - db_calendar.connected = is_connected - db.commit() - db.refresh(db_calendar) - return db_calendar - - -def update_or_create_subscriber_calendar( - db: Session, calendar: schemas.CalendarConnection, calendar_url: str, subscriber_id: int -): - """update or create a subscriber calendar""" - subscriber_calendar = get_calendar_by_url(db, calendar_url) - - if subscriber_calendar is None: - return create_subscriber_calendar(db, calendar, subscriber_id) - - return update_subscriber_calendar(db, calendar, subscriber_calendar.id) - - -def delete_subscriber_calendar(db: Session, calendar_id: int): - """remove existing calendar by id""" - db_calendar = get_calendar(db, calendar_id) - db.delete(db_calendar) - db.commit() - return db_calendar - - -def delete_subscriber_calendar_by_subscriber_id(db: Session, subscriber_id: int): - """Delete all calendars by subscriber""" - calendars = get_calendars_by_subscriber(db, subscriber_id=subscriber_id) - for calendar in calendars: - delete_subscriber_calendar(db, calendar_id=calendar.id) - return True - - -""" APPOINTMENT repository functions -""" - - -def create_calendar_appointment(db: Session, appointment: schemas.AppointmentFull, slots: list[schemas.SlotBase] = []): - """create new appointment with slots for calendar""" - db_appointment = models.Appointment(**appointment.dict()) - db.add(db_appointment) - db.commit() - db.refresh(db_appointment) - if len(slots) > 0: - add_appointment_slots(db, slots, db_appointment.id) - return db_appointment - - -def get_appointment(db: Session, appointment_id: int) -> models.Appointment|None: - """retrieve appointment by id (private)""" - if appointment_id: - return db.get(models.Appointment, appointment_id) - return None - - -def get_public_appointment(db: Session, slug: str): - """retrieve appointment by appointment slug (public)""" - if slug: - return db.query(models.Appointment).filter(models.Appointment.slug == slug).first() - return None - - -def get_appointments_by_subscriber(db: Session, subscriber_id: int): - """retrieve list of appointments by owner id""" - return db.query(models.Appointment).join(models.Calendar).filter(models.Calendar.owner_id == subscriber_id).all() - - -def appointment_is_owned(db: Session, appointment_id: int, subscriber_id: int): - """check if appointment belongs to subscriber""" - db_appointment = get_appointment(db, appointment_id) - return calendar_is_owned(db, db_appointment.calendar_id, subscriber_id) - - -def appointment_has_slot(db: Session, appointment_id: int, slot_id: int): - """check if appointment belongs to subscriber""" - db_slot = get_slot(db, slot_id) - return db_slot and db_slot.appointment_id == appointment_id - - -def update_calendar_appointment( - db: Session, - appointment: schemas.AppointmentFull, - slots: list[schemas.SlotBase], - appointment_id: int, -): - """update existing appointment by id""" - db_appointment = get_appointment(db, appointment_id) - for key, value in appointment: - setattr(db_appointment, key, value) - db.commit() - db.refresh(db_appointment) - delete_appointment_slots(db, appointment_id) - add_appointment_slots(db, slots, appointment_id) - return db_appointment - - -def delete_calendar_appointment(db: Session, appointment_id: int): - """remove existing appointment by id""" - db_appointment = get_appointment(db, appointment_id) - db.delete(db_appointment) - db.commit() - return db_appointment - - -def delete_calendar_appointments_by_subscriber_id(db: Session, subscriber_id: int): - """Delete all appointments by subscriber""" - appointments = get_appointments_by_subscriber(db, subscriber_id=subscriber_id) - for appointment in appointments: - delete_calendar_appointment(db, appointment_id=appointment.id) - return True - - -def update_appointment_status(db: Session, appointment_id: int, status: models.AppointmentStatus): - appointment = get_appointment(db, appointment_id) - if not appointment: - return False - - appointment.status = status - db.commit() - - -""" SLOT repository functions -""" - - -def get_slot(db: Session, slot_id: int) -> models.Slot | None: - """retrieve slot by id""" - if slot_id: - return db.get(models.Slot, slot_id) - return None - - -def get_slots_by_subscriber(db: Session, subscriber_id: int): - """retrieve slot by subscriber id""" - - # We need to walk through Calendars to attach Appointments, and Appointments to get Slots - return ( - db.query(models.Slot) - .join(models.Appointment) - .join(models.Calendar) - .filter(models.Calendar.owner_id == subscriber_id) - .filter(models.Appointment.calendar_id == models.Calendar.id) - .filter(models.Slot.appointment_id == models.Appointment.id) - .all() - ) - - -def add_appointment_slots(db: Session, slots: list[schemas.SlotBase], appointment_id: int): - """create new slots for appointment of given id""" - for slot in slots: - db_slot = models.Slot(**slot.dict()) - db_slot.appointment_id = appointment_id - db.add(db_slot) - db.commit() - return slots - - -def add_schedule_slot(db: Session, slot: schemas.SlotBase, schedule_id: int): - """create new slot for schedule of given id""" - db_slot = models.Slot(**slot.dict()) - db_slot.schedule_id = schedule_id - db.add(db_slot) - db.commit() - db.refresh(db_slot) - return db_slot - - -def schedule_slot_exists(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() - ) - return db_slot is not None - - -def book_slot(db: Session, slot_id: int) -> models.Slot | None: - """update booking status for slot of given id""" - db_slot = get_slot(db, slot_id) - db_slot.booking_status = models.BookingStatus.booked - db.commit() - db.refresh(db_slot) - return db_slot - - -def delete_appointment_slots(db: Session, appointment_id: int): - """delete all slots for appointment of given id""" - return db.query(models.Slot).filter(models.Slot.appointment_id == appointment_id).delete() - - -def delete_appointment_slots_by_subscriber_id(db: Session, subscriber_id: int): - """Delete all slots by subscriber""" - slots = get_slots_by_subscriber(db, subscriber_id) - - for slot in slots: - db.delete(slot) - db.commit() - - return True - - -def update_slot(db: Session, slot_id: int, attendee: schemas.Attendee): - """update existing slot by id and create corresponding attendee""" - # create attendee - db_attendee = models.Attendee(**attendee.dict()) - db.add(db_attendee) - db.commit() - db.refresh(db_attendee) - # update slot - db_slot = get_slot(db, slot_id) - # TODO: additionally handle subscriber_id here for already logged in users - setattr(db_slot, "attendee_id", db_attendee.id) - db.commit() - return db_attendee - - -def delete_slot(db: Session, slot_id: int): - """remove existing slot by id""" - db_slot = get_slot(db, slot_id) - db.delete(db_slot) - db.commit() - return db_slot - - -def slot_is_available(db: Session, slot_id: int): - """check if slot is still available for booking""" - slot = get_slot(db, slot_id) - if slot.schedule: - return slot and slot.booking_status == models.BookingStatus.requested - return False - - -"""SCHEDULES repository functions -""" - - -def create_calendar_schedule(db: Session, schedule: schemas.ScheduleBase): - """create new schedule with slots for calendar""" - db_schedule = models.Schedule(**schedule.dict()) - db.add(db_schedule) - db.commit() - db.refresh(db_schedule) - return db_schedule - - -def get_schedules_by_subscriber(db: Session, subscriber_id: int): - """Get schedules by subscriber id""" - return ( - db.query(models.Schedule) - .join(models.Calendar, models.Schedule.calendar_id == models.Calendar.id) - .filter(models.Calendar.owner_id == subscriber_id) - .all() - ) - - -def get_schedule(db: Session, schedule_id: int): - """retrieve schedule by id""" - if schedule_id: - return db.get(models.Schedule, schedule_id) - return None - - -def schedule_is_owned(db: Session, schedule_id: int, subscriber_id: int): - """check if the given schedule belongs to subscriber""" - schedules = get_schedules_by_subscriber(db, subscriber_id) - return any(s.id == schedule_id for s in schedules) - - -def schedule_exists(db: Session, schedule_id: int): - """true if schedule of given id exists""" - return True if get_schedule(db, schedule_id) is not None else False - - -def update_calendar_schedule(db: Session, schedule: schemas.ScheduleBase, schedule_id: int): - """update existing schedule by id""" - db_schedule = get_schedule(db, schedule_id) - for key, value in schedule: - setattr(db_schedule, key, value) - db.commit() - db.refresh(db_schedule) - return db_schedule - - -def get_availability_by_schedule(db: Session, schedule_id: int): - """retrieve availability by schedule id""" - return db.query(models.Availability).filter(models.Availability.schedule_id == schedule_id).all() - - -def schedule_has_slot(db: Session, schedule_id: int, slot_id: int): - """check if slot belongs to schedule""" - db_slot = get_slot(db, slot_id) - return db_slot and db_slot.schedule_id == schedule_id - - -"""INVITES repository functions -""" - - -def get_invite_by_code(db: Session, code: str): - """retrieve invite by code""" - return db.query(models.Invite).filter(models.Invite.code == code).first() - - -def generate_invite_codes(db: Session, n: int): - """generate n invite codes and return the list of created invite objects""" - codes = [str(uuid.uuid4()) for _ in range(n)] - db_invites = [] - for code in codes: - invite = schemas.Invite(code=code) - db_invite = models.Invite(**invite.dict()) - db.add(db_invite) - db.commit() - db_invites.append(db_invite) - return db_invites - - -def invite_code_exists(db: Session, code: str): - """true if invite code exists""" - return True if get_invite_by_code(db, code) is not None else False - - -def invite_code_is_used(db: Session, code: str): - """true if invite code is assigned to a user""" - db_invite = get_invite_by_code(db, code) - return db_invite.is_used - - -def invite_code_is_revoked(db: Session, code: str): - """true if invite code is revoked""" - db_invite = get_invite_by_code(db, code) - return db_invite.is_revoked - - -def invite_code_is_available(db: Session, code: str): - """true if invite code exists and can still be used""" - db_invite = get_invite_by_code(db, code) - return db_invite and db_invite.is_available - - -def use_invite_code(db: Session, code: str, subscriber_id: int): - """assign given subscriber to an invite""" - db_invite = get_invite_by_code(db, code) - if db_invite and db_invite.is_available: - db_invite.subscriber_id = subscriber_id - db.commit() - db.refresh(db_invite) - return True - else: - return False - - -def revoke_invite_code(db: Session, code: str): - """set existing invite code status to revoked""" - db_invite = get_invite_by_code(db, code) - db_invite.status = models.InviteStatus.revoked - db.commit() - db.refresh(db_invite) - return True - - -"""External Connections repository functions -""" - - -def create_subscriber_external_connection(db: Session, external_connection: ExternalConnection): - 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_subscriber_external_connection_token(db: Session, token: str, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None = None): - db_results = get_external_connections_by_type(db, subscriber_id, type, type_id) - if db_results is None or len(db_results) == 0: - return None - - db_external_connection = db_results[0] - db_external_connection.token = token - db.commit() - db.refresh(db_external_connection) - return db_external_connection - - -def delete_external_connections_by_type_id(db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str): - connections = get_external_connections_by_type(db, subscriber_id, type, type_id) - - # There should be one by type id, but just in case.. - for connection in connections: - db.delete(connection) - db.commit() - - return True - - -def get_external_connections_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) - .filter(models.ExternalConnections.owner_id == subscriber_id) - .filter(models.ExternalConnections.type == type) - ) - - if type_id is not None: - query = query.filter(models.ExternalConnections.type_id == type_id) - - result = query.all() - - return result - - -def get_subscriber_by_fxa_uid(db: Session, type_id: str): - """Return a subscriber from a fxa profile uid""" - query = ( - db.query(models.ExternalConnections) - .filter(models.ExternalConnections.type == models.ExternalConnectionType.fxa) - .filter(models.ExternalConnections.type_id == type_id) - ) - - result = query.first() - - if result is not None: - return result.owner - - return None diff --git a/backend/src/appointment/database/repo/__init__.py b/backend/src/appointment/database/repo/__init__.py new file mode 100644 index 000000000..8bcd7e2d2 --- /dev/null +++ b/backend/src/appointment/database/repo/__init__.py @@ -0,0 +1 @@ +from . import appointment, attendee, calendar, external_connection, invite, schedule, slot, subscriber diff --git a/backend/src/appointment/database/repo/appointment.py b/backend/src/appointment/database/repo/appointment.py new file mode 100644 index 000000000..1ff231bc0 --- /dev/null +++ b/backend/src/appointment/database/repo/appointment.py @@ -0,0 +1,91 @@ +"""Module: repo.appointment + +Repository providing CRUD functions for appointment database models. +""" + +from sqlalchemy.orm import Session +from .. import models, schemas, repo + + +def create(db: Session, appointment: schemas.AppointmentFull, slots: list[schemas.SlotBase] = []): + """create new appointment with slots for calendar""" + db_appointment = models.Appointment(**appointment.dict()) + db.add(db_appointment) + db.commit() + db.refresh(db_appointment) + if len(slots) > 0: + repo.slot.add_for_appointment(db, slots, db_appointment.id) + return db_appointment + + +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) + return None + + +def get_public(db: Session, slug: str): + """retrieve appointment by appointment slug (public)""" + if slug: + return db.query(models.Appointment).filter(models.Appointment.slug == slug).first() + return None + + +def get_by_subscriber(db: Session, subscriber_id: int): + """retrieve list of appointments by owner id""" + return db.query(models.Appointment).join(models.Calendar).filter(models.Calendar.owner_id == subscriber_id).all() + + +def is_owned(db: Session, appointment_id: int, subscriber_id: int): + """check if appointment belongs to subscriber""" + db_appointment = get(db, appointment_id) + return repo.calendar.is_owned(db, db_appointment.calendar_id, subscriber_id) + + +def has_slot(db: Session, appointment_id: int, slot_id: int): + """check if appointment belongs to subscriber""" + db_slot = repo.slot.get(db, slot_id) + return db_slot and db_slot.appointment_id == appointment_id + + +def update( + db: Session, + appointment: schemas.AppointmentFull, + slots: list[schemas.SlotBase], + appointment_id: int, +): + """update existing appointment by id""" + db_appointment = get(db, appointment_id) + for key, value in appointment: + setattr(db_appointment, key, value) + db.commit() + db.refresh(db_appointment) + repo.slot.delete(db, appointment_id) + repo.slot.add_for_appointment(db, slots, appointment_id) + return db_appointment + + +def delete(db: Session, appointment_id: int): + """remove existing appointment by id""" + db_appointment = get(db, appointment_id) + db.delete(db_appointment) + db.commit() + return db_appointment + + +def delete_by_subscriber(db: Session, subscriber_id: int): + """Delete all appointments by subscriber""" + appointments = get_by_subscriber(db, subscriber_id=subscriber_id) + for appointment in appointments: + delete(db, appointment_id=appointment.id) + return True + + +def update_status(db: Session, appointment_id: int, status: models.AppointmentStatus): + appointment = get(db, appointment_id) + if not appointment: + return False + + appointment.status = status + db.commit() diff --git a/backend/src/appointment/database/repo/attendee.py b/backend/src/appointment/database/repo/attendee.py new file mode 100644 index 000000000..82e8ae017 --- /dev/null +++ b/backend/src/appointment/database/repo/attendee.py @@ -0,0 +1,36 @@ +"""Module: repo.attendee + +Repository providing CRUD functions for attendee database models. +""" + +from sqlalchemy.orm import Session +from .. import models + + +def get_by_subscriber(db: Session, subscriber_id: int): + """For use with the data download. Get attendees by subscriber id.""" + # We need to walk through Calendars to attach Appointments, and Appointments to get Slots + slots = ( + db.query(models.Slot) + .join(models.Appointment) + .join(models.Calendar) + .filter(models.Calendar.owner_id == subscriber_id) + .filter(models.Appointment.calendar_id == models.Calendar.id) + .filter(models.Slot.appointment_id == models.Appointment.id) + .all() + ) + + attendee_ids = list(map(lambda slot: slot.attendee_id if slot.attendee_id is not None else None, slots)) + attendee_ids = filter(lambda attendee: attendee is not None, attendee_ids) + return db.query(models.Attendee).filter(models.Attendee.id.in_(attendee_ids)).all() + + +def delete_by_subscriber(db: Session, subscriber_id: int): + """Delete all attendees by subscriber""" + attendees = get_by_subscriber(db, subscriber_id) + + for attendee in attendees: + db.delete(attendee) + db.commit() + + return True diff --git a/backend/src/appointment/database/repo/calendar.py b/backend/src/appointment/database/repo/calendar.py new file mode 100644 index 000000000..7ead589e5 --- /dev/null +++ b/backend/src/appointment/database/repo/calendar.py @@ -0,0 +1,135 @@ +"""Module: repo.calendar + +Repository providing CRUD functions for calendar database models. +""" + +from datetime import datetime + +from fastapi import HTTPException +from sqlalchemy.orm import Session +from .. import models, schemas, repo + + +def exists(db: Session, calendar_id: int): + """true if calendar of given id exists""" + return True if db.get(models.Calendar, calendar_id) is not None else False + + +def is_owned(db: Session, calendar_id: int, subscriber_id: int): + """check if calendar belongs to subscriber""" + return ( + db.query(models.Calendar) + .filter(models.Calendar.id == calendar_id, models.Calendar.owner_id == subscriber_id) + .first() + is not None + ) + + +def get(db: Session, calendar_id: int): + """retrieve calendar by id""" + return db.get(models.Calendar, calendar_id) + + +def is_connected(db: Session, calendar_id: int): + """true if calendar of given id exists""" + return get(db, calendar_id).connected + + +def get_by_url(db: Session, url: str): + """retrieve calendar by calendar url""" + return db.query(models.Calendar).filter(models.Calendar.url == url).first() + + +def get_by_subscriber(db: Session, subscriber_id: int, include_unconnected: bool = True): + """retrieve list of calendars by owner id""" + query = db.query(models.Calendar).filter(models.Calendar.owner_id == subscriber_id) + + if not include_unconnected: + query = query.filter(models.Calendar.connected == 1) + + return query.all() + + +def create(db: Session, calendar: schemas.CalendarConnection, subscriber_id: int): + """create new calendar for owner, if not already existing""" + db_calendar = models.Calendar(**calendar.dict(), owner_id=subscriber_id) + subscriber_calendars = get_by_subscriber(db, subscriber_id) + 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") + # add new calendar + db.add(db_calendar) + db.commit() + db.refresh(db_calendar) + return db_calendar + + +def update(db: Session, calendar: schemas.CalendarConnection, calendar_id: int): + """update existing calendar by id""" + db_calendar = get(db, calendar_id) + + # list of all attributes that must never be updated + # # because they have dedicated update functions for security reasons + ignore = ["connected", "connected_at"] + # list of all attributes that will keep their current value if None is passed + 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 + if key in ignore or (key in keep_if_none and (not value or len(str(value)) == 0)): + continue + + setattr(db_calendar, key, value) + + db.commit() + db.refresh(db_calendar) + return db_calendar + + +def update_connection(db: Session, is_connected: bool, calendar_id: int): + """Updates the connected status of a calendar""" + db_calendar = get(db, calendar_id) + # check subscription limitation on connecting + if is_connected: + subscriber_calendars = get_by_subscriber(db, db_calendar.owner_id) + connected_calendars = [calendar for calendar in subscriber_calendars if calendar.connected] + 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" + ) + if not db_calendar.connected: + db_calendar.connected_at = datetime.now() + elif db_calendar.connected and is_connected is False: + db_calendar.connected_at = None + db_calendar.connected = is_connected + db.commit() + db.refresh(db_calendar) + return db_calendar + + +def update_or_create(db: Session, calendar: schemas.CalendarConnection, calendar_url: str, subscriber_id: int): + """update or create a subscriber calendar""" + subscriber_calendar = get_by_url(db, calendar_url) + + if subscriber_calendar is None: + return create(db, calendar, subscriber_id) + + return update(db, calendar, subscriber_calendar.id) + + +def delete(db: Session, calendar_id: int): + """remove existing calendar by id""" + db_calendar = get(db, calendar_id) + db.delete(db_calendar) + db.commit() + return db_calendar + + +def delete_by_subscriber(db: Session, subscriber_id: int): + """Delete all calendars by subscriber""" + calendars = get_by_subscriber(db, subscriber_id=subscriber_id) + for calendar in calendars: + delete(db, calendar_id=calendar.id) + return True diff --git a/backend/src/appointment/database/repo/external_connection.py b/backend/src/appointment/database/repo/external_connection.py new file mode 100644 index 000000000..27e0c99c6 --- /dev/null +++ b/backend/src/appointment/database/repo/external_connection.py @@ -0,0 +1,78 @@ +"""Module: repo.external_connection + +Repository providing CRUD functions for external_connection database models. +""" + + +from sqlalchemy.orm import Session +from .. import models, repo +from ..schemas import ExternalConnection + + +"""External Connections repository functions +""" + + +def create(db: Session, external_connection: ExternalConnection): + 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): + db_results = get_by_type(db, subscriber_id, type, type_id) + if db_results is None or len(db_results) == 0: + return None + + db_external_connection = db_results[0] + db_external_connection.token = token + db.commit() + db.refresh(db_external_connection) + return db_external_connection + + +def delete_by_type(db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str): + connections = get_by_type(db, subscriber_id, type, type_id) + + # There should be one by type id, but just in case.. + for connection in connections: + db.delete(connection) + db.commit() + + return True + + +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) + .filter(models.ExternalConnections.owner_id == subscriber_id) + .filter(models.ExternalConnections.type == type) + ) + + if type_id is not None: + query = query.filter(models.ExternalConnections.type_id == type_id) + + result = query.all() + + return result + + +def get_subscriber_by_fxa_uid(db: Session, type_id: str): + """Return a subscriber from a fxa profile uid""" + query = ( + db.query(models.ExternalConnections) + .filter(models.ExternalConnections.type == models.ExternalConnectionType.fxa) + .filter(models.ExternalConnections.type_id == type_id) + ) + + result = query.first() + + if result is not None: + return result.owner + + return None diff --git a/backend/src/appointment/database/repo/invite.py b/backend/src/appointment/database/repo/invite.py new file mode 100644 index 000000000..baa0edd00 --- /dev/null +++ b/backend/src/appointment/database/repo/invite.py @@ -0,0 +1,71 @@ +"""Module: repo.invite + +Repository providing CRUD functions for invite database models. +""" + +import uuid + +from sqlalchemy.orm import Session +from .. import models, schemas + + +def get_by_code(db: Session, code: str): + """retrieve invite by code""" + return db.query(models.Invite).filter(models.Invite.code == code).first() + + +def generate_codes(db: Session, n: int): + """generate n invite codes and return the list of created invite objects""" + codes = [str(uuid.uuid4()) for _ in range(n)] + db_invites = [] + for code in codes: + invite = schemas.Invite(code=code) + db_invite = models.Invite(**invite.dict()) + db.add(db_invite) + db.commit() + db_invites.append(db_invite) + return db_invites + + +def code_exists(db: Session, code: str): + """true if invite code exists""" + return True if get_by_code(db, code) is not None else False + + +def code_is_used(db: Session, code: str): + """true if invite code is assigned to a user""" + db_invite = get_by_code(db, code) + return db_invite.is_used + + +def code_is_revoked(db: Session, code: str): + """true if invite code is revoked""" + db_invite = get_by_code(db, code) + return db_invite.is_revoked + + +def code_is_available(db: Session, code: str): + """true if invite code exists and can still be used""" + db_invite = get_by_code(db, code) + return db_invite and db_invite.is_available + + +def use_code(db: Session, code: str, subscriber_id: int): + """assign given subscriber to an invite""" + db_invite = get_by_code(db, code) + if db_invite and db_invite.is_available: + db_invite.subscriber_id = subscriber_id + db.commit() + db.refresh(db_invite) + return True + else: + return False + + +def revoke_code(db: Session, code: str): + """set existing invite code status to revoked""" + db_invite = get_by_code(db, code) + db_invite.status = models.InviteStatus.revoked + db.commit() + db.refresh(db_invite) + return True diff --git a/backend/src/appointment/database/repo/schedule.py b/backend/src/appointment/database/repo/schedule.py new file mode 100644 index 000000000..f91381b67 --- /dev/null +++ b/backend/src/appointment/database/repo/schedule.py @@ -0,0 +1,71 @@ +"""Module: repo.schedule + +Repository providing CRUD functions for schedule database models. +""" + +from sqlalchemy.orm import Session +from .. import models, schemas, repo + + +def create(db: Session, schedule: schemas.ScheduleBase): + """create a new schedule with slots for calendar""" + db_schedule = models.Schedule(**schedule.dict()) + db.add(db_schedule) + db.commit() + db.refresh(db_schedule) + return db_schedule + + +def get_by_subscriber(db: Session, subscriber_id: int): + """Get schedules by subscriber id""" + return ( + db.query(models.Schedule) + .join(models.Calendar, models.Schedule.calendar_id == models.Calendar.id) + .filter(models.Calendar.owner_id == subscriber_id) + .all() + ) + + +def get(db: Session, schedule_id: int): + """retrieve schedule by id""" + if schedule_id: + return db.get(models.Schedule, schedule_id) + return None + + +def is_owned(db: Session, schedule_id: int, subscriber_id: int): + """check if the given schedule belongs to subscriber""" + schedules = get_by_subscriber(db, subscriber_id) + return any(s.id == schedule_id for s in schedules) + + +def exists(db: Session, schedule_id: int): + """true if schedule of given id exists""" + return True if get(db, schedule_id) is not None else False + + +def is_calendar_connected(db: Session, schedule_id: int) -> bool: + """true if the schedule's calendar is connected""" + schedule: models.Schedule = get(db, schedule_id) + return schedule.calendar and schedule.calendar.connected + + +def update(db: Session, schedule: schemas.ScheduleBase, schedule_id: int): + """update existing schedule by id""" + db_schedule = get(db, schedule_id) + for key, value in schedule: + setattr(db_schedule, key, value) + db.commit() + db.refresh(db_schedule) + return db_schedule + + +def get_availability(db: Session, schedule_id: int): + """retrieve availability by schedule id""" + return db.query(models.Availability).filter(models.Availability.schedule_id == schedule_id).all() + + +def has_slot(db: Session, schedule_id: int, slot_id: int): + """check if slot belongs to schedule""" + db_slot = repo.slot.get(db, slot_id) + return db_slot and db_slot.schedule_id == schedule_id diff --git a/backend/src/appointment/database/repo/slot.py b/backend/src/appointment/database/repo/slot.py new file mode 100644 index 000000000..1e5af4014 --- /dev/null +++ b/backend/src/appointment/database/repo/slot.py @@ -0,0 +1,122 @@ +"""Module: repo.slot + +Repository providing CRUD functions for slot database models. +""" + +from sqlalchemy.orm import Session +from .. import models, schemas, repo + + +""" SLOT repository functions +""" + + +def get(db: Session, slot_id: int) -> models.Slot | None: + """retrieve slot by id""" + if slot_id: + return db.get(models.Slot, slot_id) + return None + + +def get_by_subscriber(db: Session, subscriber_id: int): + """retrieve list of slots by subscriber id""" + + # We need to walk through Calendars to attach Appointments, and Appointments to get Slots + return ( + db.query(models.Slot) + .join(models.Appointment) + .join(models.Calendar) + .filter(models.Calendar.owner_id == subscriber_id) + .filter(models.Appointment.calendar_id == models.Calendar.id) + .filter(models.Slot.appointment_id == models.Appointment.id) + .all() + ) + + +def add_for_appointment(db: Session, slots: list[schemas.SlotBase], appointment_id: int): + """create new slots for appointment of given id""" + for slot in slots: + db_slot = models.Slot(**slot.dict()) + db_slot.appointment_id = appointment_id + db.add(db_slot) + db.commit() + return slots + + +def add_for_schedule(db: Session, slot: schemas.SlotBase, schedule_id: int): + """create new slot for schedule of given id""" + db_slot = models.Slot(**slot.dict()) + db_slot.schedule_id = schedule_id + db.add(db_slot) + db.commit() + db.refresh(db_slot) + return db_slot + + +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() + ) + return db_slot is not None + + +def book(db: Session, slot_id: int) -> models.Slot | None: + """update booking status for slot of given id""" + db_slot = get(db, slot_id) + db_slot.booking_status = models.BookingStatus.booked + db.commit() + db.refresh(db_slot) + return db_slot + + +def delete_all_for_appointment(db: Session, appointment_id: int): + """delete all slots for appointment of given id""" + return db.query(models.Slot).filter(models.Slot.appointment_id == appointment_id).delete() + + +def delete_all_for_subscriber(db: Session, subscriber_id: int): + """Delete all slots by subscriber""" + slots = get_by_subscriber(db, subscriber_id) + + for slot in slots: + db.delete(slot) + db.commit() + + return True + + +def update(db: Session, slot_id: int, attendee: schemas.Attendee): + """update existing slot by id and create corresponding attendee""" + # create attendee + db_attendee = models.Attendee(**attendee.dict()) + db.add(db_attendee) + db.commit() + db.refresh(db_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) + db.commit() + return db_attendee + + +def delete(db: Session, slot_id: int): + """remove existing slot by id""" + db_slot = get(db, slot_id) + db.delete(db_slot) + db.commit() + return db_slot + + +def is_available(db: Session, slot_id: int): + """check if slot is still available for booking""" + slot = get(db, slot_id) + if slot.schedule: + return slot and slot.booking_status == models.BookingStatus.requested + return False diff --git a/backend/src/appointment/database/repo/subscriber.py b/backend/src/appointment/database/repo/subscriber.py new file mode 100644 index 000000000..8d8fba1f8 --- /dev/null +++ b/backend/src/appointment/database/repo/subscriber.py @@ -0,0 +1,121 @@ +"""Module: repo.subscriber + +Repository providing CRUD functions for subscriber database models. +""" + +import re + +from sqlalchemy.orm import Session +from .. import models, schemas +from ...controller.auth import sign_url + + +def get(db: Session, subscriber_id: int) -> models.Subscriber | None: + """retrieve subscriber by id""" + return db.get(models.Subscriber, subscriber_id) + + +def get_by_email(db: Session, email: str): + """retrieve subscriber by email""" + return db.query(models.Subscriber).filter(models.Subscriber.email == email).first() + + +def get_by_username(db: Session, username: str): + """retrieve subscriber by username""" + return db.query(models.Subscriber).filter(models.Subscriber.username == username).first() + + +def get_by_appointment(db: Session, appointment_id: int): + """retrieve appointment by subscriber username and appointment slug (public)""" + if appointment_id: + return ( + db.query(models.Subscriber) + .join(models.Calendar) + .join(models.Appointment) + .filter(models.Appointment.id == appointment_id) + .first() + ) + return None + + +def get_by_google_state(db: Session, state: str): + """retrieve subscriber by google state, you'll have to manually check the google_state_expire_at!""" + if state is None: + return None + return db.query(models.Subscriber).filter(models.Subscriber.google_state == state).first() + + +def create(db: Session, subscriber: schemas.SubscriberBase): + """create new subscriber""" + db_subscriber = models.Subscriber(**subscriber.dict()) + db.add(db_subscriber) + db.commit() + db.refresh(db_subscriber) + return db_subscriber + + +def update(db: Session, data: schemas.SubscriberIn, subscriber_id: int): + """update all subscriber attributes, they can edit themselves""" + db_subscriber = get(db, subscriber_id) + for key, value in data: + if value is not None: + setattr(db_subscriber, key, value) + db.commit() + db.refresh(db_subscriber) + return db_subscriber + + +def delete(db: Session, subscriber: models.Subscriber): + """Delete a subscriber by subscriber id""" + db.delete(subscriber) + db.commit() + return True + + +def get_connections_limit(db: Session, subscriber_id: int): + """return the number of allowed connections for given subscriber or -1 for unlimited connections""" + # db_subscriber = get(db, subscriber_id) + # mapping = { + # models.SubscriberLevel.basic: int(os.getenv("TIER_BASIC_CALENDAR_LIMIT")), + # models.SubscriberLevel.plus: int(os.getenv("TIER_PLUS_CALENDAR_LIMIT")), + # models.SubscriberLevel.pro: int(os.getenv("TIER_PRO_CALENDAR_LIMIT")), + # models.SubscriberLevel.admin: -1, + # } + # return mapping[db_subscriber.level] + + # No limit right now! + return -1 + + +def verify_link(db: Session, url: str): + """Check if a given url is a valid signed subscriber profile link + Return subscriber if valid. + """ + # Look for a followed by an optional signature that ends the string + pattern = r"[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$" + match = re.findall(pattern, url) + + if match is None or len(match) == 0: + return False + + # Flatten + match = match[0] + clean_url = url + + username = match[0] + signature = None + if len(match) > 1: + signature = match[1] + 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}" + signed_signature = sign_url(clean_url_with_short_link) + + # Verify the signature matches the incoming one + if signed_signature == signature: + return subscriber + return False diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index a30bc4371..18b5588d2 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -30,6 +30,7 @@ class AttendeeBase(BaseModel): email: str name: str | None = None + timezone: str | None = None class Attendee(AttendeeBase): @@ -152,7 +153,7 @@ class Config: class ScheduleBase(BaseModel): active: bool | None = True - name: str + name: str = Field(min_length=1) calendar_id: int location_type: LocationType | None = LocationType.inperson location_url: str | None = None @@ -178,6 +179,7 @@ class Schedule(ScheduleBase): time_created: datetime | None = None time_updated: datetime | None = None availabilities: list[Availability] = [] + calendar: 'CalendarBase' class Config: from_attributes = True diff --git a/backend/src/appointment/defines.py b/backend/src/appointment/defines.py index 662b5e751..49559bf74 100644 --- a/backend/src/appointment/defines.py +++ b/backend/src/appointment/defines.py @@ -8,3 +8,5 @@ APP_ENV_DEV = 'dev' APP_ENV_TEST = 'test' +APP_ENV_STAGE = 'stage' +APP_ENV_PROD = 'prod' diff --git a/backend/src/appointment/dependencies/auth.py b/backend/src/appointment/dependencies/auth.py index bf4a338ac..ccece129f 100644 --- a/backend/src/appointment/dependencies/auth.py +++ b/backend/src/appointment/dependencies/auth.py @@ -3,7 +3,7 @@ from fastapi import Depends, Request, HTTPException, Body from fastapi.security import OAuth2PasswordBearer -from jose import jwt, JWTError +import jwt from sqlalchemy.orm import Session @@ -22,18 +22,18 @@ def get_user_from_token(db, token: str): iat = payload.get("iat") if sub is None: raise InvalidTokenException() - except JWTError: + except jwt.exceptions.InvalidTokenError: raise InvalidTokenException() id = sub.replace('uid-', '') - subscriber = repo.get_subscriber(db, int(id)) + 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 subscriber is None: - raise InvalidTokenException() - elif subscriber.minimum_valid_iat_time and not iat: - raise InvalidTokenException() - elif subscriber.minimum_valid_iat_time and subscriber.minimum_valid_iat_time.timestamp() > int(iat): + if any([ + subscriber is None, + 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 @@ -60,7 +60,7 @@ def get_subscriber_from_signed_url( db: Session = Depends(get_db), ): """Retrieve a subscriber based off a signed url from the body. Requires `url` param to be used in the request.""" - subscriber = repo.verify_subscriber_link(db, url) + subscriber = repo.subscriber.verify_link(db, url) if not subscriber: raise validation.InvalidLinkException diff --git a/backend/src/appointment/dependencies/fxa.py b/backend/src/appointment/dependencies/fxa.py index 0b386d680..65192bf90 100644 --- a/backend/src/appointment/dependencies/fxa.py +++ b/backend/src/appointment/dependencies/fxa.py @@ -1,8 +1,10 @@ import logging import os +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 @@ -31,7 +33,7 @@ def get_webhook_auth(request: Request, fxa_client: FxaClient = Depends(get_fxa_c logging.error("No public jwks available.") return None - headers = jwt.get_unverified_headers(header_token) + headers = jwt.get_unverified_header(header_token) if 'kid' not in headers: logging.error("Error decoding token. Key ID is missing from headers.") @@ -40,14 +42,17 @@ def get_webhook_auth(request: Request, fxa_client: FxaClient = Depends(get_fxa_c jwk_pem = None for current_jwk in public_jwks: if current_jwk.get('kid') == headers.get('kid'): - jwk_pem = jwk.construct(current_jwk) + jwk_pem = jwt.PyJWK(current_jwk).key break if jwk_pem is None: logging.error(f"Error decoding token. Key ID ({headers.get('kid')}) is missing from public list.") return None - decoded_jwt = jwt.decode(header_token, jwk_pem, audience=fxa_client.client_id, algorithms='RS256') + # 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) # Final verification if decoded_jwt.get('iss') != fxa_client.config.issuer: diff --git a/backend/src/appointment/exceptions/validation.py b/backend/src/appointment/exceptions/validation.py index 05ac99841..e939b2977 100644 --- a/backend/src/appointment/exceptions/validation.py +++ b/backend/src/appointment/exceptions/validation.py @@ -30,7 +30,7 @@ def get_msg(self): class InvalidLinkException(APIException): - """Raise when verify_subscriber_link fails""" + """Raise when subscriber.verify_link fails""" id_code = 'INVALID_LINK' status_code = 400 diff --git a/backend/src/appointment/l10n.py b/backend/src/appointment/l10n.py index 8cc39d8fa..1232c4710 100644 --- a/backend/src/appointment/l10n.py +++ b/backend/src/appointment/l10n.py @@ -1,10 +1,14 @@ from typing import Union, Dict, Any -from starlette_context import context + +from starlette_context import context, errors def l10n(msg_id: str, args: Union[Dict[str, Any], None] = None) -> str: """Helper function to automatically call fluent.format_value from context""" - if 'l10n' not in context: + try: + if 'l10n' not in context: + return msg_id + except errors.ContextDoesNotExistError: return msg_id return context['l10n'](msg_id, args) diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index 0cbd0c0c8..c731dd56f 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -8,7 +8,7 @@ from starlette_context.middleware import RawContextMiddleware from fastapi import Request -from .defines import APP_ENV_DEV, APP_ENV_TEST +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" @@ -66,16 +66,32 @@ def _common_setup(): if release_version: release_string = f"appointment-backend@{release_version}" + sample_rate = 0 + profile_traces_max = 0 + environment = os.getenv("APP_ENV", APP_ENV_STAGE) + + if environment == APP_ENV_STAGE: + profile_traces_max = 0.25 + sample_rate = 1.0 + elif environment == APP_ENV_PROD: + profile_traces_max = 0.25 + sample_rate = 1.0 + + def traces_sampler(sampling_context): + """Tell Sentry to ignore or reduce traces for particular routes""" + asgi_scope = sampling_context.get('asgi_scope', {}) + path = asgi_scope.get('path') + + # Ignore health check and favicon.ico + if path == '/' or '/favicon.ico': + return 0 + + return profile_traces_max + sentry_sdk.init( 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, - # Only profile staging for now - profiles_sample_rate=1.0 if os.getenv("APP_ENV", "stage") else 0.0, - send_default_pii=True if os.getenv("APP_ENV", "stage") else False, - environment=os.getenv("APP_ENV", "dev"), + sample_rate=sample_rate, + environment=environment, release=release_string, integrations=[ StarletteIntegration( @@ -85,6 +101,8 @@ def _common_setup(): transaction_style="endpoint" ), ], + profiles_sampler=traces_sampler, + traces_sampler=traces_sampler ) @@ -107,7 +125,7 @@ def server(): from .routes import webhooks # Hide openapi url (which will also hide docs/redoc) if we're not dev - openapi_url = '/openapi.json' if os.getenv('APP_ENV') == 'dev' else None + openapi_url = '/openapi.json' if os.getenv('APP_ENV') == APP_ENV_DEV else None # init app app = FastAPI(openapi_url=openapi_url) @@ -188,7 +206,7 @@ def cli(): from .routes import commands - app = typer.Typer() + 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() diff --git a/backend/src/appointment/migrations/env.py b/backend/src/appointment/migrations/env.py index b14c9babc..83b69abb4 100644 --- a/backend/src/appointment/migrations/env.py +++ b/backend/src/appointment/migrations/env.py @@ -6,8 +6,9 @@ from alembic import context +from appointment.defines import APP_ENV_DEV # This is ran from src/ so ignore the errors -from secrets import normalize_secrets +from appointment.secrets import normalize_secrets import sentry_sdk @@ -42,7 +43,7 @@ # of transactions for performance monitoring. # We recommend adjusting this value in production, traces_sample_rate=1.0, - environment=os.getenv("APP_ENV", "dev"), + environment=os.getenv("APP_ENV", APP_ENV_DEV), ) 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 2362a3512..f9040b3a5 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,8 @@ Create Date: 2024-04-16 12:41:53.550102 """ +import os + from alembic import op import sqlalchemy as sa from database.models import InviteStatus 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 new file mode 100644 index 000000000..5d70c2a18 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_04_18_0823-89e1197d980d_add_attendee_timezone.py @@ -0,0 +1,24 @@ +"""add attendee timezone + +Revision ID: 89e1197d980d +Revises: fadd0d1ef438 +Create Date: 2024-04-18 08:23:55.660065 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '89e1197d980d' +down_revision = 'fadd0d1ef438' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('attendees', sa.Column('timezone', sa.String(255), index=True)) + + +def downgrade() -> None: + op.drop_column('attendees', 'timezone') diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index f490ed355..0e00a7351 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -1,6 +1,7 @@ import logging import os import secrets +from typing import Annotated import requests.exceptions import validators @@ -18,7 +19,7 @@ # authentication from ..controller.calendar import CalDavConnector, Tools, GoogleConnector -from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks, Query, Request from datetime import timedelta, timezone from ..controller.apis.google_client import GoogleClient from ..controller.auth import signed_url_by_subscriber @@ -46,10 +47,10 @@ def update_me( data: schemas.SubscriberIn, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber) ): """endpoint to update data of authenticated subscriber""" - if subscriber.username != data.username and repo.get_subscriber_by_username(db, data.username): + if subscriber.username != data.username and repo.subscriber.get_by_username(db, data.username): raise HTTPException(status_code=403, detail=l10n('username-not-available')) - me = repo.update_subscriber(db=db, data=data, subscriber_id=subscriber.id) + me = repo.subscriber.update(db=db, data=data, subscriber_id=subscriber.id) return schemas.SubscriberBase( username=me.username, email=me.email, name=me.name, level=me.level, timezone=me.timezone ) @@ -60,7 +61,7 @@ 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.get_calendars_by_subscriber( + 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] @@ -69,7 +70,7 @@ def read_my_calendars( @router.get("/me/appointments", response_model=list[schemas.AppointmentWithCalendarOut]) def read_my_appointments(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """get all appointments of authenticated subscriber""" - appointments = repo.get_appointments_by_subscriber(db, subscriber_id=subscriber.id) + 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( @@ -87,7 +88,7 @@ def get_my_signature(subscriber: Subscriber = Depends(get_subscriber)): @router.post("/me/signature") def refresh_signature(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Refresh a subscriber's signed short link""" - repo.update_subscriber( + repo.subscriber.update( db, schemas.SubscriberAuth( email=subscriber.email, username=subscriber.username, short_link_hash=secrets.token_hex(32) @@ -101,7 +102,7 @@ def refresh_signature(db: Session = Depends(get_db), subscriber: Subscriber = De @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.verify_subscriber_link(db, url): + if repo.subscriber.verify_link(db, url): return True raise validation.InvalidLinkException() @@ -118,7 +119,7 @@ def create_my_calendar( # Test the connection first if calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.get_external_connections_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() @@ -149,7 +150,7 @@ def create_my_calendar( # create calendar try: - cal = repo.create_subscriber_calendar(db=db, calendar=calendar, subscriber_id=subscriber.id) + cal = repo.calendar.create(db=db, calendar=calendar, subscriber_id=subscriber.id) except HTTPException as e: raise HTTPException(status_code=e.status_code, detail=e.detail) return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) @@ -158,11 +159,11 @@ def create_my_calendar( @router.get("/cal/{id}", response_model=schemas.CalendarConnectionOut) def read_my_calendar(id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """endpoint to get a calendar from db""" - cal = repo.get_calendar(db, calendar_id=id) + cal = repo.calendar.get(db, calendar_id=id) if cal is None: raise validation.CalendarNotFoundException() - if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): + if not repo.calendar.is_owned(db, calendar_id=id, subscriber_id=subscriber.id): raise validation.CalendarNotAuthorizedException() return schemas.CalendarConnectionOut( @@ -184,29 +185,35 @@ def update_my_calendar( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to update an existing calendar connection for authenticated subscriber""" - if not repo.calendar_exists(db, calendar_id=id): + if not repo.calendar.exists(db, calendar_id=id): raise validation.CalendarNotFoundException() - if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): + if not repo.calendar.is_owned(db, calendar_id=id, subscriber_id=subscriber.id): raise validation.CalendarNotAuthorizedException() - cal = repo.update_subscriber_calendar(db=db, calendar=calendar, calendar_id=id) + cal = repo.calendar.update(db=db, calendar=calendar, calendar_id=id) return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) @router.post("/cal/{id}/connect", response_model=schemas.CalendarOut) -def connect_my_calendar( +@router.post("/cal/{id}/disconnect", response_model=schemas.CalendarOut) +def change_my_calendar_connection( + request: Request, id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), ): - """endpoint to update an existing calendar connection for authenticated subscriber""" - if not repo.calendar_exists(db, calendar_id=id): + """endpoint to update an existing calendar connection for authenticated subscriber + note this function handles both disconnect and connect (the double route is not a typo.)""" + if not repo.calendar.exists(db, calendar_id=id): raise validation.CalendarNotFoundException() - if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): + if not repo.calendar.is_owned(db, calendar_id=id, subscriber_id=subscriber.id): raise validation.CalendarNotAuthorizedException() + # If our path ends with /connect then connect the calendar, otherwise disconnect the calendar + connect = request.scope.get('path', '').endswith('/connect') + try: - cal = repo.update_subscriber_calendar_connection(db=db, calendar_id=id, is_connected=True) + cal = repo.calendar.update_connection(db=db, calendar_id=id, is_connected=connect) except HTTPException as e: raise HTTPException(status_code=e.status_code, detail=e.detail) return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) @@ -215,12 +222,12 @@ def connect_my_calendar( @router.delete("/cal/{id}", response_model=schemas.CalendarOut) def delete_my_calendar(id: int, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """endpoint to remove a calendar from db""" - if not repo.calendar_exists(db, calendar_id=id): + if not repo.calendar.exists(db, calendar_id=id): raise validation.CalendarNotFoundException() - if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): + if not repo.calendar.is_owned(db, calendar_id=id, subscriber_id=subscriber.id): raise validation.CalendarNotAuthorizedException() - cal = repo.delete_subscriber_calendar(db=db, calendar_id=id) + cal = repo.calendar.delete(db=db, calendar_id=id) return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) @@ -233,7 +240,7 @@ def read_remote_calendars( ): """endpoint to get calendars from a remote CalDAV server""" if connection.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.get_external_connections_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 +284,7 @@ def sync_remote_calendars( # TODO: Also handle CalDAV connections external_connection = utils.list_first( - repo.get_external_connections_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() @@ -312,13 +319,13 @@ def read_remote_events( redis_instance: Redis | None = Depends(get_redis), ): """endpoint to get events in a given date range from a remote calendar""" - db_calendar = repo.get_calendar(db, calendar_id=id) + db_calendar = repo.calendar.get(db, calendar_id=id) if db_calendar is None: raise validation.CalendarNotFoundException() if db_calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.get_external_connections_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() @@ -358,25 +365,25 @@ def create_my_calendar_appointment( a_s: schemas.AppointmentSlots, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber) ): """endpoint to add a new appointment with slots for a given calendar""" - if not repo.calendar_exists(db, calendar_id=a_s.appointment.calendar_id): + if not repo.calendar.exists(db, calendar_id=a_s.appointment.calendar_id): raise validation.CalendarNotFoundException() - if not repo.calendar_is_owned(db, calendar_id=a_s.appointment.calendar_id, subscriber_id=subscriber.id): + if not repo.calendar.is_owned(db, calendar_id=a_s.appointment.calendar_id, subscriber_id=subscriber.id): raise validation.CalendarNotAuthorizedException() - if not repo.calendar_is_connected(db, calendar_id=a_s.appointment.calendar_id): + 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: raise validation.ZoomNotConnectedException() - return repo.create_calendar_appointment(db=db, appointment=a_s.appointment, slots=a_s.slots) + return repo.appointment.create(db=db, appointment=a_s.appointment, slots=a_s.slots) @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.get_appointment(db, appointment_id=id) + db_appointment = repo.appointment.get(db, appointment_id=id) if db_appointment is None: raise validation.AppointmentNotFoundException() - if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): + if not repo.appointment.is_owned(db, appointment_id=id, subscriber_id=subscriber.id): raise validation.AppointmentNotAuthorizedException() return db_appointment @@ -390,36 +397,36 @@ def update_my_appointment( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to update an existing appointment with slots""" - db_appointment = repo.get_appointment(db, appointment_id=id) + db_appointment = repo.appointment.get(db, appointment_id=id) if db_appointment is None: raise validation.AppointmentNotFoundException() - if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): + if not repo.appointment.is_owned(db, appointment_id=id, subscriber_id=subscriber.id): raise validation.AppointmentNotAuthorizedException() - return repo.update_calendar_appointment(db=db, appointment=a_s.appointment, slots=a_s.slots, appointment_id=id) + 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) 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.get_appointment(db, appointment_id=id) + db_appointment = repo.appointment.get(db, appointment_id=id) if db_appointment is None: raise validation.AppointmentNotFoundException() - if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): + if not repo.appointment.is_owned(db, appointment_id=id, subscriber_id=subscriber.id): raise validation.AppointmentNotAuthorizedException() - return repo.delete_calendar_appointment(db=db, appointment_id=id) + return repo.appointment.delete(db=db, appointment_id=id) @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.get_public_appointment(db, slug=slug) + a = repo.appointment.get_public(db, slug=slug) if a is None: raise validation.AppointmentNotFoundException() - s = repo.get_subscriber_by_appointment(db=db, appointment_id=a.id) + s = repo.subscriber.get_by_appointment(db=db, appointment_id=a.id) if s is None: raise validation.SubscriberNotFoundException() slots = [ @@ -439,23 +446,23 @@ def update_public_appointment_slot( google_client: GoogleClient = Depends(get_google_client), ): """endpoint to update a time slot for an appointment via public link and create an event in remote calendar""" - db_appointment = repo.get_public_appointment(db, slug=slug) + db_appointment = repo.appointment.get_public(db, slug=slug) if db_appointment is None: raise validation.AppointmentNotFoundException() - db_calendar = repo.get_calendar(db, calendar_id=db_appointment.calendar_id) + db_calendar = repo.calendar.get(db, calendar_id=db_appointment.calendar_id) if db_calendar is None: raise validation.CalendarNotFoundException() - if not repo.appointment_has_slot(db, appointment_id=db_appointment.id, slot_id=s_a.slot_id): + if not repo.appointment.has_slot(db, appointment_id=db_appointment.id, slot_id=s_a.slot_id): raise validation.SlotNotFoundException() - if not repo.slot_is_available(db, slot_id=s_a.slot_id): + if not repo.slot.is_available(db, slot_id=s_a.slot_id): raise validation.SlotAlreadyTakenException() if not validators.email(s_a.attendee.email): raise HTTPException(status_code=400, detail=l10n('slot-invalid-email')) - slot = repo.get_slot(db=db, slot_id=s_a.slot_id) + slot = repo.slot.get(db=db, slot_id=s_a.slot_id) # grab the subscriber - organizer = repo.get_subscriber_by_appointment(db=db, appointment_id=db_appointment.id) + organizer = repo.subscriber.get_by_appointment(db=db, appointment_id=db_appointment.id) location_url = db_appointment.location_url @@ -509,7 +516,7 @@ def update_public_appointment_slot( # create remote event if db_calendar.provider == CalendarProvider.google: - external_connection = utils.list_first(repo.get_external_connections_by_type(db, organizer.id, schemas.ExternalConnectionType.google)) + external_connection = utils.list_first(repo.external_connection.get_by_type(db, organizer.id, schemas.ExternalConnectionType.google)) if external_connection is None or external_connection.token is None: raise RemoteCalendarConnectionError() @@ -537,7 +544,7 @@ def update_public_appointment_slot( con.create_event(event=event, attendee=s_a.attendee, organizer=organizer, organizer_email=organizer_email) # update appointment slot data - repo.update_slot(db=db, slot_id=s_a.slot_id, attendee=s_a.attendee) + repo.slot.update(db=db, slot_id=s_a.slot_id, attendee=s_a.attendee) # send mail with .ics attachment to attendee Tools().send_vevent(background_tasks, db_appointment, slot, organizer, s_a.attendee) @@ -548,18 +555,18 @@ def update_public_appointment_slot( @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.get_public_appointment(db, slug=slug) + db_appointment = repo.appointment.get_public(db, slug=slug) if db_appointment is None: raise validation.AppointmentNotFoundException() - if not repo.appointment_has_slot(db, appointment_id=db_appointment.id, slot_id=slot_id): + if not repo.appointment.has_slot(db, appointment_id=db_appointment.id, slot_id=slot_id): raise validation.SlotNotFoundException() - slot = repo.get_slot(db=db, slot_id=slot_id) + slot = repo.slot.get(db=db, slot_id=slot_id) if slot is None: raise validation.SlotNotFoundException() - organizer = repo.get_subscriber_by_appointment(db=db, appointment_id=db_appointment.id) + organizer = repo.subscriber.get_by_appointment(db=db, appointment_id=db_appointment.id) return schemas.FileDownload( name="invite", diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 01371d6e1..18bc46f03 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -5,8 +5,9 @@ from typing import Annotated import argon2.exceptions +import jwt from fastapi.security import OAuth2PasswordRequestForm -from jose import jwt +from sentry_sdk import capture_exception from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, HTTPException, Request @@ -111,14 +112,14 @@ def fxa_callback( raise HTTPException(400, l10n('email-mismatch')) # Check if we have an existing fxa connection by profile's uid - fxa_subscriber = repo.get_subscriber_by_fxa_uid(db, profile['uid']) + fxa_subscriber = repo.external_connection.get_subscriber_by_fxa_uid(db, profile['uid']) # Also look up the subscriber (in case we have an existing account that's not tied to a given fxa account) - subscriber = repo.get_subscriber_by_email(db, email) + subscriber = repo.subscriber.get_by_email(db, email) new_subscriber_flow = not fxa_subscriber and not subscriber if new_subscriber_flow: - subscriber = repo.create_subscriber(db, schemas.SubscriberBase( + subscriber = repo.subscriber.create(db, schemas.SubscriberBase( email=email, username=email, timezone=timezone, @@ -126,6 +127,18 @@ def fxa_callback( elif not subscriber: subscriber = fxa_subscriber + fxa_connections = repo.external_connection.get_by_type(db, subscriber.id, ExternalConnectionType.fxa) + + # If we have fxa_connections, ensure the incoming one matches our known one. + # This shouldn't occur, but it's a safety check in-case we missed a webhook push. + 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") + capture_exception(e) + + raise HTTPException(403, l10n('invalid-credentials')) + external_connection_schema = schemas.ExternalConnection( name=profile['email'], type=ExternalConnectionType.fxa, @@ -135,9 +148,9 @@ def fxa_callback( ) if not fxa_subscriber: - repo.create_subscriber_external_connection(db, external_connection_schema) + repo.external_connection.create(db, external_connection_schema) else: - repo.update_subscriber_external_connection_token(db, json.dumps(creds), subscriber.id, + repo.external_connection.update_token(db, json.dumps(creds), subscriber.id, external_connection_schema.type, external_connection_schema.type_id) @@ -155,7 +168,7 @@ def fxa_callback( data.name = profile['displayName'] if 'displayName' in profile else profile['email'].split('@')[0] data.username = profile['email'] - repo.update_subscriber(db, data, subscriber.id) + repo.subscriber.update(db, data, subscriber.id) # Generate our jwt token, we only store the username on the token access_token_expires = timedelta(minutes=float(os.getenv('JWT_EXPIRE_IN_MINS'))) @@ -175,7 +188,7 @@ def token( raise HTTPException(status_code=405) """Retrieve an access token from a given username and password.""" - subscriber = repo.get_subscriber_by_username(db, form_data.username) + subscriber = repo.subscriber.get_by_username(db, form_data.username) if not subscriber or subscriber.password is None: raise HTTPException(status_code=403, detail=l10n('invalid-credentials')) @@ -232,7 +245,7 @@ def me( # if os.getenv('AUTH_SCHEME') != 'password': # raise HTTPException(status_code=405) # -# subscriber = repo.create_subscriber(db, schemas.SubscriberBase( +# subscriber = repo.subscriber.create(db, schemas.SubscriberBase( # email=email, # username=email, # name=email.split('@')[0], diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index b076d5741..cbf92632f 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -59,7 +59,7 @@ def google_callback( return google_callback_error(l10n('google-auth-fail')) subscriber_id = request.session.get('google_oauth_subscriber_id') - subscriber = repo.get_subscriber(db, subscriber_id) + subscriber = repo.subscriber.get(db, subscriber_id) # Clear session keys request.session.pop('google_oauth_state') @@ -76,10 +76,10 @@ def google_callback( if google_id is None: return google_callback_error(l10n('google-auth-fail')) - external_connection = repo.get_external_connections_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.get_external_connections_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')) @@ -94,9 +94,9 @@ def google_callback( token=creds.to_json() ) - repo.create_subscriber_external_connection(db, external_connection_schema) + repo.external_connection.create(db, external_connection_schema) else: - repo.update_subscriber_external_connection_token(db, creds.to_json(), subscriber.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) diff --git a/backend/src/appointment/routes/invite.py b/backend/src/appointment/routes/invite.py index e2e285eaf..972ebba06 100644 --- a/backend/src/appointment/routes/invite.py +++ b/backend/src/appointment/routes/invite.py @@ -19,28 +19,32 @@ def get_all_invites(db: Session = Depends(get_db)): @router.post("/generate/{n}", response_model=list[schemas.Invite]) def generate_invite_codes(n: int, db: Session = Depends(get_db)): + raise NotImplementedError """endpoint to generate n invite codes""" - return repo.generate_invite_codes(db, n) + return repo.invite.generate_codes(db, n) @router.put("/redeem/{code}") def use_invite_code(code: str, db: Session = Depends(get_db)): + raise NotImplementedError + """endpoint to create a new subscriber and update the corresponding invite""" - if not repo.invite_code_exists(db, code): + if not repo.invite.code_exists(db, code): raise validation.InviteCodeNotFoundException() - if not repo.invite_code_is_available(db, code): + if not repo.invite.code_is_available(db, code): raise validation.InviteCodeNotAvailableException() # TODO: get email from admin panel email = 'placeholder@mozilla.org' - subscriber = repo.create_subscriber(db, schemas.SubscriberBase(email=email, username=email)) - return repo.use_invite_code(db, code, subscriber.id) + subscriber = repo.subscriber.create(db, schemas.SubscriberBase(email=email, username=email)) + return repo.invite.use_code(db, code, subscriber.id) @router.put("/revoke/{code}") -def use_invite_code(code: str, db: Session = Depends(get_db)): +def revoke_invite_code(code: str, db: Session = Depends(get_db)): + raise NotImplementedError """endpoint to revoke a given invite code and mark in unavailable""" - if not repo.invite_code_exists(db, code): + if not repo.invite.code_exists(db, code): raise validation.InviteCodeNotFoundException() - if not repo.invite_code_is_available(db, code): + if not repo.invite.code_is_available(db, code): raise validation.InviteCodeNotAvailableException() - return repo.revoke_invite_code(db, code) + return repo.invite.revoke_code(db, code) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index f5e545ef4..8a2d5a814 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -38,19 +38,19 @@ def create_calendar_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to add a new schedule for a given calendar""" - if not repo.calendar_exists(db, calendar_id=schedule.calendar_id): + if not repo.calendar.exists(db, calendar_id=schedule.calendar_id): raise validation.CalendarNotFoundException() - if not repo.calendar_is_owned(db, calendar_id=schedule.calendar_id, subscriber_id=subscriber.id): + if not repo.calendar.is_owned(db, calendar_id=schedule.calendar_id, subscriber_id=subscriber.id): raise validation.CalendarNotAuthorizedException() - if not repo.calendar_is_connected(db, calendar_id=schedule.calendar_id): + if not repo.calendar.is_connected(db, calendar_id=schedule.calendar_id): raise validation.CalendarNotConnectedException() - return repo.create_calendar_schedule(db=db, schedule=schedule) + return repo.schedule.create(db=db, schedule=schedule) @router.get("/", response_model=list[schemas.Schedule]) def read_schedules(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Gets all of the available schedules for the logged in subscriber""" - return repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) + return repo.schedule.get_by_subscriber(db, subscriber_id=subscriber.id) @router.get("/{id}", response_model=schemas.Schedule, deprecated=True) @@ -60,10 +60,10 @@ def read_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """Gets information regarding a specific schedule""" - schedule = repo.get_schedule(db, schedule_id=id) + schedule = repo.schedule.get(db, schedule_id=id) if schedule is None: raise validation.ScheduleNotFoundException() - if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): + if not repo.schedule.is_owned(db, schedule_id=id, subscriber_id=subscriber.id): raise validation.ScheduleNotAuthorizedException() return schedule @@ -76,13 +76,15 @@ def update_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to update an existing calendar connection for authenticated subscriber""" - if not repo.schedule_exists(db, schedule_id=id): + if not repo.schedule.exists(db, schedule_id=id): raise validation.ScheduleNotFoundException() - if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): + if not repo.calendar.is_connected(db, calendar_id=schedule.calendar_id): + 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: raise validation.ZoomNotConnectedException() - return repo.update_calendar_schedule(db=db, schedule=schedule, schedule_id=id) + return repo.schedule.update(db=db, schedule=schedule, schedule_id=id) @router.post("/public/availability", response_model=schemas.AppointmentOut) @@ -97,18 +99,22 @@ def read_schedule_availabilities( if subscriber.timezone is None: raise validation.ScheduleNotFoundException() - schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) + schedules = repo.schedule.get_by_subscriber(db, subscriber_id=subscriber.id) try: schedule = schedules[0] # for now we only process the first existing schedule except IndexError: - raise validation.ScheduleNotFoundException() + raise validation.ScheduleNotActive() # check if schedule is enabled if not schedule.active: raise validation.ScheduleNotActive() - calendars = repo.get_calendars_by_subscriber(db, subscriber.id, False) + # check if calendar is connected, if its not then its a schedule not active error + if not schedule.calendar or not schedule.calendar.connected: + raise validation.ScheduleNotActive() + + calendars = repo.calendar.get_by_subscriber(db, subscriber.id, False) if not calendars or len(calendars) == 0: raise validation.CalendarNotFoundException() @@ -147,7 +153,7 @@ def request_schedule_availability_slot( if subscriber.timezone is None: raise validation.ScheduleNotFoundException() - schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) + schedules = repo.schedule.get_by_subscriber(db, subscriber_id=subscriber.id) try: schedule = schedules[0] # for now we only process the first existing schedule @@ -159,18 +165,18 @@ def request_schedule_availability_slot( raise validation.ScheduleNotFoundException() # get calendar - db_calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) + db_calendar = repo.calendar.get(db, calendar_id=schedule.calendar_id) if db_calendar is None: raise validation.CalendarNotFoundException() # check if slot still available, might already be taken at this time slot = schemas.SlotBase(**s_a.slot.dict()) - if repo.schedule_slot_exists(db, slot, schedule.id): + 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.get_external_connections_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() @@ -196,7 +202,7 @@ def request_schedule_availability_slot( # Ok we need to clear the cache for all calendars, because we need to recheck them. con.bust_cached_events(True) - calendars = repo.get_calendars_by_subscriber(db, subscriber.id, False) + 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) has_collision = Tools.events_roll_up_difference([slot], existing_remote_events) @@ -209,10 +215,10 @@ def request_schedule_availability_slot( slot.booking_tkn = token slot.booking_expires_at = datetime.now() + timedelta(days=1) slot.booking_status = BookingStatus.requested - slot = repo.add_schedule_slot(db, slot, schedule.id) + slot = repo.slot.add_for_schedule(db, slot, schedule.id) # create attendee for this slot - attendee = repo.update_slot(db, slot.id, s_a.attendee) + 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}" @@ -220,14 +226,19 @@ def request_schedule_availability_slot( # 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 = 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})" # 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}" - appointment = repo.create_calendar_appointment(db, schemas.AppointmentFull( + appointment = repo.appointment.create(db, schemas.AppointmentFull( title=title, details=schedule.details, calendar_id=db_calendar.id, @@ -246,7 +257,7 @@ def request_schedule_availability_slot( background_tasks.add_task(send_confirmation_email, url=url, attendee=attendee, date=date, to=subscriber.email) # Sending pending email to attendee - background_tasks.add_task(send_pending_email, owner=subscriber, date=date, to=slot.attendee.email) + background_tasks.add_task(send_pending_email, owner=subscriber, 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( @@ -269,7 +280,7 @@ def decide_on_schedule_availability_slot( if confirmed: create an event in remote calendar and send invitation mail TODO: if denied: send information mail to bookee """ - subscriber = repo.verify_subscriber_link(db, data.owner_url) + subscriber = repo.subscriber.verify_link(db, data.owner_url) if not subscriber: raise validation.InvalidLinkException() @@ -277,7 +288,7 @@ def decide_on_schedule_availability_slot( if subscriber.timezone is None: raise validation.ScheduleNotFoundException() - schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) + schedules = repo.schedule.get_by_subscriber(db, subscriber_id=subscriber.id) try: schedule = schedules[0] # for now we only process the first existing schedule except IndexError: @@ -288,16 +299,16 @@ def decide_on_schedule_availability_slot( raise validation.ScheduleNotFoundException() # get calendar - calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) + calendar = repo.calendar.get(db, calendar_id=schedule.calendar_id) if calendar is None: raise validation.CalendarNotFoundException() # get slot and check if slot exists and is not booked yet and token is the same - slot = repo.get_slot(db, data.slot_id) + slot = repo.slot.get(db, data.slot_id) if ( not slot - or not repo.slot_is_available(db, slot.id) - or not repo.schedule_has_slot(db, schedule.id, slot.id) + or not repo.slot.is_available(db, slot.id) + or not repo.schedule.has_slot(db, schedule.id, slot.id) or slot.booking_tkn != data.slot_token ): raise validation.SlotNotFoundException() @@ -314,10 +325,10 @@ def decide_on_schedule_availability_slot( if slot.appointment_id: # delete the appointment, this will also delete the slot. - repo.delete_calendar_appointment(db, slot.appointment_id) + repo.appointment.delete(db, slot.appointment_id) else: # delete the scheduled slot to make the time available again - repo.delete_slot(db, slot.id) + repo.slot.delete(db, slot.id) # otherwise, confirm slot and create event else: @@ -358,7 +369,7 @@ def decide_on_schedule_availability_slot( else: title = slot.appointment.title # Update the appointment to closed - repo.update_appointment_status(db, slot.appointment_id, models.AppointmentStatus.closed) + repo.appointment.update_status(db, slot.appointment_id, models.AppointmentStatus.closed) event = schemas.Event( title=title, @@ -377,7 +388,7 @@ def decide_on_schedule_availability_slot( # create remote event if calendar.provider == CalendarProvider.google: - external_connection: ExternalConnection|None = utils.list_first(repo.get_external_connections_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() @@ -410,11 +421,15 @@ def decide_on_schedule_availability_slot( raise EventCouldNotBeAccepted # Book the slot at the end - slot = repo.book_slot(db, slot.id) + slot = repo.slot.book(db, slot.id) Tools().send_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) return schemas.AvailabilitySlotAttendee( slot=schemas.SlotBase(start=slot.start, duration=slot.duration), - attendee=schemas.AttendeeBase(email=slot.attendee.email, name=slot.attendee.name) + attendee=schemas.AttendeeBase( + email=slot.attendee.email, + name=slot.attendee.name, + timezone=slot.attendee.timezone + ) ) diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index 5a0417070..2bdf8dea6 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -24,7 +24,7 @@ def fxa_process( ): """Main for webhooks regarding fxa""" - subscriber: models.Subscriber = repo.get_subscriber_by_fxa_uid(db, decoded_token.get('sub')) + 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.") return @@ -58,7 +58,7 @@ def fxa_process( try: profile = fxa_client.get_profile() # Update profile with fxa info - repo.update_subscriber(db, schemas.SubscriberIn( + repo.subscriber.update(db, schemas.SubscriberIn( avatar_url=profile['avatar'], name=subscriber.name, username=subscriber.username diff --git a/backend/src/appointment/routes/zoom.py b/backend/src/appointment/routes/zoom.py index 0c54d50f8..f9e448a2d 100644 --- a/backend/src/appointment/routes/zoom.py +++ b/backend/src/appointment/routes/zoom.py @@ -47,7 +47,7 @@ def zoom_callback( raise HTTPException(400, l10n('oauth-error')) # Retrieve the user id set at the start of the zoom oauth process - subscriber = repo.get_subscriber(db, request.session['zoom_user_id']) + subscriber = repo.subscriber.get(db, request.session['zoom_user_id']) # Clear zoom session keys request.session.pop('zoom_state') @@ -69,8 +69,8 @@ def zoom_callback( token=json.dumps(creds) ) - if len(repo.get_external_connections_by_type(db, subscriber.id, external_connection_schema.type, external_connection_schema.type_id)) == 0: - repo.create_subscriber_external_connection(db, external_connection_schema) + 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") @@ -84,7 +84,7 @@ def disconnect_account( zoom_connection = subscriber.get_external_connection(ExternalConnectionType.zoom) if zoom_connection: - repo.delete_external_connections_by_type_id(db, subscriber.id, zoom_connection.type, zoom_connection.type_id) + repo.external_connection.delete_by_type(db, subscriber.id, zoom_connection.type, zoom_connection.type_id) else: return False diff --git a/backend/test/conftest.py b/backend/test/conftest.py index e83c1c74f..61405d2af 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -160,7 +160,7 @@ def override_get_subscriber(request : Request): if 'authorization' not in request.headers: raise InvalidTokenException - return repo.get_subscriber_by_email(with_db(), os.getenv('TEST_USER_EMAIL')) + return repo.subscriber.get_by_email(with_db(), os.getenv('TEST_USER_EMAIL')) def override_get_google_client(): return None diff --git a/backend/test/factory/appointment_factory.py b/backend/test/factory/appointment_factory.py index 6397f3339..57ae6cefc 100644 --- a/backend/test/factory/appointment_factory.py +++ b/backend/test/factory/appointment_factory.py @@ -24,7 +24,7 @@ def _make_appointment(calendar_id=FAKER_RANDOM_VALUE, slots=FAKER_RANDOM_VALUE ): with with_db() as db: - appointment = repo.create_calendar_appointment(db, schemas.AppointmentFull( + 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), @@ -44,7 +44,7 @@ def _make_appointment(calendar_id=FAKER_RANDOM_VALUE, if not factory_has_value(slots): make_appointment_slot(appointment_id=appointment.id) else: - repo.add_appointment_slots(db, slots, appointment.id) + repo.slot.add_for_appointment(db, slots, appointment.id) # Refresh our appointment now that is has slot data db.refresh(appointment) diff --git a/backend/test/factory/calendar_factory.py b/backend/test/factory/calendar_factory.py index e573d0451..c038382cc 100644 --- a/backend/test/factory/calendar_factory.py +++ b/backend/test/factory/calendar_factory.py @@ -11,7 +11,7 @@ def make_caldav_calendar(with_db): 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.create_subscriber_calendar(db, schemas.CalendarConnection( + return repo.calendar.create(db, schemas.CalendarConnection( title=title, color=color if factory_has_value(color) else fake.color(), connected=connected, @@ -32,7 +32,7 @@ def _make_google_calendar(subscriber_id=TEST_USER_ID, title=FAKER_RANDOM_VALUE, 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.create_subscriber_calendar(db, schemas.CalendarConnection( + return repo.calendar.create(db, schemas.CalendarConnection( title=title, color=color if factory_has_value(color) else fake.color(), connected=connected, diff --git a/backend/test/factory/external_connection_factory.py b/backend/test/factory/external_connection_factory.py index 55776ff71..dc2b4e3b3 100644 --- a/backend/test/factory/external_connection_factory.py +++ b/backend/test/factory/external_connection_factory.py @@ -14,7 +14,7 @@ def _make_external_connections(subscriber_id, type_id=FAKER_RANDOM_VALUE, token=FAKER_RANDOM_VALUE): with with_db() as db: - return repo.create_subscriber_external_connection(db, schemas.ExternalConnection( + 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( diff --git a/backend/test/factory/schedule_factory.py b/backend/test/factory/schedule_factory.py index 03d22b6c1..d6c7bf870 100644 --- a/backend/test/factory/schedule_factory.py +++ b/backend/test/factory/schedule_factory.py @@ -24,7 +24,7 @@ def _make_schedule(calendar_id=FAKER_RANDOM_VALUE, slot_duration=FAKER_RANDOM_VALUE, ): with with_db() as db: - return repo.create_calendar_schedule(db, schemas.ScheduleBase( + 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(), diff --git a/backend/test/factory/slot_factory.py b/backend/test/factory/slot_factory.py index b7c18a7c0..fda08151c 100644 --- a/backend/test/factory/slot_factory.py +++ b/backend/test/factory/slot_factory.py @@ -18,7 +18,7 @@ def _make_appointment_slot(appointment_id=None, meeting_link_id=None, meeting_link_url=None): with with_db() as db: - return repo.add_appointment_slots(db, [schemas.SlotBase( + 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, diff --git a/backend/test/factory/subscriber_factory.py b/backend/test/factory/subscriber_factory.py index e6351e7b1..461dca74e 100644 --- a/backend/test/factory/subscriber_factory.py +++ b/backend/test/factory/subscriber_factory.py @@ -11,7 +11,7 @@ def make_subscriber(with_db): 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.create_subscriber(db, schemas.SubscriberBase( + 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(), diff --git a/backend/test/integration/test_appointment.py b/backend/test/integration/test_appointment.py index a9f1c12e3..87d3c95a6 100644 --- a/backend/test/integration/test_appointment.py +++ b/backend/test/integration/test_appointment.py @@ -338,7 +338,7 @@ def test_attendee_selects_slot_of_unavailable_appointment(self, with_db, with_cl response = with_client.put( f"/apmt/public/{generated_appointment.slug}", - json={"slot_id": generated_appointment.slots[-1].id, "attendee": {"email": "a", "name": "b"}}, + json={"slot_id": generated_appointment.slots[-1].id, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, ) assert response.status_code == 403, response.text @@ -347,7 +347,7 @@ def test_attendee_selects_slot_of_missing_appointment(self, with_client, make_ap response = with_client.put( f"/apmt/public/{generated_appointment}", - json={"slot_id": generated_appointment.slots[0].id, "attendee": {"email": "a", "name": "b"}}, + json={"slot_id": generated_appointment.slots[0].id, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, ) assert response.status_code == 404, response.text @@ -356,7 +356,7 @@ def test_attendee_selects_missing_slot_of_existing_appointment(self, with_client response = with_client.put( f"/apmt/public/{generated_appointment.id}", - json={"slot_id": generated_appointment.slots[0].id + 1, "attendee": {"email": "a", "name": "b"}}, + json={"slot_id": generated_appointment.slots[0].id + 1, "attendee": {"email": "a", "name": "b", "timezone": "c"}}, ) assert response.status_code == 404, response.text diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index 3904be9c0..73492b1d8 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -1,6 +1,7 @@ import os -from defines import FXA_CLIENT_PATCH +from appointment.l10n import l10n +from defines import FXA_CLIENT_PATCH, TEST_USER_ID from appointment.database import repo, models @@ -82,10 +83,41 @@ def test_fxa_callback(self, with_db, with_client, monkeypatch): assert response.status_code == 307, response.text with with_db() as db: - subscriber = repo.get_subscriber_by_email(db, FXA_CLIENT_PATCH.get('subscriber_email')) + subscriber = repo.subscriber.get_by_email(db, FXA_CLIENT_PATCH.get('subscriber_email')) assert subscriber assert subscriber.avatar_url == FXA_CLIENT_PATCH.get('subscriber_avatar_url') assert subscriber.name == FXA_CLIENT_PATCH.get('subscriber_display_name') fxa = subscriber.get_external_connection(models.ExternalConnectionType.fxa) 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.""" + os.environ['AUTH_SCHEME'] = 'fxa' + + state = 'a1234' + + subscriber = make_basic_subscriber(email=FXA_CLIENT_PATCH.get('subscriber_email')) + + 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' + }) + + response = with_client.get( + "/fxa", + params={ + 'code': FXA_CLIENT_PATCH.get('credentials_code'), + 'state': state + }, + follow_redirects=False + ) + + # This should error out as a 403 + assert response.status_code == 403, response.text + # This will just key match due to the lack of context. + assert response.json().get('detail') == l10n('invalid-credentials') diff --git a/backend/test/integration/test_profile.py b/backend/test/integration/test_profile.py index 23464e8c2..cd4dbdd08 100644 --- a/backend/test/integration/test_profile.py +++ b/backend/test/integration/test_profile.py @@ -31,7 +31,7 @@ def test_update_me(self, with_db, with_client): # Confirm the data was saved with with_db() as db: - subscriber = repo.get_subscriber_by_email(db, os.getenv('TEST_USER_EMAIL')) + 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" diff --git a/backend/test/integration/test_schedule.py b/backend/test/integration/test_schedule.py index 469107eee..cc5b02321 100644 --- a/backend/test/integration/test_schedule.py +++ b/backend/test/integration/test_schedule.py @@ -462,7 +462,8 @@ def bust_cached_events(self, all_calendars = False): ), attendee=schemas.AttendeeBase( email='hello@example.org', - name='Greg' + name='Greg', + timezone='Europe/Berlin' ) ).model_dump(mode='json') @@ -507,5 +508,5 @@ def bust_cached_events(self, all_calendars = False): # Look up the slot with with_db() as db: - slot = repo.get_slot(db, slot_id) + slot = repo.slot.get(db, slot_id) assert slot.appointment_id diff --git a/backend/test/integration/test_webhooks.py b/backend/test/integration/test_webhooks.py index f5f328a20..3faa572b1 100644 --- a/backend/test/integration/test_webhooks.py +++ b/backend/test/integration/test_webhooks.py @@ -40,7 +40,7 @@ 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.get_external_connections_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() @@ -51,19 +51,19 @@ def override_get_webhook_auth(): assert response.status_code == 200, response.text with with_db() as db: - subscriber = repo.get_subscriber(db, subscriber_id) + subscriber = repo.subscriber.get(db, subscriber_id) assert subscriber.minimum_valid_iat_time is not None # 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.get_external_connections_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() # Reset our minimum_valid_iat_time, so we can ensure it stays None - subscriber = repo.get_subscriber(db, subscriber_id) + subscriber = repo.subscriber.get(db, subscriber_id) subscriber.minimum_valid_iat_time = None db.add(subscriber) db.commit() @@ -74,7 +74,7 @@ def override_get_webhook_auth(): ) assert response.status_code == 200, response.text with with_db() as db: - subscriber = repo.get_subscriber(db, subscriber_id) + 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): @@ -119,7 +119,7 @@ def override_get_webhook_auth(): # Refresh the subscriber and test minimum_valid_iat_time (they should be logged out), and email address with with_db() as db: - subscriber = repo.get_subscriber(db, subscriber_id) + subscriber = repo.subscriber.get(db, subscriber_id) assert subscriber.email == NEW_EMAIL assert subscriber.minimum_valid_iat_time is not None @@ -160,6 +160,6 @@ def override_get_webhook_auth(): with with_db() as db: # Make sure everything we created is gone. A more exhaustive check is done in the delete account test - assert repo.get_subscriber(db, subscriber.id) is None - assert repo.get_calendar(db, calendar.id) is None - assert repo.get_appointment(db, appointment.id) is None + assert repo.subscriber.get(db, subscriber.id) is None + assert repo.calendar.get(db, calendar.id) is None + assert repo.appointment.get(db, appointment.id) is None diff --git a/backend/test/unit/test_auth_dependency.py b/backend/test/unit/test_auth_dependency.py index 9d6824788..4bf26a22d 100644 --- a/backend/test/unit/test_auth_dependency.py +++ b/backend/test/unit/test_auth_dependency.py @@ -50,7 +50,7 @@ def test_get_user_from_token(self, with_db, with_l10n, make_pro_subscriber): 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.get_subscriber(db, subscriber.id) + subscriber = repo.subscriber.get(db, subscriber.id) subscriber.minimum_valid_iat_time = datetime.datetime.now(datetime.UTC) db.add(subscriber) db.commit() diff --git a/backend/test/unit/test_mailer.py b/backend/test/unit/test_mailer.py index 25b75c3b2..edfb3a81c 100644 --- a/backend/test/unit/test_mailer.py +++ b/backend/test/unit/test_mailer.py @@ -17,7 +17,7 @@ def test_confirm(self, faker, with_l10n): deny_url = 'https://example.org/no' fake_email = 'to@example.org' now = datetime.datetime.now() - attendee = schemas.AttendeeBase(email=faker.email(), name=faker.name()) + attendee = schemas.AttendeeBase(email=faker.email(), name=faker.name(), timezone='Europe/Berlin') mailer = ConfirmationMail(confirm_url, deny_url, attendee, now, to=fake_email) assert mailer.html() diff --git a/frontend/.env.prod.example b/frontend/.env.prod.example new file mode 100644 index 000000000..11b58c7f0 --- /dev/null +++ b/frontend/.env.prod.example @@ -0,0 +1,11 @@ +# Production env config, do not put secrets in here !! +VITE_API_URL=appointment.day/api/v1/ +VITE_BASE_URL=appointment.day +VITE_API_SECURE=true +VITE_SHORT_BASE_URL=https://apmt.day + +# -- Auth scheme -- +VITE_AUTH_SCHEME=fxa + +# For fxa +VITE_FXA_EDIT_PROFILE=https://accounts.firefox.com/settings diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 85902ef94..b1b59457c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -98,7 +98,7 @@ const call = createFetch({ data.detail?.message || 'Please re-connect with Google', url, ); - } else if (response.status === 401 && data?.detail?.id === 'INVALID_TOKEN') { + } else if (response && response.status === 401 && data?.detail?.id === 'INVALID_TOKEN') { // Clear current user data, and ship them to the login screen! await currentUser.$reset(); await router.push('/login'); @@ -134,7 +134,7 @@ const scheduleStore = useScheduleStore(); // true if route can be accessed without authentication const routeIsPublic = computed( - () => ['availability', 'home', 'login', 'post-login', 'confirmation'].includes(route.name), + () => ['availability', 'home', 'login', 'post-login', 'confirmation', 'terms', 'privacy'].includes(route.name), ); const routeIsHome = computed( () => ['home'].includes(route.name), diff --git a/frontend/src/assets/legal/en/privacy.html b/frontend/src/assets/legal/en/privacy.html index 7a6f3937d..ea64a4d51 100644 --- a/frontend/src/assets/legal/en/privacy.html +++ b/frontend/src/assets/legal/en/privacy.html @@ -21,6 +21,7 @@

Set Up and Schedule Calendar Appointments with Thunderbird Appointment

You can connect your Google, Microsoft, or Apple calendar to Thunderbird Appointment to assist with scheduling.

If you choose to connect your Apple Calendar, Microsoft 365, or Google Calendar to Thunderbird Appointment, we will receive basic information about your calendar invites such as the title, date, stated location, the name and emails of the attendees, and any text in the appointment to display them within Thunderbird Appointment and allow you to invite others to schedule time in your calendar. We will receive technical and interaction data about your interactions with this feature such as how many events you create, whether you have connected to a Google, Microsoft, or Apple account.

We will only use your data to provide and improve the Thunderbird Appointment service.

+

Thunderbird Appointment use and transfer of information received from Google APIs to any other app will adhere to Google API Services User Data Policy, including the Limited Use requirements.

Review Crash Reports

If Thunderbird crashes, we will ask you to share a report with more detailed information about the crash, but you always have the choice to decline. Thunderbird uses the information in the crash report to diagnose and correct the problem that caused the crash.

Sensitive data: Crash reports include a “dump file” of Thunderbird’s memory contents at the time of the crash, which may contain data that identifies you or is otherwise sensitive to you.

diff --git a/frontend/src/components/BookingModal.vue b/frontend/src/components/BookingModal.vue index 946ce240e..a3121ffca 100644 --- a/frontend/src/components/BookingModal.vue +++ b/frontend/src/components/BookingModal.vue @@ -79,9 +79,7 @@ diff --git a/frontend/src/components/CalendarManagement.vue b/frontend/src/components/CalendarManagement.vue index 9a761fede..35b2517ab 100644 --- a/frontend/src/components/CalendarManagement.vue +++ b/frontend/src/components/CalendarManagement.vue @@ -47,7 +47,7 @@ {{ t('label.editCalendar') }}