From 6f46a9481cea19de7005a930c4183a21c489fe72 Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Tue, 13 Oct 2026 23:57:41 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20feat(work-hours):=20add=20Graph?= =?UTF-8?q?=20occurrencesView=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Account.work_hours_and_locations() and a typed WorkHoursAndLocations API with WorkPlanOccurrence models for /me and /users occurrencesView retrieval, including timezone-aware parsing and endpoint-context HTTP errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- O365/account.py | 11 ++ O365/work_hours_and_locations.py | 229 +++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 O365/work_hours_and_locations.py diff --git a/O365/account.py b/O365/account.py index 16a8d50c..6bbb1c9c 100644 --- a/O365/account.py +++ b/O365/account.py @@ -372,3 +372,14 @@ def subscriptions(self, *, resource: str = ''): from .subscriptions import Subscriptions return Subscriptions(parent=self, main_resource=resource) + + def work_hours_and_locations(self): + """Get an instance to work with work-hours and location occurrences.""" + if not isinstance(self.protocol, MSGraphProtocol): + raise RuntimeError( + "work_hours_and_locations api only works on Microsoft Graph API" + ) + + from .work_hours_and_locations import WorkHoursAndLocations + + return WorkHoursAndLocations(parent=self) diff --git a/O365/work_hours_and_locations.py b/O365/work_hours_and_locations.py new file mode 100644 index 00000000..008a85ce --- /dev/null +++ b/O365/work_hours_and_locations.py @@ -0,0 +1,229 @@ +import datetime as dt +import logging +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from dateutil.parser import parse +from requests.exceptions import HTTPError + +from .utils import ApiComponent, get_iana_tz + +log = logging.getLogger(__name__) + + +class WorkPlanOccurrence(ApiComponent): + """A work-hours and location occurrence returned by Microsoft Graph.""" + + def __init__(self, *, parent=None, con=None, **kwargs): + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + cloud_data = kwargs.get(self._cloud_data_key, {}) + cc = self._cc + + self.object_id = cloud_data.get(cc("id")) + if not self.object_id: + raise ValueError("Missing occurrence id") + self.id = self.object_id + + self.recurrence_id = cloud_data.get(cc("recurrenceId")) + self.work_location_type = cloud_data.get(cc("workLocationType"), "") + + start_payload = cloud_data.get(cc("startDateTime")) or cloud_data.get( + cc("start") + ) + end_payload = cloud_data.get(cc("endDateTime")) or cloud_data.get(cc("end")) + + self.time_zone = ( + cloud_data.get(cc("timeZone")) + or self._extract_time_zone(start_payload) + or self._extract_time_zone(end_payload) + ) + + self.start = self._parse_occurrence_datetime(start_payload, self.time_zone) + self.end = self._parse_occurrence_datetime(end_payload, self.time_zone) + + self.location = cloud_data.get(cc("location")) or cloud_data.get( + cc("workLocation") + ) + + if self.start is None or self.end is None: + raise ValueError("Occurrence is missing a valid start or end datetime") + + def _extract_time_zone(self, payload): + if not isinstance(payload, dict): + return None + return payload.get(self._cc("timeZone")) + + def _resolve_time_zone(self, time_zone_name): + if time_zone_name: + try: + return ZoneInfo(time_zone_name) + except ZoneInfoNotFoundError: + try: + return get_iana_tz(time_zone_name) + except ZoneInfoNotFoundError: + log.debug( + "TimeZone not found in IANA or Windows maps: %s. Using protocol timezone.", + time_zone_name, + ) + + return self.protocol.timezone + + def _parse_occurrence_datetime(self, payload, fallback_time_zone): + if payload is None: + return None + + cc = self._cc + date_time_raw = payload + time_zone_name = fallback_time_zone + + if isinstance(payload, dict): + date_time_raw = payload.get(cc("dateTime")) + time_zone_name = payload.get(cc("timeZone")) or fallback_time_zone + + if not date_time_raw: + return None + + parsed = parse(date_time_raw) + + if parsed.tzinfo is not None: + date_time = parsed + else: + date_time = parsed.replace(tzinfo=self._resolve_time_zone(time_zone_name)) + + return date_time.astimezone(self.protocol.timezone) + + +class WorkHoursAndLocations(ApiComponent): + """Read work-hours and location occurrences from Microsoft Graph.""" + + occurrence_constructor = WorkPlanOccurrence #: :meta private: + + def __init__(self, *, parent=None, con=None, **kwargs): + if parent and con: + raise ValueError("Need a parent or a connection but not both") + self.con = parent.con if parent else con + + main_resource = kwargs.pop("main_resource", None) or ( + getattr(parent, "main_resource", None) if parent else None + ) + + super().__init__( + protocol=parent.protocol if parent else kwargs.get("protocol"), + main_resource=main_resource, + ) + + @staticmethod + def _validate_datetime(value, name): + if not isinstance(value, dt.datetime): + raise ValueError(f"'{name}' must be a datetime instance") + return value + + def _serialize_graph_datetime(self, value): + if value.tzinfo is None: + value = value.replace(tzinfo=self.protocol.timezone) + return value.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + def _build_occurrences_view_url(self, start_datetime, end_datetime, user=None): + resource = "/me" if user is None else f"/users/{user}" + start_value = self._serialize_graph_datetime(start_datetime) + end_value = self._serialize_graph_datetime(end_datetime) + endpoint = ( + "/settings/workHoursAndLocations/occurrencesView" + f"(startDateTime='{start_value}',endDateTime='{end_value}')" + ) + return f"{self.protocol.service_url.rstrip('/')}{resource}{endpoint}" + + def occurrences_view( + self, start_datetime, end_datetime, user=None, top=None, select=None + ): + """Return work plan occurrences in a datetime range. + + :param datetime start_datetime: range start datetime + :param datetime end_datetime: range end datetime + :param str user: optional user id/upn, defaults to /me when omitted + :param int top: optional OData $top + :param list[str] select: optional OData $select fields + :rtype: list[WorkPlanOccurrence] + """ + start_datetime = self._validate_datetime(start_datetime, "start_datetime") + end_datetime = self._validate_datetime(end_datetime, "end_datetime") + + if end_datetime <= start_datetime: + raise ValueError("'end_datetime' must be greater than 'start_datetime'") + + if user is not None and not isinstance(user, str): + raise ValueError("'user' must be a string when provided") + + url = self._build_occurrences_view_url(start_datetime, end_datetime, user=user) + + params = {} + if top is not None: + if not isinstance(top, int) or top <= 0: + raise ValueError("'top' must be a positive integer") + params["$top"] = top + if select is not None: + if not isinstance(select, (list, tuple)) or not all( + isinstance(field, str) for field in select + ): + raise ValueError("'select' must be a list of strings") + params["$select"] = ",".join(select) + + try: + response = self.con.get(url, params=params or None) + except HTTPError as e: + status_code = e.response.status_code if e.response else None + if status_code in (403, 404): + message = ( + "Work-hours endpoint is unavailable for this tenant/user " + f"(status: {status_code})" + ) + else: + message = ( + "Failed to fetch work-hours occurrences; this may be transient" + ) + if status_code: + message = f"{message} (status: {status_code})" + + raise HTTPError(f"{message} | Endpoint: {url}", response=e.response) from e + + if not response: + return [] + + payload = response.json() + values = payload.get(self._cc("value"), payload.get("value")) + + if values is None: + raise ValueError( + "Malformed response payload: missing 'value' collection for occurrences view" + ) + if not isinstance(values, list): + raise ValueError("Malformed response payload: 'value' must be a list") + + occurrences = [] + for index, item in enumerate(values): + if not isinstance(item, dict): + raise ValueError( + f"Malformed response payload: occurrence at index {index} is not an object" + ) + + try: + occurrences.append( + self.occurrence_constructor(parent=self, **{self._cloud_data_key: item}) + ) + except Exception as e: + raise ValueError( + f"Malformed occurrence payload at index {index}: {e}" + ) from e + + return occurrences From e049ac293ecb53d829425b70d980557c5f4e1449 Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Tue, 13 Oct 2026 23:57:41 +0200 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=85=20test(work-hours):=20cover=20occ?= =?UTF-8?q?urrencesView=20URL=20and=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests for /me and /users URL construction, query options, aware and naive datetime parsing (IANA + Windows timezone mapping), endpoint errors, and malformed payload handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_work_hours_and_locations.py | 217 +++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/test_work_hours_and_locations.py diff --git a/tests/test_work_hours_and_locations.py b/tests/test_work_hours_and_locations.py new file mode 100644 index 00000000..86842337 --- /dev/null +++ b/tests/test_work_hours_and_locations.py @@ -0,0 +1,217 @@ +import datetime as dt + +import pytest +from requests import Response +from requests.exceptions import HTTPError + +from O365 import Account +from O365.connection import MSGraphProtocol + + +class MockResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + return self._payload + + def __bool__(self): + return True + + +class MockConnection: + def __init__(self): + self.url = None + self.params = None + self.payload = {"value": []} + self.error = None + + def get(self, url, params=None, **kwargs): + self.url = url + self.params = params + if self.error: + raise self.error + return MockResponse(self.payload) + + +def _build_http_error(status_code): + response = Response() + response.status_code = status_code + return HTTPError(f"{status_code} error", response=response) + + +@pytest.fixture +def work_hours(): + account = Account( + ("client id", "client secret"), + protocol=MSGraphProtocol(timezone="UTC"), + ) + work_hours = account.work_hours_and_locations() + work_hours.con = MockConnection() + return work_hours + + +def test_occurrences_view_builds_me_url(work_hours): + start = dt.datetime(2026, 1, 1, 9, 0, tzinfo=dt.timezone.utc) + end = dt.datetime(2026, 1, 1, 17, 0, tzinfo=dt.timezone.utc) + + work_hours.occurrences_view(start, end) + + assert ( + work_hours.con.url + == "https://graph.microsoft.com/v1.0/me/settings/workHoursAndLocations/" + "occurrencesView(startDateTime='2026-01-01T09:00:00Z',endDateTime='2026-01-01T17:00:00Z')" + ) + + +def test_occurrences_view_builds_users_url_and_query(work_hours): + start = dt.datetime(2026, 1, 1, 9, 0, tzinfo=dt.timezone.utc) + end = dt.datetime(2026, 1, 1, 17, 0, tzinfo=dt.timezone.utc) + + work_hours.occurrences_view( + start, end, user="alice@example.com", top=5, select=["id", "startDateTime"] + ) + + assert ( + work_hours.con.url + == "https://graph.microsoft.com/v1.0/users/alice@example.com/settings/workHoursAndLocations/" + "occurrencesView(startDateTime='2026-01-01T09:00:00Z',endDateTime='2026-01-01T17:00:00Z')" + ) + assert work_hours.con.params == {"$top": 5, "$select": "id,startDateTime"} + + +def test_occurrence_parses_aware_datetime(work_hours): + work_hours.con.payload = { + "value": [ + { + "id": "occ-1", + "recurrenceId": "rec-1", + "workLocationType": "office", + "startDateTime": {"dateTime": "2026-04-03T10:00:00+02:00"}, + "endDateTime": {"dateTime": "2026-04-03T11:00:00+02:00"}, + } + ] + } + + occurrences = work_hours.occurrences_view( + dt.datetime(2026, 4, 3, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2026, 4, 4, 0, 0, tzinfo=dt.timezone.utc), + ) + + assert len(occurrences) == 1 + assert occurrences[0].start == dt.datetime( + 2026, 4, 3, 8, 0, tzinfo=dt.timezone.utc + ) + assert occurrences[0].end == dt.datetime(2026, 4, 3, 9, 0, tzinfo=dt.timezone.utc) + + +def test_occurrence_parses_naive_datetime_with_iana_timezone(work_hours): + work_hours.con.payload = { + "value": [ + { + "id": "occ-2", + "workLocationType": "remote", + "startDateTime": { + "dateTime": "2026-01-03T09:00:00", + "timeZone": "America/Los_Angeles", + }, + "endDateTime": { + "dateTime": "2026-01-03T10:00:00", + "timeZone": "America/Los_Angeles", + }, + } + ] + } + + occurrences = work_hours.occurrences_view( + dt.datetime(2026, 1, 3, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2026, 1, 4, 0, 0, tzinfo=dt.timezone.utc), + ) + + assert occurrences[0].start == dt.datetime( + 2026, 1, 3, 17, 0, tzinfo=dt.timezone.utc + ) + assert occurrences[0].end == dt.datetime(2026, 1, 3, 18, 0, tzinfo=dt.timezone.utc) + + +def test_occurrence_parses_naive_datetime_with_windows_timezone(work_hours): + work_hours.con.payload = { + "value": [ + { + "id": "occ-3", + "workLocationType": "office", + "startDateTime": { + "dateTime": "2026-01-03T09:00:00", + "timeZone": "Pacific Standard Time", + }, + "endDateTime": { + "dateTime": "2026-01-03T10:00:00", + "timeZone": "Pacific Standard Time", + }, + } + ] + } + + occurrences = work_hours.occurrences_view( + dt.datetime(2026, 1, 3, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2026, 1, 4, 0, 0, tzinfo=dt.timezone.utc), + ) + + assert occurrences[0].start == dt.datetime( + 2026, 1, 3, 17, 0, tzinfo=dt.timezone.utc + ) + assert occurrences[0].end == dt.datetime(2026, 1, 3, 18, 0, tzinfo=dt.timezone.utc) + assert occurrences[0].time_zone == "Pacific Standard Time" + + +@pytest.mark.parametrize("status_code", [403, 404]) +def test_occurrences_view_unavailable_endpoint_error_has_context( + work_hours, status_code +): + work_hours.con.error = _build_http_error(status_code) + + with pytest.raises(HTTPError) as exc: + work_hours.occurrences_view( + dt.datetime(2026, 1, 1, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2026, 1, 2, 0, 0, tzinfo=dt.timezone.utc), + ) + + message = str(exc.value) + assert "unavailable" in message + assert "Endpoint:" in message + assert "workHoursAndLocations/occurrencesView" in message + + +def test_occurrences_view_transient_error_has_context(work_hours): + work_hours.con.error = _build_http_error(500) + + with pytest.raises(HTTPError) as exc: + work_hours.occurrences_view( + dt.datetime(2026, 1, 1, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2026, 1, 2, 0, 0, tzinfo=dt.timezone.utc), + ) + + message = str(exc.value) + assert "transient" in message + assert "Endpoint:" in message + + +def test_occurrences_view_malformed_payload_raises(work_hours): + work_hours.con.payload = {"unexpected": []} + + with pytest.raises(ValueError): + work_hours.occurrences_view( + dt.datetime(2026, 1, 1, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2026, 1, 2, 0, 0, tzinfo=dt.timezone.utc), + ) + + +def test_occurrences_view_malformed_occurrence_raises(work_hours): + work_hours.con.payload = {"value": [{"workLocationType": "office"}]} + + with pytest.raises(ValueError): + work_hours.occurrences_view( + dt.datetime(2026, 1, 1, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2026, 1, 2, 0, 0, tzinfo=dt.timezone.utc), + ) From c0a08aa98757c0eca179221f2d5716fcc7d2d105 Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Tue, 13 Oct 2026 23:57:41 +0200 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=93=9D=20docs(work-hours):=20add=20us?= =?UTF-8?q?age=20and=20API=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document work-hours and locations occurrences support, required permissions, tenant availability notes, and wire the new pages into usage/API toctrees. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/source/api.rst | 1 + docs/source/api/work_hours_and_locations.rst | 10 +++++ docs/source/usage.rst | 1 + .../source/usage/work_hours_and_locations.rst | 37 +++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 docs/source/api/work_hours_and_locations.rst create mode 100644 docs/source/usage/work_hours_and_locations.rst diff --git a/docs/source/api.rst b/docs/source/api.rst index d558897d..51011c16 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -22,4 +22,5 @@ O365 API api/subscriptions api/tasks api/teams + api/work_hours_and_locations api/utils diff --git a/docs/source/api/work_hours_and_locations.rst b/docs/source/api/work_hours_and_locations.rst new file mode 100644 index 00000000..bbadb1ad --- /dev/null +++ b/docs/source/api/work_hours_and_locations.rst @@ -0,0 +1,10 @@ +Work Hours and Locations +------------------------ + +.. include:: global.rst + +.. automodule:: O365.work_hours_and_locations + :members: + :undoc-members: + :show-inheritance: + :member-order: groupwise diff --git a/docs/source/usage.rst b/docs/source/usage.rst index aaba6b9d..16c08f0b 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -20,4 +20,5 @@ Detailed Usage usage/subscriptions usage/tasks usage/teams + usage/work_hours_and_locations usage/utils diff --git a/docs/source/usage/work_hours_and_locations.rst b/docs/source/usage/work_hours_and_locations.rst new file mode 100644 index 00000000..580be864 --- /dev/null +++ b/docs/source/usage/work_hours_and_locations.rst @@ -0,0 +1,37 @@ +Work Hours and Locations +======================== + +Read-only support for Microsoft Graph work-hours and location occurrences. + +These are the scopes commonly used for this feature. + +========================== ======================== ========================================================== +Raw Scope Included in Scope Helper Description +========================== ======================== ========================================================== +MailboxSettings.Read — Read work-hours/location settings for the signed-in user +MailboxSettings.ReadWrite mailbox_settings Read/write mailbox settings (also permits reading occurrences) +User.Read.All — Read another user via ``/users/{id|upn}`` in delegated flows +========================== ======================== ========================================================== + +.. note:: + Endpoint availability depends on cloud/tenant support. ``/me`` is recommended for delegated usage when available. + A ``403`` or ``404`` from this endpoint usually indicates the feature is unavailable for the current tenant/user. + +Example: + +.. code-block:: python + + from datetime import datetime, timedelta, timezone + + whl = account.work_hours_and_locations() + start = datetime.now(timezone.utc) + end = start + timedelta(days=7) + + # /me endpoint + occurrences = whl.occurrences_view(start, end) + + # /users/{id|upn} endpoint + other_user_occurrences = whl.occurrences_view(start, end, user="user@contoso.com") + + for item in occurrences: + print(item.object_id, item.work_location_type, item.start, item.end, item.time_zone) From 31dd6e9b4f5210c29a32071ac0dc0519b719244d Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Wed, 14 Oct 2026 00:06:39 +0200 Subject: [PATCH 4/8] =?UTF-8?q?=E2=9C=A8=20feat(work-hours):=20add=20metad?= =?UTF-8?q?ata=20lookup=20for=20workLocationType?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add get_valid_work_location_types() to resolve enum members from Graph / with cached fallback defaults, and support include_valid_values on occurrences_view() to attach valid values to returned occurrences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- O365/work_hours_and_locations.py | 79 ++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/O365/work_hours_and_locations.py b/O365/work_hours_and_locations.py index 008a85ce..753219e7 100644 --- a/O365/work_hours_and_locations.py +++ b/O365/work_hours_and_locations.py @@ -1,5 +1,6 @@ import datetime as dt import logging +import xml.etree.ElementTree as ET from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from dateutil.parser import parse @@ -107,6 +108,13 @@ def _parse_occurrence_datetime(self, payload, fallback_time_zone): class WorkHoursAndLocations(ApiComponent): """Read work-hours and location occurrences from Microsoft Graph.""" + default_work_location_types = ( + "unspecified", + "office", + "remote", + "timeOff", + "unknownFutureValue", + ) occurrence_constructor = WorkPlanOccurrence #: :meta private: def __init__(self, *, parent=None, con=None, **kwargs): @@ -122,6 +130,7 @@ def __init__(self, *, parent=None, con=None, **kwargs): protocol=parent.protocol if parent else kwargs.get("protocol"), main_resource=main_resource, ) + self._work_location_types_cache = None @staticmethod def _validate_datetime(value, name): @@ -144,8 +153,63 @@ def _build_occurrences_view_url(self, start_datetime, end_datetime, user=None): ) return f"{self.protocol.service_url.rstrip('/')}{resource}{endpoint}" + @staticmethod + def _extract_work_location_type_members(metadata_xml): + if not metadata_xml: + return [] + + try: + root = ET.fromstring(metadata_xml) + except ET.ParseError: + return [] + + for enum_type in root.findall(".//{*}EnumType"): + if enum_type.attrib.get("Name") != "workLocationType": + continue + return [ + member.attrib.get("Name") + for member in enum_type.findall("{*}Member") + if member.attrib.get("Name") + ] + return [] + + def get_valid_work_location_types(self, force_refresh=False): + """Return valid workLocationType values from Graph metadata. + + Falls back to known Graph enum defaults when metadata is unavailable. + """ + if self._work_location_types_cache is not None and not force_refresh: + return list(self._work_location_types_cache) + + metadata_url = f"{self.protocol.service_url.rstrip('/')}/$metadata" + resolved_types = [] + + try: + response = self.con.get(metadata_url) + if response: + resolved_types = self._extract_work_location_type_members( + getattr(response, "text", "") + ) + except HTTPError as e: + log.debug( + "Could not read Graph metadata for workLocationType values. Error: %s", + e, + ) + + if not resolved_types: + resolved_types = list(self.default_work_location_types) + + self._work_location_types_cache = tuple(resolved_types) + return list(self._work_location_types_cache) + def occurrences_view( - self, start_datetime, end_datetime, user=None, top=None, select=None + self, + start_datetime, + end_datetime, + user=None, + top=None, + select=None, + include_valid_values=False, ): """Return work plan occurrences in a datetime range. @@ -154,6 +218,7 @@ def occurrences_view( :param str user: optional user id/upn, defaults to /me when omitted :param int top: optional OData $top :param list[str] select: optional OData $select fields + :param bool include_valid_values: include valid workLocationType values on each item :rtype: list[WorkPlanOccurrence] """ start_datetime = self._validate_datetime(start_datetime, "start_datetime") @@ -178,6 +243,8 @@ def occurrences_view( ): raise ValueError("'select' must be a list of strings") params["$select"] = ",".join(select) + if not isinstance(include_valid_values, bool): + raise ValueError("'include_valid_values' must be a boolean") try: response = self.con.get(url, params=params or None) @@ -211,6 +278,9 @@ def occurrences_view( raise ValueError("Malformed response payload: 'value' must be a list") occurrences = [] + valid_values = ( + self.get_valid_work_location_types() if include_valid_values else None + ) for index, item in enumerate(values): if not isinstance(item, dict): raise ValueError( @@ -218,9 +288,12 @@ def occurrences_view( ) try: - occurrences.append( - self.occurrence_constructor(parent=self, **{self._cloud_data_key: item}) + occurrence = self.occurrence_constructor( + parent=self, **{self._cloud_data_key: item} ) + if valid_values is not None: + occurrence.valid_work_location_types = list(valid_values) + occurrences.append(occurrence) except Exception as e: raise ValueError( f"Malformed occurrence payload at index {index}: {e}" From bb9ae792c58a3f4200ea66e11e8f99f1251cd182 Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Wed, 14 Oct 2026 00:06:39 +0200 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=85=20test(work-hours):=20cover=20met?= =?UTF-8?q?adata=20enum=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for workLocationType extraction from metadata, fallback behavior when metadata is unavailable, and inclusion of valid values in occurrence results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_work_hours_and_locations.py | 93 +++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/tests/test_work_hours_and_locations.py b/tests/test_work_hours_and_locations.py index 86842337..2ba1123f 100644 --- a/tests/test_work_hours_and_locations.py +++ b/tests/test_work_hours_and_locations.py @@ -9,9 +9,10 @@ class MockResponse: - def __init__(self, payload, status_code=200): + def __init__(self, payload, status_code=200, text=""): self._payload = payload self.status_code = status_code + self.text = text def json(self): return self._payload @@ -26,10 +27,16 @@ def __init__(self): self.params = None self.payload = {"value": []} self.error = None + self.metadata_text = None + self.metadata_error = None def get(self, url, params=None, **kwargs): self.url = url self.params = params + if url.endswith("/$metadata"): + if self.metadata_error: + raise self.metadata_error + return MockResponse({}, text=self.metadata_text or "") if self.error: raise self.error return MockResponse(self.payload) @@ -215,3 +222,87 @@ def test_occurrences_view_malformed_occurrence_raises(work_hours): dt.datetime(2026, 1, 1, 0, 0, tzinfo=dt.timezone.utc), dt.datetime(2026, 1, 2, 0, 0, tzinfo=dt.timezone.utc), ) + + +def test_get_valid_work_location_types_from_metadata(work_hours): + work_hours.con.metadata_text = """ + + + + + + + + + + + + + + """ + + values = work_hours.get_valid_work_location_types() + assert values == [ + "unspecified", + "office", + "remote", + "timeOff", + "unknownFutureValue", + ] + + +def test_get_valid_work_location_types_uses_fallback_when_metadata_unavailable( + work_hours, +): + work_hours.con.metadata_error = _build_http_error(404) + + values = work_hours.get_valid_work_location_types(force_refresh=True) + assert values == list(work_hours.default_work_location_types) + + +def test_occurrences_view_includes_valid_values_when_requested(work_hours): + work_hours.con.metadata_text = """ + + + + + + + + + + + + + + """ + work_hours.con.payload = { + "value": [ + { + "id": "occ-4", + "workLocationType": "office", + "startDateTime": { + "dateTime": "2026-01-03T09:00:00", + "timeZone": "UTC", + }, + "endDateTime": { + "dateTime": "2026-01-03T10:00:00", + "timeZone": "UTC", + }, + } + ] + } + + occurrences = work_hours.occurrences_view( + dt.datetime(2026, 1, 3, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2026, 1, 4, 0, 0, tzinfo=dt.timezone.utc), + include_valid_values=True, + ) + + assert occurrences[0].valid_work_location_types == [ + "unspecified", + "office", + "remote", + "timeOff", + "unknownFutureValue", + ] From 25511a9a30c32fc46de89f1b8c2308e8293c03cd Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Wed, 14 Oct 2026 00:06:39 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=93=9D=20docs(work-hours):=20document?= =?UTF-8?q?=20workLocationType=20value=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document get_valid_work_location_types() and include_valid_values usage for passing valid workLocationType values to downstream consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/source/usage/work_hours_and_locations.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/source/usage/work_hours_and_locations.rst b/docs/source/usage/work_hours_and_locations.rst index 580be864..d4e6b91f 100644 --- a/docs/source/usage/work_hours_and_locations.rst +++ b/docs/source/usage/work_hours_and_locations.rst @@ -35,3 +35,21 @@ Example: for item in occurrences: print(item.object_id, item.work_location_type, item.start, item.end, item.time_zone) + +Getting valid ``workLocationType`` values +"""""""""""""""""""""""""""""""""""""""""" + +Microsoft Graph doesn't expose a dedicated enum endpoint, but the wrapper can resolve +the values from Graph ``/$metadata`` and fallback to known defaults if metadata is unavailable. + +.. code-block:: python + + valid_values = whl.get_valid_work_location_types() + print(valid_values) + +You can also attach these values to each returned occurrence object: + +.. code-block:: python + + occurrences = whl.occurrences_view(start, end, include_valid_values=True) + print(occurrences[0].valid_work_location_types) From 8315951b5064f684e899ee95424e3a3ab6598fae Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Thu, 29 Oct 2026 11:08:27 +0200 Subject: [PATCH 7/8] fix(work-hours): handle windows timezone fallback and 403/404 classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- O365/work_hours_and_locations.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/O365/work_hours_and_locations.py b/O365/work_hours_and_locations.py index 753219e7..1234fd75 100644 --- a/O365/work_hours_and_locations.py +++ b/O365/work_hours_and_locations.py @@ -14,6 +14,12 @@ class WorkPlanOccurrence(ApiComponent): """A work-hours and location occurrence returned by Microsoft Graph.""" + windows_time_zone_fallbacks = { + # Some Windows mappings resolve to legacy IANA names that are not + # present in zoneinfo databases (e.g. US/Pacific-New). + "Pacific Standard Time": "America/Los_Angeles", + } + def __init__(self, *, parent=None, con=None, **kwargs): if parent and con: raise ValueError("Need a parent or a connection but not both") @@ -73,6 +79,12 @@ def _resolve_time_zone(self, time_zone_name): try: return get_iana_tz(time_zone_name) except ZoneInfoNotFoundError: + fallback_zone = self.windows_time_zone_fallbacks.get(time_zone_name) + if fallback_zone: + try: + return ZoneInfo(fallback_zone) + except ZoneInfoNotFoundError: + pass log.debug( "TimeZone not found in IANA or Windows maps: %s. Using protocol timezone.", time_zone_name, @@ -249,7 +261,7 @@ def occurrences_view( try: response = self.con.get(url, params=params or None) except HTTPError as e: - status_code = e.response.status_code if e.response else None + status_code = e.response.status_code if e.response is not None else None if status_code in (403, 404): message = ( "Work-hours endpoint is unavailable for this tenant/user " From 13c92df361d1f7111bdd2a736217970dd159d6d6 Mon Sep 17 00:00:00 2001 From: "Clifford W. Hansen" Date: Fri, 30 Oct 2026 08:58:12 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=93=9D=20docs(work-hours):=20declare?= =?UTF-8?q?=20attributes=20in=20standard=20library=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- O365/work_hours_and_locations.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/O365/work_hours_and_locations.py b/O365/work_hours_and_locations.py index 1234fd75..5fd8816d 100644 --- a/O365/work_hours_and_locations.py +++ b/O365/work_hours_and_locations.py @@ -21,6 +21,16 @@ class WorkPlanOccurrence(ApiComponent): } def __init__(self, *, parent=None, con=None, **kwargs): + """A work-hours and location occurrence + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ if parent and con: raise ValueError("Need a parent or a connection but not both") self.con = parent.con if parent else con @@ -37,12 +47,16 @@ def __init__(self, *, parent=None, con=None, **kwargs): cloud_data = kwargs.get(self._cloud_data_key, {}) cc = self._cc + #: The unique id of the occurrence. |br| **Type:** str self.object_id = cloud_data.get(cc("id")) if not self.object_id: raise ValueError("Missing occurrence id") + #: Alias of object_id. |br| **Type:** str self.id = self.object_id + #: The id of the recurrence this occurrence belongs to. |br| **Type:** str self.recurrence_id = cloud_data.get(cc("recurrenceId")) + #: The work location type, e.g. office, remote, timeOff. |br| **Type:** str self.work_location_type = cloud_data.get(cc("workLocationType"), "") start_payload = cloud_data.get(cc("startDateTime")) or cloud_data.get( @@ -50,19 +64,26 @@ def __init__(self, *, parent=None, con=None, **kwargs): ) end_payload = cloud_data.get(cc("endDateTime")) or cloud_data.get(cc("end")) + #: The time zone of the occurrence. |br| **Type:** str self.time_zone = ( cloud_data.get(cc("timeZone")) or self._extract_time_zone(start_payload) or self._extract_time_zone(end_payload) ) + #: Start of the occurrence, in the protocol time zone. |br| **Type:** datetime self.start = self._parse_occurrence_datetime(start_payload, self.time_zone) + #: End of the occurrence, in the protocol time zone. |br| **Type:** datetime self.end = self._parse_occurrence_datetime(end_payload, self.time_zone) + #: The location details of the occurrence. |br| **Type:** dict self.location = cloud_data.get(cc("location")) or cloud_data.get( cc("workLocation") ) + #: Valid workLocationType values; populated when include_valid_values is requested. |br| **Type:** list[str] + self.valid_work_location_types = None + if self.start is None or self.end is None: raise ValueError("Occurrence is missing a valid start or end datetime") @@ -120,6 +141,7 @@ def _parse_occurrence_datetime(self, payload, fallback_time_zone): class WorkHoursAndLocations(ApiComponent): """Read work-hours and location occurrences from Microsoft Graph.""" + #: Fallback workLocationType values used when Graph metadata is unavailable. |br| **Type:** tuple[str] default_work_location_types = ( "unspecified", "office", @@ -130,6 +152,16 @@ class WorkHoursAndLocations(ApiComponent): occurrence_constructor = WorkPlanOccurrence #: :meta private: def __init__(self, *, parent=None, con=None, **kwargs): + """Object to retrieve work-hours and location occurrences + + :param parent: parent object + :type parent: Account + :param Connection con: connection to use if no parent specified + :param Protocol protocol: protocol to use if no parent specified + (kwargs) + :param str main_resource: use this resource instead of parent resource + (kwargs) + """ if parent and con: raise ValueError("Need a parent or a connection but not both") self.con = parent.con if parent else con