Repository: allenporter/gcal_sync (language: python)

Issue:
Feature request: fetch guests (i.e. attendees)
It would be great if we can fetch the guests (i.e. attendees) of an event, perhaps in the form of:

```json
[
    {
        "name": "John Doe",
        "email": "john.doe@biz.com"
    },
    {
        "name": "Jane Doe",
        "email": "jane.doe@biz.com"
    }
]
```

Context: this library is being used in the [Home Assistant Google Calendar integration](https://github.com/home-assistant/core/tree/dev/homeassistant/components/google). If I know I'm in my kitchen (using presence detection) and I have a meeting, I'd like Google Assistant to notify me about this. I tend to create events for myself without guests (e.g. focus time) and I don't need to trigger automations on those. 


Discussion / hints:
Really cool suggestion.

I feel like the example you gave was very compelling.  I think of two possible ways to consider this.

- Dump the data from the calendar API into the home assistant data (entity or calendar event in the trigger). It might not be standard across other calendars so we'd have to work through a generic way to add this to the data model

- Actually associating the guests with People in home assistant which might be more powerful and potentially useful by other integrations. This might get more interest from the dev community about other things we can connect and power.

Going to keep thinking about this...
My other thought is that location may also be an interesting similarly shaped attribute.

(know who is invited and where they should be)
My other thought is that location may also be an interesting similarly shaped attribute.

(know who is invited and where they should be)
Thanks for giving this some thought! Are you also the maintainer for the HA integration?

> Dump the data from the calendar API into the home assistant data (entity or calendar event in the trigger). It might not be standard across other calendars so we'd have to work through a generic way to add this to the data model

Yes, I was thinking about something like this. I'm not too familiar with how HA works, but I was assuming I could attach something like a `data` dictionary to the trigger events that I can then handle in HA. I was about to write this myself, but noticed guests aren't exposed from this library, which brought me to this feature request.

> Actually associating the guests with People in home assistant which might be more powerful and potentially useful by other integrations. This might get more interest from the dev community about other things we can connect and power.

This sounds very cool. Most of the people I meet with aren't a `Person` entity in my HA instance, though I can see its use. I think the `gcal-sync` library still would need to be extended to include guests in its data model though, am I right?

Yes, agreed exposing the data here is a pre-req, before exposing in home assistant, and straight forward.

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ea2af48..34ce5d6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -43,4 +43,5 @@ repos:
         "-rn", # Only display messages
         "-sn", # Don't display the score
         "--disable=too-few-public-methods",
+        "--disable=duplicate-code",
       ]
diff --git a/gcal_sync/model.py b/gcal_sync/model.py
index 1e105db..4b8574d 100644
--- a/gcal_sync/model.py
+++ b/gcal_sync/model.py
@@ -10,7 +10,10 @@
 from pydantic import BaseModel, Field, root_validator
 
 DATE_STR_FORMAT = "%Y-%m-%d"
-EVENT_FIELDS = "id,summary,description,location,start,end,transparency,timeZone"
+EVENT_FIELDS = (
+    "id,summary,description,location,start,end,transparency,timeZone,eventType,"
+    "visibility,attendees,attendeesOmitted"
+)
 
 
 class Calendar(BaseModel):
@@ -63,13 +66,39 @@ class Config:
 
 
 class EventStatusEnum(str, Enum):
-    "Status of the event"
+    "Status of the event."
 
     CONFIRMED = "confirmed"
     TENTATIVE = "tentative"
     CANCELLED = "cancelled"
 
 
+class EventTypeEnum(str, Enum):
+    """Type of the event."""
+
+    DEFAULT = "default"
+    OUT_OF_OFFICE = "outOfOffice"
+    FOCUS_TIME = "focusTime"
+
+
+class VisibilityEnum(str, Enum):
+    """Visibility of the event."""
+
+    DEFAULT = "default"
+    PUBLIC = "public"
+    PRIVATE = "private"  # Same as confidential
+
+
+class Attendee(BaseModel):
+    """An attendee of an event."""
+
+    id: Optional[str] = None
+    email: str = ""
+    displayName: Optional[str] = None
+    optional: bool = False
+    comment: Optional[str] = None
+
+
 class Event(BaseModel):
     """A single event on a calendar."""
 
@@ -84,6 +113,10 @@ class Event(BaseModel):
     # such as enabling incremental sync or explicitly asking for deleted items. That is,
     # most users should not need to check the status.
     status: EventStatusEnum = EventStatusEnum.CONFIRMED
+    event_type: EventTypeEnum = Field(alias="eventType", default=EventTypeEnum.DEFAULT)
+    visibility: VisibilityEnum = VisibilityEnum.DEFAULT
+    attendees: list[Attendee] = []
+    attendeesOmitted: bool = False
 
     @root_validator(pre=True)
     def allow_cancelled_events(cls, values: dict[str, Any]) -> dict[str, Any]:
@@ -96,6 +129,14 @@ def allow_cancelled_events(cls, values: dict[str, Any]) -> dict[str, Any]:
                     values["end"] = DateOrDatetime(date=datetime.date.min)
         return values
 
+    @root_validator(pre=True)
+    def adjust_visibility(cls, values: dict[str, Any]) -> dict[str, Any]:
+        """Convert legacy visibility types to new types."""
+        if visibility := values.get("visibility"):
+            if visibility == "confidential":
+                values["visibility"] = "private"
+        return values
+
     class Config:
         """Model configuration."""
 


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 897924e..05a6549 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -6,7 +6,7 @@
 from freezegun import freeze_time
 
 from gcal_sync.api import GoogleCalendarService, ListEventsRequest
-from gcal_sync.model import Calendar, DateOrDatetime, Event
+from gcal_sync.model import EVENT_FIELDS, Calendar, DateOrDatetime, Event
 
 from .conftest import ApiRequest, ApiResult
 
@@ -97,8 +97,8 @@ async def test_list_events(
     )
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-04-30T01:31:02%2B00:00"
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-04-30T01:31:02%2B00:00"
     ]
     assert result.items == [
         Event(
@@ -149,9 +149,8 @@ async def test_list_events_with_date_limit(
     )
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start,"
-        "end,transparency,timeZone)&timeMin=2022-04-13T07:30:12-06:00"
-        "&timeMax=2022-04-13T09:30:12-06:00"
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-04-13T07:30:12-06:00&timeMax=2022-04-13T09:30:12-06:00"
     ]
 
 
@@ -390,16 +389,16 @@ async def test_list_events_multiple_pages_with_iterator(
     assert url_request() == [
         # Request #1
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-04-30T01:31:02%2B00:00",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-04-30T01:31:02%2B00:00",
         # Request #2
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-04-30T01:31:02%2B00:00&pageToken=page-token-1",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-04-30T01:31:02%2B00:00&pageToken=page-token-1",
         # Request #3
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-04-30T01:31:02%2B00:00&pageToken=page-token-2",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-04-30T01:31:02%2B00:00&pageToken=page-token-2",
     ]
     assert items == [
         Event(
@@ -440,6 +439,6 @@ async def test_list_event_url_encoding(
     assert url_request() == [
         "/calendars/en.usa#holiday@group.v.calendar.google.com/events?maxResult=100"
         "&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-04-30T01:31:02%2B00:00"
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-04-30T01:31:02%2B00:00"
     ]
diff --git a/tests/test_event_sync.py b/tests/test_event_sync.py
index 4304806..731129c 100644
--- a/tests/test_event_sync.py
+++ b/tests/test_event_sync.py
@@ -11,7 +11,7 @@
 from gcal_sync.api import GoogleCalendarService, LocalListEventsRequest
 from gcal_sync.event_sync import VERSION, CalendarEventSyncManager
 from gcal_sync.exceptions import ApiException
-from gcal_sync.model import DateOrDatetime, Event
+from gcal_sync.model import EVENT_FIELDS, DateOrDatetime, Event
 from gcal_sync.store import CalendarStore, InMemoryCalendarStore
 
 from .conftest import ApiResult, ResponseResult
@@ -96,8 +96,8 @@ async def test_lookup_items(
     await sync.run()
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02"
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02"
     ]
 
     result = await sync.store_service.async_list_events(
@@ -227,11 +227,11 @@ async def test_sync_date_pages(
     await sync.run()
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02",
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02&pageToken=page-token-1",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02&pageToken=page-token-1",
     ]
 
     json_response(
@@ -255,14 +255,14 @@ async def test_sync_date_pages(
     await sync.run()
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02",
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02&pageToken=page-token-1",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02&pageToken=page-token-1",
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&syncToken=sync-token-1",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&syncToken=sync-token-1",
     ]
 
 
@@ -318,11 +318,11 @@ async def test_sync_datetime_pages(
     await sync.run()
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02",
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02&pageToken=page-token-1",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02&pageToken=page-token-1",
     ]
 
     json_response(
@@ -346,14 +346,14 @@ async def test_sync_datetime_pages(
     await sync.run()
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02",
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02&pageToken=page-token-1",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02&pageToken=page-token-1",
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&syncToken=sync-token-1",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&syncToken=sync-token-1",
     ]
 
 
@@ -401,8 +401,8 @@ async def test_invalidated_sync_token(
     await sync.run()
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02"
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02"
     ]
 
     result = await sync.store_service.async_list_events(LocalListEventsRequest())
@@ -446,11 +446,11 @@ async def test_invalidated_sync_token(
     await sync.run()
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&syncToken=sync-token-1",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS}"
+        ")&syncToken=sync-token-1",
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02",
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02",
     ]
     result = await sync.store_service.async_list_events(LocalListEventsRequest())
     assert result.events == [
@@ -497,8 +497,8 @@ async def test_token_version_invalidation(
     await sync.run()
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02"
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02"
     ]
 
     result = await sync.store_service.async_list_events(LocalListEventsRequest())
@@ -531,8 +531,8 @@ async def test_token_version_invalidation(
 
     assert url_request() == [
         "/calendars/some-calendar-id/events?maxResult=100&singleEvents=true&orderBy=startTime"
-        "&fields=kind,nextPageToken,nextSyncToken,items(id,summary,description,location,start"
-        ",end,transparency,timeZone)&timeMin=2022-03-08T00:31:02"
+        f"&fields=kind,nextPageToken,nextSyncToken,items({EVENT_FIELDS})"
+        "&timeMin=2022-03-08T00:31:02"
     ]
     result = await sync.store_service.async_list_events(LocalListEventsRequest())
     assert len(result.events) == 1
diff --git a/tests/test_model.py b/tests/test_model.py
index ec4e5fe..273ffa9 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -6,7 +6,14 @@
 import pytest
 from pydantic import ValidationError
 
-from gcal_sync.model import Calendar, Event, EventStatusEnum
+from gcal_sync.model import (
+    Attendee,
+    Calendar,
+    Event,
+    EventStatusEnum,
+    EventTypeEnum,
+    VisibilityEnum,
+)
 
 
 def test_calendar() -> None:
@@ -344,3 +351,106 @@ def test_required_fields() -> None:
                 "status": "confirmed",
             }
         )
+
+
+@pytest.mark.parametrize(
+    "api_event_type,event_type",
+    [
+        ("default", EventTypeEnum.DEFAULT),
+        ("focusTime", EventTypeEnum.FOCUS_TIME),
+        ("outOfOffice", EventTypeEnum.OUT_OF_OFFICE),
+    ],
+)
+def test_event_type(api_event_type: str, event_type: EventTypeEnum) -> None:
+    """Exercise basic parsing of an event API response."""
+
+    event = Event.parse_obj(
+        {
+            "id": "some-event-id",
+            "eventType": api_event_type,
+            "summary": "Event summary",
+            "start": {
+                "date": "2022-04-12",
+            },
+            "end": {
+                "date": "2022-04-13",
+            },
+        }
+    )
+    assert event.id == "some-event-id"
+    assert event.summary == "Event summary"
+    assert event.event_type == event_type
+
+
+@pytest.mark.parametrize(
+    "api_visibility,visibility",
+    [
+        ("default", VisibilityEnum.DEFAULT),
+        ("public", VisibilityEnum.PUBLIC),
+        ("private", VisibilityEnum.PRIVATE),
+        ("confidential", VisibilityEnum.PRIVATE),
+    ],
+)
+def test_visibility_enum(api_visibility: str, visibility: VisibilityEnum) -> None:
+    """Exercise basic parsing of an event API response."""
+
+    event = Event.parse_obj(
+        {
+            "id": "some-event-id",
+            "visibility": api_visibility,
+            "summary": "Event summary",
+            "start": {
+                "date": "2022-04-12",
+            },
+            "end": {
+                "date": "2022-04-13",
+            },
+        }
+    )
+    assert event.id == "some-event-id"
+    assert event.summary == "Event summary"
+    assert event.visibility == visibility
+
+
+def test_attendees() -> None:
+    """Test event attendees."""
+
+    event = Event.parse_obj(
+        {
+            "id": "some-event-id",
+            "summary": "Event summary",
+            "start": {
+                "date": "2022-04-12",
+            },
+            "end": {
+                "date": "2022-04-13",
+            },
+            "attendees": [
+                {
+                    "id": "attendee-id-1",
+                    "email": "example1@example.com",
+                    "displayName": "Example 1",
+                    "comment": "comment 1",
+                },
+                {
+                    "id": "attendee-id-2",
+                    "email": "example2@example.com",
+                    "displayName": "Example 2",
+                },
+            ],
+        }
+    )
+    assert len(event.attendees) == 2
+    assert event.attendees == [
+        Attendee(
+            id="attendee-id-1",
+            email="example1@example.com",
+            displayName="Example 1",
+            comment="comment 1",
+        ),
+        Attendee(
+            id="attendee-id-2",
+            email="example2@example.com",
+            displayName="Example 2",
+        ),
+    ]