Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove deleted items in incremental sync #47

Merged
merged 1 commit into from
May 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion gcal_sync/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from .auth import AbstractAuth
from .const import EVENTS
from .model import EVENT_FIELDS, Calendar, Event
from .model import EVENT_FIELDS, Calendar, Event, EventStatusEnum
from .store import CalendarStore

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -239,6 +239,8 @@ async def async_list_events(
events = []
for event_data in events_data.values():
event = Event.parse_obj(event_data)
if event.status == EventStatusEnum.CANCELLED:
continue
if request.start_time:
if event.end.date and request.start_time.date() > event.end.date:
continue
Expand Down
24 changes: 24 additions & 0 deletions gcal_sync/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import datetime
import zoneinfo
from enum import Enum
from typing import Any, Optional, Union

from pydantic import BaseModel, Field, root_validator
Expand Down Expand Up @@ -61,6 +62,14 @@ class Config:
arbitrary_types_allowed = True


class EventStatusEnum(str, Enum):
"Status of the event"

CONFIRMED = "confirmed"
TENTATIVE = "tentative"
CANCELLED = "cancelled"


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

Expand All @@ -71,6 +80,21 @@ class Event(BaseModel):
description: Optional[str]
location: Optional[str]
transparency: str = Field(default="opaque")
# Note deleted events are only returned in some scenarios based on request options
# 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

@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."""
if status := values.get("status"):
if status == EventStatusEnum.CANCELLED:
if "start" not in values:
values["start"] = DateOrDatetime(date=datetime.date.min)
if "end" not in values:
values["end"] = DateOrDatetime(date=datetime.date.min)
return values

class Config:
"""Model configuration."""
Expand Down
107 changes: 107 additions & 0 deletions tests/test_event_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,3 +537,110 @@ async def test_token_version_invalidation(
result = await sync.store_service.async_list_events(LocalListEventsRequest())
assert len(result.events) == 1
assert result.events[0].id == "some-event-id-2"


@freeze_time("2022-04-05 07:31:02", tz_offset=-7)
async def test_canceled_events(
event_sync_manager_cb: Callable[[], Awaitable[CalendarEventSyncManager]],
json_response: ApiResult,
) -> None:
"""Test lookup events API."""

json_response(
{
"items": [
{
"id": "some-event-id-1",
"summary": "Event 1",
"description": "Event description 1",
"start": {
"date": "2022-04-13",
},
"end": {
"date": "2022-04-14",
},
"status": "confirmed",
},
{
"id": "some-event-id-2",
"summary": "Event 2",
"description": "Event description 2",
"start": {
"date": "2022-04-15",
},
"end": {
"date": "2022-04-20",
},
},
],
"nextSyncToken": "sync-token-1",
}
)

sync = await event_sync_manager_cb()
await sync.run()
result = await sync.store_service.async_list_events(
LocalListEventsRequest(
start_time=datetime.datetime.fromisoformat("0001-01-01T00:00:00"),
)
)
assert result.events == [
Event(
id="some-event-id-1",
summary="Event 1",
description="Event description 1",
start=DateOrDatetime(date=datetime.date(2022, 4, 13)),
end=DateOrDatetime(date=datetime.date(2022, 4, 14)),
),
Event(
id="some-event-id-2",
summary="Event 2",
description="Event description 2",
start=DateOrDatetime(date=datetime.date(2022, 4, 15)),
end=DateOrDatetime(date=datetime.date(2022, 4, 20)),
),
]
json_response(
{
"items": [
{
"id": "some-event-id-1",
"status": "cancelled",
},
{
"id": "some-event-id-3",
"summary": "Event 3",
"description": "Event description 3",
"start": {
"date": "2022-04-15",
},
"end": {
"date": "2022-04-20",
},
},
],
"nextSyncToken": "sync-token-2",
}
)
await sync.run()
result = await sync.store_service.async_list_events(
LocalListEventsRequest(
start_time=datetime.datetime.fromisoformat("0001-01-01T00:00:00"),
)
)
assert result.events == [
Event(
id="some-event-id-2",
summary="Event 2",
description="Event description 2",
start=DateOrDatetime(date=datetime.date(2022, 4, 15)),
end=DateOrDatetime(date=datetime.date(2022, 4, 20)),
),
Event(
id="some-event-id-3",
summary="Event 3",
description="Event description 3",
start=DateOrDatetime(date=datetime.date(2022, 4, 15)),
end=DateOrDatetime(date=datetime.date(2022, 4, 20)),
),
]
38 changes: 32 additions & 6 deletions tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest
from pydantic import ValidationError

from gcal_sync.model import Calendar, Event
from gcal_sync.model import Calendar, Event, EventStatusEnum


def test_calendar() -> None:
Expand Down Expand Up @@ -36,7 +36,7 @@ def test_event_with_date() -> None:
{
"kind": "calendar#event",
"id": "some-event-id",
"status": "some-status",
"status": "confirmed",
"summary": "Event summary",
"description": "Event description",
"location": "Event location",
Expand All @@ -52,6 +52,7 @@ def test_event_with_date() -> None:
assert event.id == "some-event-id"
assert event.summary == "Event summary"
assert event.description == "Event description"
assert event.status == EventStatusEnum.CONFIRMED
assert event.location == "Event location"
assert event.transparency == "transparent"
assert event.start
Expand All @@ -73,7 +74,6 @@ def test_event_datetime() -> None:
{
"kind": "calendar#event",
"id": "some-event-id",
"status": "some-status",
"summary": "Event summary",
"start": {
"dateTime": "2022-04-12T16:30:00-08:00",
Expand All @@ -86,6 +86,7 @@ def test_event_datetime() -> None:
assert event.id == "some-event-id"
assert event.summary == "Event summary"
assert event.description is None
assert event.status == EventStatusEnum.CONFIRMED
assert event.location is None
tzinfo = datetime.timezone(datetime.timedelta(hours=-8))

Expand Down Expand Up @@ -113,7 +114,6 @@ def test_invalid_datetime() -> None:
base_event = {
"kind": "calendar#event",
"id": "some-event-id",
"status": "some-status",
"summary": "Event summary",
"end": {
"dateTime": "2022-04-12T17:00:00-08:00",
Expand Down Expand Up @@ -152,7 +152,6 @@ def test_event_timezone() -> None:
{
"kind": "calendar#event",
"id": "some-event-id",
"status": "some-status",
"summary": "Event summary",
"start": {
"dateTime": "2022-04-12T16:30:00",
Expand Down Expand Up @@ -199,7 +198,6 @@ def test_event_utc() -> None:
{
"kind": "calendar#event",
"id": "some-event-id",
"status": "some-status",
"summary": "Event summary",
"start": {
"dateTime": "2022-04-12T16:30:00Z",
Expand Down Expand Up @@ -318,3 +316,31 @@ def test_event_timezone_comparison_zimetone_not_used() -> None:
assert dt1.astimezone(datetime.timezone.utc) == dt2.astimezone(
datetime.timezone.utc
)


def test_event_cancelled() -> None:
"""Exercise basic parsing of an event API response."""

event = Event.parse_obj(
{
"id": "some-event-id",
"status": "cancelled",
}
)
assert event.id == "some-event-id"
assert not event.summary
assert event.description is None
assert event.location is None
assert event.status == EventStatusEnum.CANCELLED


def test_required_fields() -> None:
"""Exercise required fields for normal non-deleted events."""

with pytest.raises(ValidationError):
Event.parse_obj(
{
"id": "some-event-id",
"status": "confirmed",
}
)