diff --git a/backend/src/appointment/dependencies/auth.py b/backend/src/appointment/dependencies/auth.py index 29b2812ed..21b12e50f 100644 --- a/backend/src/appointment/dependencies/auth.py +++ b/backend/src/appointment/dependencies/auth.py @@ -9,7 +9,7 @@ from ..database import repo, schemas from ..dependencies.database import get_db -from ..exceptions.validation import APIInvalidToken +from ..exceptions.validation import InvalidTokenException oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -19,9 +19,9 @@ def get_user_from_token(db, token: str): payload = jwt.decode(token, os.getenv('JWT_SECRET'), algorithms=[os.getenv('JWT_ALGO')]) sub = payload.get("sub") if sub is None: - raise HTTPException(401, "Could not validate credentials") + raise InvalidTokenException() except JWTError: - raise HTTPException(401, "Could not validate credentials") + raise InvalidTokenException() id = sub.replace('uid-', '') return repo.get_subscriber(db, int(id)) @@ -35,6 +35,6 @@ def get_subscriber( user = get_user_from_token(db, token) if user is None: - raise APIInvalidToken() + raise InvalidTokenException() return user diff --git a/backend/src/appointment/exceptions/validation.py b/backend/src/appointment/exceptions/validation.py index 4c79f5c2a..e77b6732a 100644 --- a/backend/src/appointment/exceptions/validation.py +++ b/backend/src/appointment/exceptions/validation.py @@ -3,37 +3,93 @@ from ..l10n import l10n -class APIInvalidToken(HTTPException): - """Raise when the subscriber could not be parsed from the auth token""" +class APIException(HTTPException): + """Base exception for all custom API exceptions + Custom messages are defined in a function, because l10n needs context set before use.""" + status_code = 500 + def __init__(self, **kwargs): - super().__init__(status_code=401, detail=l10n('protected-route-fail'), **kwargs) + super().__init__(status_code=self.status_code, detail=self.get_msg(), **kwargs) + + def get_msg(self): + return l10n('unknown-error') + +class InvalidTokenException(APIException): + """Raise when the subscriber could not be parsed from the auth token""" + status_code = 401 + + def get_msg(self): + return l10n('protected-route-fail') -class APISubscriberNotFound(HTTPException): + +class SubscriberNotFoundException(APIException): """Raise when the calendar is not found during route validation""" - def __init__(self, **kwargs): - super().__init__(status_code=404, detail=l10n('calendar-not-found'), **kwargs) + status_code = 404 + def get_msg(self): + return l10n('subscriber-not-found') -class APICalendarNotFound(HTTPException): + +class CalendarNotFoundException(APIException): """Raise when the calendar is not found during route validation""" - def __init__(self, **kwargs): - super().__init__(status_code=404, detail=l10n('calendar-not-found'), **kwargs) + status_code = 404 + def get_msg(self): + return l10n('calendar-not-found') -class APICalendarNotAuthorized(HTTPException): + +class CalendarNotAuthorizedException(APIException): """Raise when the calendar is owned by someone else during route validation""" - def __init__(self, **kwargs): - super().__init__(status_code=403, detail=l10n('calendar-not-auth'), **kwargs) + status_code = 403 + def get_msg(self): + return l10n('calendar-not-auth') + + +class CalendarNotConnectedException(APIException): + """Raise when the calendar is owned by someone else during route validation""" + status_code = 403 -class APIAppointmentNotFound(HTTPException): + def get_msg(self): + return l10n('calendar-not-active') + + +class AppointmentNotFoundException(APIException): """Raise when the appointment is not found during route validation""" - def __init__(self, **kwargs): - super().__init__(status_code=404, detail=l10n('appointment-not-found'), **kwargs) + status_code = 404 + + def get_msg(self): + return l10n('appointment-not-found') -class APIAppointmentNotAuthorized(HTTPException): +class AppointmentNotAuthorizedException(APIException): """Raise when the appointment is owned by someone else during route validation""" - def __init__(self, **kwargs): - super().__init__(status_code=403, detail=l10n('appointment-not-auth'), **kwargs) + status_code = 403 + + def get_msg(self): + return l10n('appointment-not-auth') + + +class ScheduleNotFoundException(APIException): + """Raise when the schedule is not found during route validation""" + status_code = 404 + + def get_msg(self): + return l10n('schedule-not-found') + + +class ScheduleNotAuthorizedException(APIException): + """Raise when the schedule is owned by someone else during route validation""" + status_code = 403 + + def get_msg(self): + return l10n('schedule-not-auth') + + +class ZoomNotConnectedException(APIException): + """Raise if the user requires a zoom connection during route validation""" + status_code = 400 + + def get_msg(self): + return l10n('zoom-not-connected') diff --git a/backend/src/appointment/l10n/en/main.ftl b/backend/src/appointment/l10n/en/main.ftl index 30cadd1f4..2a0cac33a 100644 --- a/backend/src/appointment/l10n/en/main.ftl +++ b/backend/src/appointment/l10n/en/main.ftl @@ -10,21 +10,27 @@ health-ok = System is operational ## General Exceptions +unknown-error = An unknown error occurred. Please try again later. + +appointment-not-found = The appointment could not be found. +calendar-not-found = The calendar could not be found. +schedule-not-found = The schedule could not be found. +slot-not-found = The time slot you have selected could not be found. Please try again. +subscriber-not-found = The subscriber could not be found. + +appointment-not-auth = You are not authorized to view or modify this appointment. +calendar-not-auth = You are not authorized to view or modify this calendar. +schedule-not-auth = You are not authorized to view or modify this schedule. +slot-not-auth = You are not authorized to view or modify this time slot. + account-delete-fail = There was a problem deleting your data. This incident has been logged and your data will manually be removed. protected-route-fail = No valid authentication credentials provided. username-not-available = This username has already been taken. invalid-link = This link is no longer valid. -subscriber-not-found = The subscriber could not be found. -calendar-not-found = The calendar could not be found. -calendar-not-auth = You are not authorized to view or modify this calendar. calendar-sync-fail = An error occurred while syncing calendars. Please try again later. calendar-not-active = The calendar connection is not active. -appointment-not-found = The appointment could not be found. -appointment-not-auth = You are not authorized to view or modify this appointment. -slot-not-found = The time slot you have selected could not be found. Please try again. slot-already-taken = The time slot you have selected is no longer available. Please try again. slot-invalid-email = The email you have provided was not valid. Please try again. -slot-not-auth = You are not authorized to view or modify this time slot. ## Authentication Exceptions diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index c856b68ed..50c649833 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -24,8 +24,8 @@ from ..dependencies.auth import get_subscriber from ..dependencies.database import get_db from ..dependencies.zoom import get_zoom_client -from ..exceptions.validation import APICalendarNotFound, APICalendarNotAuthorized, APIAppointmentNotFound, \ - APIAppointmentNotAuthorized, APISubscriberNotFound +from ..exceptions.validation import CalendarNotFoundException, CalendarNotAuthorizedException, AppointmentNotFoundException, \ + AppointmentNotAuthorizedException, SubscriberNotFoundException, ZoomNotConnectedException, CalendarNotConnectedException from ..l10n import l10n router = APIRouter() @@ -124,9 +124,9 @@ def read_my_calendar(id: int, db: Session = Depends(get_db), subscriber: Subscri cal = repo.get_calendar(db, calendar_id=id) if cal is None: - raise APICalendarNotFound() + raise CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): - raise APICalendarNotAuthorized() + raise CalendarNotAuthorizedException() return schemas.CalendarConnectionOut( id=cal.id, @@ -148,9 +148,9 @@ def update_my_calendar( ): """endpoint to update an existing calendar connection for authenticated subscriber""" if not repo.calendar_exists(db, calendar_id=id): - raise APICalendarNotFound() + raise CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): - raise APICalendarNotAuthorized() + raise CalendarNotAuthorizedException() cal = repo.update_subscriber_calendar(db=db, calendar=calendar, calendar_id=id) return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) @@ -164,9 +164,9 @@ def connect_my_calendar( ): """endpoint to update an existing calendar connection for authenticated subscriber""" if not repo.calendar_exists(db, calendar_id=id): - raise APICalendarNotFound() + raise CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): - raise APICalendarNotAuthorized() + raise CalendarNotAuthorizedException() try: cal = repo.update_subscriber_calendar_connection(db=db, calendar_id=id, is_connected=True) @@ -179,9 +179,9 @@ def connect_my_calendar( 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): - raise APICalendarNotFound() + raise CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=id, subscriber_id=subscriber.id): - raise APICalendarNotAuthorized() + raise CalendarNotAuthorizedException() cal = repo.delete_subscriber_calendar(db=db, calendar_id=id) return schemas.CalendarOut(id=cal.id, title=cal.title, color=cal.color, connected=cal.connected) @@ -246,7 +246,7 @@ def read_remote_events( db_calendar = repo.get_calendar(db, calendar_id=id) if db_calendar is None: - raise APICalendarNotFound() + raise CalendarNotFoundException() if db_calendar.provider == CalendarProvider.google: con = GoogleConnector( @@ -271,13 +271,13 @@ def create_my_calendar_appointment( ): """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): - raise APICalendarNotFound() + raise CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=a_s.appointment.calendar_id, subscriber_id=subscriber.id): - raise APICalendarNotAuthorized() + raise CalendarNotAuthorizedException() if not repo.calendar_is_connected(db, calendar_id=a_s.appointment.calendar_id): - raise HTTPException(status_code=403, detail=l10n('calendar-not-active')) + raise CalendarNotConnectedException() if a_s.appointment.meeting_link_provider == MeetingLinkProviderType.zoom and subscriber.get_external_connection(ExternalConnectionType.zoom) is None: - raise HTTPException(status_code=400, detail=l10n('zoom-not-connected')) + raise ZoomNotConnectedException() return repo.create_calendar_appointment(db=db, appointment=a_s.appointment, slots=a_s.slots) @@ -287,9 +287,9 @@ def read_my_appointment(id: str, db: Session = Depends(get_db), subscriber: Subs db_appointment = repo.get_appointment(db, appointment_id=id) if db_appointment is None: - raise APIAppointmentNotFound() + raise AppointmentNotFoundException() if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): - raise APIAppointmentNotAuthorized() + raise AppointmentNotAuthorizedException() return db_appointment @@ -305,9 +305,9 @@ def update_my_appointment( db_appointment = repo.get_appointment(db, appointment_id=id) if db_appointment is None: - raise APIAppointmentNotFound() + raise AppointmentNotFoundException() if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): - raise APIAppointmentNotAuthorized() + raise AppointmentNotAuthorizedException() return repo.update_calendar_appointment(db=db, appointment=a_s.appointment, slots=a_s.slots, appointment_id=id) @@ -318,9 +318,9 @@ def delete_my_appointment(id: int, db: Session = Depends(get_db), subscriber: Su db_appointment = repo.get_appointment(db, appointment_id=id) if db_appointment is None: - raise APIAppointmentNotFound() + raise AppointmentNotFoundException() if not repo.appointment_is_owned(db, appointment_id=id, subscriber_id=subscriber.id): - raise APIAppointmentNotAuthorized() + raise AppointmentNotAuthorizedException() return repo.delete_calendar_appointment(db=db, appointment_id=id) @@ -330,10 +330,10 @@ def read_public_appointment(slug: str, db: Session = Depends(get_db)): """endpoint to retrieve an appointment from db via public link and only expose necessary data""" a = repo.get_public_appointment(db, slug=slug) if a is None: - raise APIAppointmentNotFound() + raise AppointmentNotFoundException() s = repo.get_subscriber_by_appointment(db=db, appointment_id=a.id) if s is None: - raise APISubscriberNotFound() + raise SubscriberNotFoundException() slots = [ schemas.SlotOut(id=sl.id, start=sl.start, duration=sl.duration, attendee_id=sl.attendee_id) for sl in a.slots ] @@ -352,10 +352,10 @@ def update_public_appointment_slot( """endpoint to update a time slot for an appointment via public link and create an event in remote calendar""" db_appointment = repo.get_public_appointment(db, slug=slug) if db_appointment is None: - raise APIAppointmentNotFound() + raise AppointmentNotFoundException() db_calendar = repo.get_calendar(db, calendar_id=db_appointment.calendar_id) if db_calendar is None: - raise APICalendarNotFound() + raise CalendarNotFoundException() if not repo.appointment_has_slot(db, appointment_id=db_appointment.id, slot_id=s_a.slot_id): raise HTTPException(status_code=404, detail=l10n('slot-not-found')) if not repo.slot_is_available(db, slot_id=s_a.slot_id): @@ -441,7 +441,7 @@ def public_appointment_serve_ics(slug: str, slot_id: int, db: Session = Depends( """endpoint to serve ICS file for time slot to download""" db_appointment = repo.get_public_appointment(db, slug=slug) if db_appointment is None: - raise APIAppointmentNotFound() + raise AppointmentNotFoundException() if not repo.appointment_has_slot(db, appointment_id=db_appointment.id, slot_id=slot_id): raise HTTPException(status_code=404, detail=l10n('slot-not-auth')) slot = repo.get_slot(db=db, slot_id=slot_id) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index afb267871..59f26a6f0 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -20,6 +20,9 @@ from zoneinfo import ZoneInfo from ..dependencies.zoom import get_zoom_client +from ..exceptions.validation import CalendarNotFoundException, CalendarNotAuthorizedException, ScheduleNotFoundException, \ + ScheduleNotAuthorizedException, ZoomNotConnectedException, CalendarNotConnectedException +from ..l10n import l10n router = APIRouter() @@ -31,22 +34,18 @@ def create_calendar_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to add a new schedule for a given calendar""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if not repo.calendar_exists(db, calendar_id=schedule.calendar_id): - raise HTTPException(status_code=404, detail="Calendar not found") + raise CalendarNotFoundException() if not repo.calendar_is_owned(db, calendar_id=schedule.calendar_id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") + raise CalendarNotAuthorizedException() if not repo.calendar_is_connected(db, calendar_id=schedule.calendar_id): - raise HTTPException(status_code=403, detail="Calendar connection is not active") + raise CalendarNotConnectedException() return repo.create_calendar_schedule(db=db, schedule=schedule) @router.get("/", response_model=list[schemas.Schedule]) def read_schedules(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Gets all of the available schedules for the logged in subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") return repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) @@ -57,13 +56,11 @@ def read_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """Gets information regarding a specific schedule""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") schedule = repo.get_schedule(db, schedule_id=id) if schedule is None: - raise HTTPException(status_code=404, detail="Schedule not found") + raise ScheduleNotFoundException() if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Schedule not owned by subscriber") + raise ScheduleNotAuthorizedException() return schedule @@ -75,14 +72,12 @@ def update_schedule( subscriber: Subscriber = Depends(get_subscriber), ): """endpoint to update an existing calendar connection for authenticated subscriber""" - if not subscriber: - raise HTTPException(status_code=401, detail="No valid authentication credentials provided") if not repo.schedule_exists(db, schedule_id=id): - raise HTTPException(status_code=404, detail="Schedule not found") + raise ScheduleNotFoundException() if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): - raise HTTPException(status_code=403, detail="Schedule not owned by subscriber") + raise ScheduleNotAuthorizedException() if schedule.meeting_link_provider == MeetingLinkProviderType.zoom and subscriber.get_external_connection(ExternalConnectionType.zoom) is None: - raise HTTPException(status_code=400, detail="You need a connected Zoom account in order to create a meeting link") + raise ZoomNotConnectedException() return repo.update_calendar_schedule(db=db, schedule=schedule, schedule_id=id) @@ -95,17 +90,17 @@ def read_schedule_availabilities( """Returns the calculated availability for the first schedule from a subscribers public profile link""" subscriber = repo.verify_subscriber_link(db, url) if not subscriber: - raise HTTPException(status_code=401, detail="Invalid profile link") + raise HTTPException(status_code=401, detail=l10n('invalid-link')) schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) try: schedule = schedules[0] # for now we only process the first existing schedule except IndexError: - raise HTTPException(status_code=404, detail="Schedule not found") + raise ScheduleNotFoundException() # check if schedule is enabled if not schedule.active: - raise HTTPException(status_code=404, detail="Schedule not found") + raise ScheduleNotFoundException() # calculate theoretically possible slots from schedule config availableSlots = Tools.available_slots_from_schedule(schedule)