diff --git a/.bumpversion.toml b/.bumpversion.toml index 82691b4..ded0f19 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.15.3" +current_version = "0.15.4" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:-(?Prc)(?P0|[1-9]\\d*))?" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9563862..0138b7a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,19 +10,13 @@ repos: - id: check-yaml args: [--unsafe] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.2 + rev: v0.12.7 hooks: - - id: ruff - language_version: python3 + - id: ruff-check args: [--fix, --exit-non-zero-on-fix, --show-fixes] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.1 hooks: - id: codespell args: [--config, .codespellrc] - # - repo: https://github.com/pre-commit/mirrors-mypy - # rev: v1.10.0 - # hooks: - # - id: mypy - # exclude: ^(tests|examples)/ diff --git a/pyproject.toml b/pyproject.toml index 7ac37b3..2be5bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "otf-api" -version = "0.15.3" +version = "0.15.4" description = "Python OrangeTheory Fitness API Client" authors = [{ name = "Jessica Smith", email = "j.smith.git1@gmail.com" }] requires-python = ">=3.11" diff --git a/source/conf.py b/source/conf.py index 49aff39..b1086ae 100644 --- a/source/conf.py +++ b/source/conf.py @@ -14,7 +14,7 @@ project = "OrangeTheory API" copyright = "2025, Jessica Smith" author = "Jessica Smith" -release = "0.15.3" +release = "0.15.4" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/src/otf_api/__init__.py b/src/otf_api/__init__.py index cfa784f..a497bc1 100644 --- a/src/otf_api/__init__.py +++ b/src/otf_api/__init__.py @@ -47,7 +47,7 @@ def _setup_logging() -> None: _setup_logging() -__version__ = "0.15.3" +__version__ = "0.15.4" __all__ = ["Otf", "OtfUser", "models"] diff --git a/src/otf_api/api/bookings/booking_api.py b/src/otf_api/api/bookings/booking_api.py index 3983651..83c6324 100644 --- a/src/otf_api/api/bookings/booking_api.py +++ b/src/otf_api/api/bookings/booking_api.py @@ -1,4 +1,5 @@ import typing +from collections import defaultdict from datetime import date, datetime, time, timedelta from logging import getLogger from typing import Literal @@ -114,62 +115,45 @@ def get_bookings_new( ) LOGGER.debug("Found %d bookings between %s and %s", len(bookings_resp), start_date, end_date) - # filter out bookings with ids that start with "no-booking-id" - # no idea what these are, but I am praying for the poor sap stuck with maintaining OTF's data model results: list[models.BookingV2] = [] - for b in bookings_resp: - if not b.get("id", "").startswith("no-booking-id"): - try: - results.append(models.BookingV2.create(**b, api=self.otf)) - except ValueError as e: - LOGGER.error("Failed to create BookingV2 from response: %s. Booking data:\n%s", e, b) - continue + try: + results.append(models.BookingV2.create(**b, api=self.otf)) + except Exception as e: + LOGGER.error( + "Failed to create BookingV2 from response: %s - %s. Booking data:\n%s", type(e).__name__, e, b + ) + continue if not remove_duplicates: return results - results = self._deduplicate_bookings(results, exclude_cancelled=exclude_cancelled) + results = self._deduplicate_bookings(results) return results - def _deduplicate_bookings( - self, results: list[models.BookingV2], exclude_cancelled: bool = True - ) -> list[models.BookingV2]: + def _deduplicate_bookings(self, results: list[models.BookingV2]) -> list[models.BookingV2]: """Deduplicate bookings by class_id, keeping the most recent booking. Args: results (list[BookingV2]): The list of bookings to deduplicate. - exclude_cancelled (bool): If True, will not include cancelled bookings in the results. Returns: list[BookingV2]: The deduplicated list of bookings. """ - # remove duplicates by class_id, keeping the one with the most recent updated_at timestamp - orig_count = len(results) - seen_classes: dict[str, models.BookingV2] = {} + classes_by_id: defaultdict[str, list[models.BookingV2]] = defaultdict(list) + keep_classes: list[models.BookingV2] = [] for booking in results: - class_id = booking.otf_class.class_id - if class_id not in seen_classes: - seen_classes[class_id] = booking - continue + classes_by_id[booking.otf_class.class_id].append(booking) - existing_booking = seen_classes[class_id] - if exclude_cancelled: - LOGGER.warning( - f"Duplicate class_id {class_id} found when `exclude_cancelled` is True, " - "this is unexpected behavior." - ) - if booking.updated_at > existing_booking.updated_at: - LOGGER.debug( - "Replacing existing booking for class_id %s with more recent booking %s", class_id, booking - ) - seen_classes[class_id] = booking + for bookings in classes_by_id.values(): + top_booking = min(bookings, key=lambda b: b.get_sort_key()) + keep_classes.append(top_booking) - results = list(seen_classes.values()) + results = list(keep_classes) results = sorted(results, key=lambda x: x.starts_at) new_count = len(results) diff --git a/src/otf_api/models/bookings/bookings_v2.py b/src/otf_api/models/bookings/bookings_v2.py index faf6908..1dd4dd8 100644 --- a/src/otf_api/models/bookings/bookings_v2.py +++ b/src/otf_api/models/bookings/bookings_v2.py @@ -155,13 +155,16 @@ class BookingV2(ApiMixin, OtfItemBase): person_id: str created_at: datetime | None = Field( - None, - description="Date the booking was created in the system, not when the booking was made", + default=None, + description="Date the booking was created in the system", exclude=True, repr=False, ) - updated_at: datetime = Field( - description="Date the booking was updated, not when the booking was made", exclude=True, repr=False + updated_at: datetime | None = Field( + default=None, + description="Date the booking was updated in the system", + exclude=True, + repr=False, ) @property @@ -230,3 +233,17 @@ def cancel(self) -> None: self.raise_if_api_not_set() self._api.bookings.cancel_booking_new(self) + + def get_sort_key(self) -> tuple: + """Returns a tuple for sorting bookings, used when attempting to remove duplicates.""" + # Use negative timestamps to favor later ones (a more recent updated_at is better) + updated_at = self.updated_at or datetime.min # noqa DTZ901 + created_at = self.created_at or datetime.min # noqa DTZ901 + + status_priority = self.status.priority() + + return (self.starts_at, -_safe_ts(updated_at), -_safe_ts(created_at), status_priority) + + +def _safe_ts(dt: datetime | None) -> float: + return dt.timestamp() if (isinstance(dt, datetime) and dt != datetime.min) else float("inf") # noqa DTZ901 diff --git a/src/otf_api/models/bookings/enums.py b/src/otf_api/models/bookings/enums.py index de86274..5bc9924 100644 --- a/src/otf_api/models/bookings/enums.py +++ b/src/otf_api/models/bookings/enums.py @@ -17,6 +17,25 @@ class BookingStatus(StrEnum): CancelCheckinPending = "Cancel Checkin Pending" CancelCheckinRequested = "Cancel Checkin Requested" + def priority(self) -> int: + """Returns the priority of the booking status for sorting purposes.""" + priorities = { + BookingStatus.Booked: 0, + BookingStatus.Confirmed: 1, + BookingStatus.Waitlisted: 2, + BookingStatus.Pending: 3, + BookingStatus.Requested: 4, + BookingStatus.CheckedIn: 5, + BookingStatus.CheckinPending: 6, + BookingStatus.CheckinRequested: 7, + BookingStatus.CheckinCancelled: 8, + BookingStatus.Cancelled: 9, + BookingStatus.LateCancelled: 10, + BookingStatus.CancelCheckinPending: 11, + BookingStatus.CancelCheckinRequested: 12, + } + return priorities.get(self, 999) + HISTORICAL_BOOKING_STATUSES = [ BookingStatus.CheckedIn, diff --git a/uv.lock b/uv.lock index c2f69b6..4aa0f21 100644 --- a/uv.lock +++ b/uv.lock @@ -893,7 +893,7 @@ wheels = [ [[package]] name = "otf-api" -version = "0.15.3" +version = "0.15.4" source = { editable = "." } dependencies = [ { name = "attrs" },