Skip to content

Commit

Permalink
Add APIs for create/delete of events (#131)
Browse files Browse the repository at this point in the history
* Add API for deletion of events

* Update APIs and improve test coverage

* Remove EventUuid

* Remove temporary vim files

* Improve timeline test coverage

* Add test coverage for creating events

* Improve test coverage for failure cases

* Remove dead code

* Let the caller manage syncing the event store

* Additional tweaks to behavior of recurring events

* Remove unused instance and update code

* Add back test cases and coverage
  • Loading branch information
allenporter authored Oct 30, 2022
1 parent 79f232d commit dd87a49
Show file tree
Hide file tree
Showing 9 changed files with 880 additions and 27 deletions.
202 changes: 185 additions & 17 deletions gcal_sync/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from .auth import AbstractAuth
from .const import ITEMS
from .exceptions import ApiException
from .model import EVENT_FIELDS, Calendar, Event, EventStatusEnum
from .model import EVENT_FIELDS, Calendar, Event, EventStatusEnum, SyntheticEventId
from .store import CalendarStore
from .timeline import Timeline, calendar_timeline

Expand All @@ -47,6 +47,7 @@
"LocalListEventsRequest",
"LocalListEventsResponse",
"Boolean",
"Range",
]


Expand All @@ -61,6 +62,8 @@
CALENDAR_LIST_URL = "users/me/calendarList"
CALENDAR_GET_URL = "calendars/{calendar_id}"
CALENDAR_EVENTS_URL = "calendars/{calendar_id}/events"
CALENDAR_EVENT_ID_URL = "calendars/{calendar_id}/events/{event_id}"
INSTANCES_URL = "calendars/{calendar_id}/events/{event_id}/instances"


class SyncableRequest(BaseModel):
Expand Down Expand Up @@ -326,18 +329,14 @@ async def async_get_calendar(self, calendar_id: str) -> Calendar:
)
return Calendar.parse_obj(result)

async def async_create_event(
self,
calendar_id: str,
event: Event,
) -> None:
"""Create an event on the specified calendar."""
body = json.loads(
event.json(exclude_unset=True, by_alias=True, exclude={"calendar_id"})
)
await self._auth.post(
CALENDAR_EVENTS_URL.format(calendar_id=pathname2url(calendar_id)), json=body
async def async_get_event(self, calendar_id: str, event_id: str) -> Event:
"""Return an event based on the event id."""
result = await self._auth.get_json(
CALENDAR_EVENT_ID_URL.format(
calendar_id=pathname2url(calendar_id), event_id=pathname2url(event_id)
)
)
return Event.parse_obj(result)

async def async_list_events(
self,
Expand Down Expand Up @@ -375,6 +374,46 @@ async def async_list_events_page(
_LOGGER.debug("Unable to parse result: %s", result)
raise ApiException("Error parsing API response") from err

async def async_create_event(
self,
calendar_id: str,
event: Event,
) -> None:
"""Create an event on the specified calendar."""
body = json.loads(event.json(exclude_unset=True, by_alias=True))
await self._auth.post(
CALENDAR_EVENTS_URL.format(calendar_id=pathname2url(calendar_id)),
json=body,
)

async def async_patch_event(
self,
calendar_id: str,
event_id: str,
body: dict[str, Any],
) -> None:
"""Updates an event using patch semantics, with raw API data."""
await self._auth.request(
"patch",
CALENDAR_EVENT_ID_URL.format(
calendar_id=pathname2url(calendar_id), event_id=pathname2url(event_id)
),
json=body,
)

async def async_delete_event(
self,
calendar_id: str,
event_id: str,
) -> None:
"""Delete an event on the specified calendar."""
await self._auth.request(
"delete",
CALENDAR_EVENT_ID_URL.format(
calendar_id=pathname2url(calendar_id), event_id=pathname2url(event_id)
),
)


class LocalCalendarListResponse(BaseModel):
"""Api response containing a list of calendars."""
Expand Down Expand Up @@ -430,12 +469,37 @@ async def async_list_calendars(
)


class Range(str, enum.Enum):
"""Specifies an effective range of recurrence instances for a recurrence id.
This is used when modifying a recurrence rule and specifying that the action
applies to all events following the specified event.
"""

NONE = "NONE"
"""No range is specified, just a single instance."""

THIS_AND_FUTURE = "THISANDFUTURE"
"""The range of the recurrence identifier and all subsequent values."""


class CalendarEventStoreService:
"""Performs event lookups from the local store."""
"""Performs event lookups from the local store.
def __init__(self, store: CalendarStore) -> None:
A CalendarEventStoreService should not be instantiated directly, and
instead created from a `gcal_sync.sync.CalendarEventSyncManager`.
"""

def __init__(
self,
store: CalendarStore,
calendar_id: str,
api: GoogleCalendarService,
) -> None:
"""Initialize CalendarEventStoreService."""
self._store = store
self._calendar_id = calendar_id
self._api = api

async def async_list_events(
self,
Expand All @@ -462,9 +526,7 @@ async def async_get_timeline(
self, tzinfo: datetime.tzinfo | None = None
) -> Timeline:
"""Get the timeline of events."""
store_data = await self._store.async_load() or {}
store_data.setdefault(ITEMS, {})
events_data = store_data.get(ITEMS, {})
events_data = await self._lookup_events_data()
_LOGGER.debug("Created timeline of %d events", len(events_data))

events: list[Event] = []
Expand All @@ -475,3 +537,109 @@ async def async_get_timeline(
events.append(event)

return calendar_timeline(events, tzinfo if tzinfo else datetime.timezone.utc)

async def async_add_event(self, event: Event) -> None:
"""Add the specified event to the calendar.
You should sync the event store after adding an event.
"""
_LOGGER.debug("Adding event: %s", event)
await self._api.async_create_event(self._calendar_id, event)

async def async_delete_event(
self,
event_id: str | None = None,
recurring_event_id: str | None = None,
recurrence_range: Range = Range.NONE,
) -> None:
"""Delete the event from the calendar.
This method is used to delete an existing event. For a recurring event
either the whole event or instances of an event may be deleted.
To delete the complete range of a recurring event, the `recurring_event_id`
for the event must be specified and the `event_id` should not be specified.
To delete individual instances or range of instances both the `recurring_event_id`
and `event_id` for the instances should be specified. The `recurrence_range`
determines if its just the individual event (`Range.NONE`) or also including
events going forward (`Range.THIS_AND_FUTURE`)
You should sync the event store after performing a delete operation.
"""
if not recurring_event_id:
if not event_id:
raise ValueError(
"At least one of event_id and recurring_event_id must be specified"
)
# Deleting a single event
await self._api.async_delete_event(self._calendar_id, event_id)
return

if not event_id:
# Deleting an entire series of a recurring event
await self._api.async_delete_event(self._calendar_id, recurring_event_id)
return

# Both an event_id and recurring_event_id are specified, so deleting one or
# more instances in the recurring event series
event = await self._lookup_event(recurring_event_id)
if not event:
raise ValueError(f"Event does not exist: {recurring_event_id}")
if not event.recurrence:
raise ValueError(
f"Specified recurrence_id but event is not recurring: {event_id}, {recurring_event_id}"
)

synthetic_event_id = SyntheticEventId.parse(event_id)
if recurrence_range == Range.NONE:
# A single recurrence instance is removed, marked as cancelled
cancelled_event = Event.parse_obj(
{
"id": event_id,
"status": EventStatusEnum.CANCELLED,
"start": event.start,
"end": event.end,
}
)
body = json.loads(cancelled_event.json(exclude_unset=True, by_alias=True))
del body["start"]
del body["end"]
await self._api.async_patch_event(self._calendar_id, event_id, body)
return

# Assumes any recurrence deletion is valid, and that overwriting
# the "until" value will not produce more instances.
if not (rule := event.recur):
raise ValueError(f"Unable to update RRULE, does not conform: {rule}")

# Stop recurring events before the specified date. This assumes that
# setting the "util" field won't create more instances by changing count.
rule.count = 0
rule.until = synthetic_event_id.dtstart - datetime.timedelta(seconds=1)
updated_event = Event.parse_obj(
{
"id": recurring_event_id,
"recurrence": [rule.as_rrule_str()],
"start": event.start,
"end": event.end,
}
)
body = json.loads(updated_event.json(exclude_unset=True, by_alias=True))
del body["start"]
del body["end"]
await self._api.async_patch_event(self._calendar_id, recurring_event_id, body)

async def _lookup_events_data(self) -> dict[str, Any]:
"""Loookup the raw events storage dictionary."""
store_data = await self._store.async_load() or {}
store_data.setdefault(ITEMS, {})
return store_data.get(ITEMS, {})

async def _lookup_event(self, event_id: str) -> Event | None:
"""Find the specified event by id in the local store."""
event_store_data = await self._lookup_events_data()
_LOGGER.debug("store_data=%s", event_store_data)
if event_data := event_store_data.get(event_id):
return Event.parse_obj(event_data)
return None
2 changes: 1 addition & 1 deletion gcal_sync/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async def request(
if not (url.startswith("http://") or url.startswith("https://")):
url = f"{self._host}/{url}"
_LOGGER.debug("request[%s]=%s %s", method, url, kwargs.get("params"))
if method == "post" and "json" in kwargs:
if method != "get" and "json" in kwargs:
_LOGGER.debug("request[post json]=%s", kwargs["json"])
return await self._websession.request(method, url, **kwargs, headers=headers)

Expand Down
92 changes: 91 additions & 1 deletion gcal_sync/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from dateutil import rrule
from ical.timespan import Timespan
from ical.types.recur import Recur
from pydantic import BaseModel, Field, root_validator

__all__ = [
Expand All @@ -36,6 +37,7 @@
"visibility,attendees,attendeesOmitted,recurrence,recurringEventId,originalStartTime"
)
MIDNIGHT = datetime.time()
ID_DELIM = "_"


class Calendar(BaseModel):
Expand Down Expand Up @@ -196,14 +198,95 @@ class Attendee(BaseModel):
"""The attendee's response status."""


class SyntheticEventId:
"""Used to generate a event ids for synthetic recurring events.
A `gcal_sync.timeline.Timeline` will create synthetic events for each instance
of a recurring event. The API returns the original event id of the underlying
event as `recurring_event_id`. This class is used to create the synthetic
unique `event_id` that includes the date or datetime value of the event instance.
This class does not generate values in the `recurring_event_id` field.
"""

def __init__(
self, event_id: str, dtstart: datetime.date | datetime.datetime
) -> None:
self._event_id = event_id
self._dtstart = dtstart

@classmethod
def of( # pylint: disable=invalid-name]
cls,
event_id: str,
dtstart: datetime.date | datetime.datetime,
) -> SyntheticEventId:
"""Create a SyntheticEventId based on the event instance."""
return SyntheticEventId(event_id, dtstart)

@classmethod
def parse(cls, synthetic_event_id: str) -> SyntheticEventId:
"""Parse a SyntheticEventId from the event id string."""
parts = synthetic_event_id.rsplit(ID_DELIM, maxsplit=1)
if len(parts) != 2:
raise ValueError(
f"id was not a valid synthetic_event_id: {synthetic_event_id}"
)
dtstart: datetime.date | datetime.datetime
if len(parts[1]) != 8:
if len(parts[1]) == 0 or parts[1][-1] != "Z":
raise ValueError(
f"SyntheticEventId had invalid date/time or timezone: {synthetic_event_id}"
)

dtstart = datetime.datetime.strptime(
parts[1][:-1], "%Y%m%dT%H%M%S"
).replace(tzinfo=datetime.timezone.utc)
else:
dtstart = datetime.datetime.strptime(parts[1], "%Y%m%d").date()
return SyntheticEventId(parts[0], dtstart)

@classmethod
def is_valid(cls, synthetic_event_id: str) -> bool:
"""Return true if the value is a valid SyntheticEventId string."""
try:
cls.parse(synthetic_event_id)
except ValueError:
return False
return True

@property
def event_id(self) -> str:
"""Return the string value of the new event id."""
if isinstance(self._dtstart, datetime.datetime):
utc = self._dtstart.astimezone(datetime.timezone.utc)
return f"{self._event_id}{ID_DELIM}{utc.strftime('%Y%m%dT%H%M%SZ')}"
return f"{self._event_id}{ID_DELIM}{self._dtstart.strftime('%Y%m%d')}"

@property
def original_event_id(self) -> str:
"""Return the underlying/original event id."""
return self._event_id

@property
def dtstart(self) -> datetime.date | datetime.datetime:
"""Return the date value for the event id."""
return self._dtstart


class Event(BaseModel):
"""A single event on a calendar."""

id: Optional[str] = None
"""Opaque identifier of the event."""

ical_uuid: Optional[str] = Field(alias="iCalUID", default=None)
"""Event unique identifier as defined in RFC5545."""
"""Event unique identifier as defined in RFC5545.
Note that the iCalUID and the id are not identical. One difference in
their semantics is that in recurring events, all occurrences of one event
have different ids while they all share the same iCalUIDs.
"""

summary: str = ""
"""Title of the event."""
Expand Down Expand Up @@ -273,6 +356,13 @@ def rrule(self) -> rrule.rrule | rrule.rruleset:
f"Invalid recurrence rule: {self.json()}: {str(err)}"
) from err

@property
def recur(self) -> Recur:
"""Build a recurrence rule for the event."""
if len(self.recurrence) != 1:
raise ValueError(f"Unexpected recurrence value: {self.recurrence}")
return Recur.from_rrule(self.recurrence[0])

@root_validator(pre=True)
def _allow_cancelled_events(cls, values: dict[str, Any]) -> dict[str, Any]:
"""Special case for canceled event tombstones that are missing required fields."""
Expand Down
Loading

0 comments on commit dd87a49

Please sign in to comment.