contextbase-plugin-google-calendar 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contextbase_plugin_google_calendar-0.2.3.dist-info/METADATA +13 -0
- contextbase_plugin_google_calendar-0.2.3.dist-info/RECORD +17 -0
- contextbase_plugin_google_calendar-0.2.3.dist-info/WHEEL +4 -0
- plugin_google_calendar/__init__.py +0 -0
- plugin_google_calendar/binding_config.py +7 -0
- plugin_google_calendar/component.py +126 -0
- plugin_google_calendar/defs/__init__.py +0 -0
- plugin_google_calendar/defs/defs.yaml +1 -0
- plugin_google_calendar/models/__init__.py +0 -0
- plugin_google_calendar/models/ctx.py +88 -0
- plugin_google_calendar/models/ingress.py +384 -0
- plugin_google_calendar/models/translators.py +176 -0
- plugin_google_calendar/plugin.json +9 -0
- plugin_google_calendar/sources/__init__.py +0 -0
- plugin_google_calendar/sources/sync.py +248 -0
- plugin_google_calendar/utils/__init__.py +0 -0
- plugin_google_calendar/utils/client.py +113 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: contextbase-plugin-google-calendar
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Google Calendar plugin for ContextBase
|
|
5
|
+
Author: Alizain Feerasta
|
|
6
|
+
Author-email: Alizain Feerasta <alizain.feerasta@gmail.com>
|
|
7
|
+
Requires-Dist: contextbase-shared-plugins==0.2.3
|
|
8
|
+
Requires-Dist: dagster==1.12.14
|
|
9
|
+
Requires-Dist: dagster-dlt==0.28.14
|
|
10
|
+
Requires-Dist: dlt>=1.26.0
|
|
11
|
+
Requires-Dist: google-api-python-client>=2.185.0
|
|
12
|
+
Requires-Dist: pydantic>=2.12.0
|
|
13
|
+
Requires-Python: >=3.14, <3.15
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
plugin_google_calendar/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
plugin_google_calendar/binding_config.py,sha256=ywTFcaL5NnKKjc3c-Kx0XJTdAIVHfF9Oxuquew-c6GY,165
|
|
3
|
+
plugin_google_calendar/component.py,sha256=VHDK5ZuBz7E0aTHYA07SlWhGTmCk_e7TabCGOb_Tl40,4212
|
|
4
|
+
plugin_google_calendar/defs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
plugin_google_calendar/defs/defs.yaml,sha256=RWet1IW81pbom10Badvi6xu9RFcTUcDBvhbT8XCUybY,67
|
|
6
|
+
plugin_google_calendar/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
plugin_google_calendar/models/ctx.py,sha256=UP2pXtTZD9bflxIUegCaX-12CWfnCs7-bWvG3VcJ1p4,3263
|
|
8
|
+
plugin_google_calendar/models/ingress.py,sha256=vV5tUlX6JLXuMH52tdBuYGhzDGSlnfOY_Gjn2szUHNw,13797
|
|
9
|
+
plugin_google_calendar/models/translators.py,sha256=Ji8-3T19hjvVp01i-7VaYBZe2Hn1uyLGgX_kI7qg_es,7225
|
|
10
|
+
plugin_google_calendar/plugin.json,sha256=x1zAlAetAtZwfvIdRht5SXqWjWxHgSEpuNqN5VbxGIk,183
|
|
11
|
+
plugin_google_calendar/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
plugin_google_calendar/sources/sync.py,sha256=hFbtIN6GiiZaE7QBY4T6GUu2wcddSFhSCIxGiTzl_u8,8403
|
|
13
|
+
plugin_google_calendar/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
plugin_google_calendar/utils/client.py,sha256=xhGt_1T7uH5lzy3TZFGAdgFuwjTpp4k3JtFN1vmss6o,3673
|
|
15
|
+
contextbase_plugin_google_calendar-0.2.3.dist-info/WHEEL,sha256=i9aSRDivn5iP9LaR1BLQX2GNAuriQWPsFwbbWygTX2k,81
|
|
16
|
+
contextbase_plugin_google_calendar-0.2.3.dist-info/METADATA,sha256=2DfwQiqk-EZFrZuiMPu-3X63p3Zp1pgKXLn_mNFYnBE,467
|
|
17
|
+
contextbase_plugin_google_calendar-0.2.3.dist-info/RECORD,,
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import dagster as dg
|
|
2
|
+
from dagster import AssetExecutionContext
|
|
3
|
+
from dagster_dlt import DagsterDltResource
|
|
4
|
+
from shared_plugins.automation import non_overlapping_automation_condition
|
|
5
|
+
from shared_plugins.bindings import (
|
|
6
|
+
parse_binding_config,
|
|
7
|
+
require_authenticated_account,
|
|
8
|
+
)
|
|
9
|
+
from shared_plugins.control_plane import ControlPlaneClient
|
|
10
|
+
from shared_plugins.dlt import resolve_partition_binding, run_dlt_pipeline
|
|
11
|
+
from shared_plugins.google_client.auth import (
|
|
12
|
+
build_google_service,
|
|
13
|
+
)
|
|
14
|
+
from shared_plugins.resources import DLT_RESOURCE
|
|
15
|
+
from shared_plugins.naming import (
|
|
16
|
+
dagster_asset_group_name,
|
|
17
|
+
dagster_asset_tags,
|
|
18
|
+
dagster_dlt_asset_key,
|
|
19
|
+
dagster_partition_def_name,
|
|
20
|
+
dagster_pool_name,
|
|
21
|
+
dlt_source_name,
|
|
22
|
+
plugin_id_from_module,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .binding_config import GoogleCalendarBindingConfig
|
|
26
|
+
from .sources.sync import google_calendar_sync_source
|
|
27
|
+
from .utils.client import (
|
|
28
|
+
GoogleCalendarApiClient,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
PLUGIN_ID = plugin_id_from_module(__file__)
|
|
32
|
+
SYNC_JOB = "sync"
|
|
33
|
+
SYNC_SOURCE_NAME = dlt_source_name(PLUGIN_ID, SYNC_JOB)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_sync_specs(
|
|
37
|
+
partitions_def: dg.PartitionsDefinition,
|
|
38
|
+
automation_condition: dg.AutomationCondition,
|
|
39
|
+
) -> list[dg.AssetSpec]:
|
|
40
|
+
shared = dict(
|
|
41
|
+
group_name=dagster_asset_group_name(PLUGIN_ID),
|
|
42
|
+
tags=dagster_asset_tags(PLUGIN_ID),
|
|
43
|
+
automation_condition=automation_condition,
|
|
44
|
+
partitions_def=partitions_def,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
calendars_key = dagster_dlt_asset_key(SYNC_SOURCE_NAME, "calendars")
|
|
48
|
+
return [
|
|
49
|
+
dg.AssetSpec(key=calendars_key, **shared),
|
|
50
|
+
dg.AssetSpec(
|
|
51
|
+
key=dagster_dlt_asset_key(SYNC_SOURCE_NAME, "events"),
|
|
52
|
+
deps=[calendars_key],
|
|
53
|
+
**shared,
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GoogleCalendarSyncComponent(dg.Component):
|
|
59
|
+
def build_defs(self, context: dg.ComponentLoadContext) -> dg.Definitions:
|
|
60
|
+
partitions_def = dg.DynamicPartitionsDefinition(
|
|
61
|
+
name=dagster_partition_def_name(PLUGIN_ID)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
sync_specs = _build_sync_specs(
|
|
65
|
+
partitions_def,
|
|
66
|
+
non_overlapping_automation_condition(
|
|
67
|
+
dg.AutomationCondition.on_missing()
|
|
68
|
+
| dg.AutomationCondition.on_cron("*/15 * * * *")
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@dg.multi_asset(
|
|
73
|
+
specs=sync_specs,
|
|
74
|
+
can_subset=True,
|
|
75
|
+
name="google_calendar_sync",
|
|
76
|
+
pool=dagster_pool_name(PLUGIN_ID),
|
|
77
|
+
)
|
|
78
|
+
def google_calendar_sync_assets(
|
|
79
|
+
context: AssetExecutionContext,
|
|
80
|
+
dlt_resource: DagsterDltResource,
|
|
81
|
+
control_plane: dg.ResourceParam[ControlPlaneClient],
|
|
82
|
+
):
|
|
83
|
+
binding = resolve_partition_binding(
|
|
84
|
+
context=context,
|
|
85
|
+
control_plane=control_plane,
|
|
86
|
+
plugin_id=PLUGIN_ID,
|
|
87
|
+
)
|
|
88
|
+
binding_id = str(binding.binding_id)
|
|
89
|
+
parse_binding_config(binding, GoogleCalendarBindingConfig)
|
|
90
|
+
authenticated_account = require_authenticated_account(binding)
|
|
91
|
+
client = GoogleCalendarApiClient(
|
|
92
|
+
service=build_google_service(
|
|
93
|
+
api_name="calendar",
|
|
94
|
+
api_version="v3",
|
|
95
|
+
auth=authenticated_account,
|
|
96
|
+
control_plane=control_plane,
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
source = google_calendar_sync_source(
|
|
101
|
+
binding_id,
|
|
102
|
+
client=client,
|
|
103
|
+
)
|
|
104
|
+
yield from run_dlt_pipeline(
|
|
105
|
+
context=context,
|
|
106
|
+
dlt_resource=dlt_resource,
|
|
107
|
+
source=source,
|
|
108
|
+
plugin_id=PLUGIN_ID,
|
|
109
|
+
binding_id=binding_id,
|
|
110
|
+
job_name=SYNC_JOB,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
automation_sensor = dg.AutomationConditionSensorDefinition(
|
|
114
|
+
name="google_calendar_automation_sensor",
|
|
115
|
+
target=dg.AssetSelection.assets(google_calendar_sync_assets),
|
|
116
|
+
default_status=dg.DefaultSensorStatus.RUNNING,
|
|
117
|
+
minimum_interval_seconds=30,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return dg.Definitions(
|
|
121
|
+
assets=[google_calendar_sync_assets],
|
|
122
|
+
sensors=[automation_sensor],
|
|
123
|
+
resources={
|
|
124
|
+
"dlt_resource": DLT_RESOURCE,
|
|
125
|
+
},
|
|
126
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
type: plugin_google_calendar.component.GoogleCalendarSyncComponent
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import AwareDatetime
|
|
6
|
+
from shared_plugins.models import CtxModel, IdStr, NonNegativeInt
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CalendarRow(CtxModel):
|
|
10
|
+
id: IdStr
|
|
11
|
+
etag: str | None = None
|
|
12
|
+
summary: str | None = None
|
|
13
|
+
summary_override: str | None = None
|
|
14
|
+
description: str | None = None
|
|
15
|
+
location: str | None = None
|
|
16
|
+
time_zone: str | None = None
|
|
17
|
+
access_role: str | None = None
|
|
18
|
+
primary: bool | None = None
|
|
19
|
+
selected: bool | None = None
|
|
20
|
+
hidden: bool | None = None
|
|
21
|
+
is_deleted: bool = False
|
|
22
|
+
auto_accept_invitations: bool | None = None
|
|
23
|
+
data_owner: str | None = None
|
|
24
|
+
color_id: str | None = None
|
|
25
|
+
background_color: str | None = None
|
|
26
|
+
foreground_color: str | None = None
|
|
27
|
+
default_reminders: list[dict[str, Any]] | None = None
|
|
28
|
+
notification_settings: dict[str, Any] | None = None
|
|
29
|
+
conference_properties: dict[str, Any] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EventRow(CtxModel):
|
|
33
|
+
calendar_id: IdStr
|
|
34
|
+
id: IdStr
|
|
35
|
+
etag: str | None = None
|
|
36
|
+
status: str | None = None
|
|
37
|
+
event_type: str | None = None
|
|
38
|
+
is_cancelled: bool = False
|
|
39
|
+
is_all_day: bool = False
|
|
40
|
+
is_recurring_instance: bool = False
|
|
41
|
+
i_cal_uid: str | None = None
|
|
42
|
+
recurring_event_id: str | None = None
|
|
43
|
+
sequence: NonNegativeInt | None = None
|
|
44
|
+
original_start_date: str | None = None
|
|
45
|
+
original_start_date_time: AwareDatetime | None = None
|
|
46
|
+
original_start_time_zone: str | None = None
|
|
47
|
+
summary: str | None = None
|
|
48
|
+
description: str | None = None
|
|
49
|
+
location: str | None = None
|
|
50
|
+
color_id: str | None = None
|
|
51
|
+
html_link: str | None = None
|
|
52
|
+
hangout_link: str | None = None
|
|
53
|
+
creator_email: str | None = None
|
|
54
|
+
creator_display_name: str | None = None
|
|
55
|
+
creator_self: bool | None = None
|
|
56
|
+
organizer_email: str | None = None
|
|
57
|
+
organizer_display_name: str | None = None
|
|
58
|
+
organizer_self: bool | None = None
|
|
59
|
+
created_at: AwareDatetime | None = None
|
|
60
|
+
updated_at: AwareDatetime | None = None
|
|
61
|
+
start_date: str | None = None
|
|
62
|
+
start_date_time: AwareDatetime | None = None
|
|
63
|
+
start_time_zone: str | None = None
|
|
64
|
+
end_date: str | None = None
|
|
65
|
+
end_date_time: AwareDatetime | None = None
|
|
66
|
+
end_time_zone: str | None = None
|
|
67
|
+
end_time_unspecified: bool | None = None
|
|
68
|
+
transparency: str | None = None
|
|
69
|
+
visibility: str | None = None
|
|
70
|
+
locked: bool | None = None
|
|
71
|
+
private_copy: bool | None = None
|
|
72
|
+
attendees_omitted: bool | None = None
|
|
73
|
+
anyone_can_add_self: bool | None = None
|
|
74
|
+
guests_can_invite_others: bool | None = None
|
|
75
|
+
guests_can_modify: bool | None = None
|
|
76
|
+
guests_can_see_other_guests: bool | None = None
|
|
77
|
+
recurrence: list[str] | None = None
|
|
78
|
+
attendees: list[dict[str, Any]] | None = None
|
|
79
|
+
attachments: list[dict[str, Any]] | None = None
|
|
80
|
+
conference_data: dict[str, Any] | None = None
|
|
81
|
+
reminders: dict[str, Any] | None = None
|
|
82
|
+
source: dict[str, Any] | None = None
|
|
83
|
+
extended_properties_private: dict[str, str] | None = None
|
|
84
|
+
extended_properties_shared: dict[str, str] | None = None
|
|
85
|
+
out_of_office_properties: dict[str, Any] | None = None
|
|
86
|
+
focus_time_properties: dict[str, Any] | None = None
|
|
87
|
+
working_location_properties: dict[str, Any] | None = None
|
|
88
|
+
birthday_properties: dict[str, Any] | None = None
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import AwareDatetime, Field, field_validator
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from shared_plugins.models import IdStr, IngressModel
|
|
7
|
+
from shared_plugins.values import parse_utc_datetime_from_str
|
|
8
|
+
|
|
9
|
+
NonNegativeInt = Annotated[int, Field(ge=0, strict=True)]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CalendarDefaultReminderIngress(IngressModel):
|
|
13
|
+
method: str | None = None
|
|
14
|
+
minutes: NonNegativeInt | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CalendarNotificationIngress(IngressModel):
|
|
18
|
+
type: str | None = None
|
|
19
|
+
method: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CalendarNotificationSettingsIngress(IngressModel):
|
|
23
|
+
notifications: list[CalendarNotificationIngress] = Field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CalendarConferencePropertiesIngress(IngressModel):
|
|
27
|
+
allowed_conference_solution_types: list[str] = Field(
|
|
28
|
+
default_factory=list,
|
|
29
|
+
alias="allowedConferenceSolutionTypes",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GoogleCalendarCalendarListItemIngress(IngressModel):
|
|
34
|
+
kind: str | None = None
|
|
35
|
+
id: IdStr
|
|
36
|
+
etag: str | None = None
|
|
37
|
+
summary: str | None = None
|
|
38
|
+
summary_override: str | None = Field(default=None, alias="summaryOverride")
|
|
39
|
+
description: str | None = None
|
|
40
|
+
location: str | None = None
|
|
41
|
+
time_zone: str | None = Field(default=None, alias="timeZone")
|
|
42
|
+
access_role: str | None = Field(default=None, alias="accessRole")
|
|
43
|
+
primary: bool | None = Field(default=None, strict=True)
|
|
44
|
+
selected: bool | None = Field(default=None, strict=True)
|
|
45
|
+
hidden: bool | None = Field(default=None, strict=True)
|
|
46
|
+
deleted: bool | None = Field(default=None, strict=True)
|
|
47
|
+
auto_accept_invitations: bool | None = Field(
|
|
48
|
+
default=None, alias="autoAcceptInvitations", strict=True
|
|
49
|
+
)
|
|
50
|
+
data_owner: str | None = Field(default=None, alias="dataOwner")
|
|
51
|
+
color_id: str | None = Field(default=None, alias="colorId")
|
|
52
|
+
background_color: str | None = Field(default=None, alias="backgroundColor")
|
|
53
|
+
foreground_color: str | None = Field(default=None, alias="foregroundColor")
|
|
54
|
+
default_reminders: list[CalendarDefaultReminderIngress] = Field(
|
|
55
|
+
default_factory=list,
|
|
56
|
+
alias="defaultReminders",
|
|
57
|
+
)
|
|
58
|
+
notification_settings: CalendarNotificationSettingsIngress | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
alias="notificationSettings",
|
|
61
|
+
)
|
|
62
|
+
conference_properties: CalendarConferencePropertiesIngress | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
alias="conferenceProperties",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GoogleCalendarCalendarListResponseIngress(IngressModel):
|
|
69
|
+
kind: str | None = None
|
|
70
|
+
etag: str | None = None
|
|
71
|
+
items: list[GoogleCalendarCalendarListItemIngress] = Field(default_factory=list)
|
|
72
|
+
next_page_token: str | None = Field(default=None, alias="nextPageToken")
|
|
73
|
+
next_sync_token: str | None = Field(default=None, alias="nextSyncToken")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EventDateTimeIngress(IngressModel):
|
|
77
|
+
date: str | None = None
|
|
78
|
+
date_time: AwareDatetime | None = Field(default=None, alias="dateTime")
|
|
79
|
+
time_zone: str | None = Field(default=None, alias="timeZone")
|
|
80
|
+
|
|
81
|
+
@field_validator("date_time", mode="before")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _coerce_date_time(cls, value: object) -> object:
|
|
84
|
+
if not isinstance(value, str):
|
|
85
|
+
return value
|
|
86
|
+
return parse_utc_datetime_from_str(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EventPersonIngress(IngressModel):
|
|
90
|
+
id: str | None = None
|
|
91
|
+
email: str | None = None
|
|
92
|
+
display_name: str | None = Field(default=None, alias="displayName")
|
|
93
|
+
is_self: bool | None = Field(default=None, alias="self", strict=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class EventAttendeeIngress(EventPersonIngress):
|
|
97
|
+
organizer: bool | None = Field(default=None, strict=True)
|
|
98
|
+
resource: bool | None = Field(default=None, strict=True)
|
|
99
|
+
optional: bool | None = Field(default=None, strict=True)
|
|
100
|
+
response_status: str | None = Field(default=None, alias="responseStatus")
|
|
101
|
+
comment: str | None = None
|
|
102
|
+
additional_guests: NonNegativeInt | None = Field(
|
|
103
|
+
default=None,
|
|
104
|
+
alias="additionalGuests",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class EventAttachmentIngress(IngressModel):
|
|
109
|
+
file_url: str | None = Field(default=None, alias="fileUrl")
|
|
110
|
+
title: str | None = None
|
|
111
|
+
mime_type: str | None = Field(default=None, alias="mimeType")
|
|
112
|
+
icon_link: str | None = Field(default=None, alias="iconLink")
|
|
113
|
+
file_id: str | None = Field(default=None, alias="fileId")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class EventConferenceSolutionKeyIngress(IngressModel):
|
|
117
|
+
type: str | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class EventConferenceSolutionIngress(IngressModel):
|
|
121
|
+
key: EventConferenceSolutionKeyIngress | None = None
|
|
122
|
+
name: str | None = None
|
|
123
|
+
icon_uri: str | None = Field(default=None, alias="iconUri")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class EventConferenceEntryPointIngress(IngressModel):
|
|
127
|
+
entry_point_type: str | None = Field(default=None, alias="entryPointType")
|
|
128
|
+
uri: str | None = None
|
|
129
|
+
label: str | None = None
|
|
130
|
+
pin: str | None = None
|
|
131
|
+
access_code: str | None = Field(default=None, alias="accessCode")
|
|
132
|
+
meeting_code: str | None = Field(default=None, alias="meetingCode")
|
|
133
|
+
passcode: str | None = None
|
|
134
|
+
password: str | None = None
|
|
135
|
+
region_code: str | None = Field(default=None, alias="regionCode")
|
|
136
|
+
entry_point_features: list[str] = Field(
|
|
137
|
+
default_factory=list,
|
|
138
|
+
alias="entryPointFeatures",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class EventConferenceCreateRequestStatusIngress(IngressModel):
|
|
143
|
+
status_code: str | None = Field(default=None, alias="statusCode")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class EventConferenceCreateRequestIngress(IngressModel):
|
|
147
|
+
request_id: str | None = Field(default=None, alias="requestId")
|
|
148
|
+
conference_solution_key: EventConferenceSolutionKeyIngress | None = Field(
|
|
149
|
+
default=None,
|
|
150
|
+
alias="conferenceSolutionKey",
|
|
151
|
+
)
|
|
152
|
+
status: EventConferenceCreateRequestStatusIngress | None = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class EventConferenceParametersAddOnIngress(IngressModel):
|
|
156
|
+
parameters: dict[str, str] = Field(default_factory=dict)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class EventConferenceParametersIngress(IngressModel):
|
|
160
|
+
add_on_parameters: EventConferenceParametersAddOnIngress | None = Field(
|
|
161
|
+
default=None,
|
|
162
|
+
alias="addOnParameters",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class EventConferenceDataIngress(IngressModel):
|
|
167
|
+
conference_id: str | None = Field(default=None, alias="conferenceId")
|
|
168
|
+
conference_solution: EventConferenceSolutionIngress | None = Field(
|
|
169
|
+
default=None,
|
|
170
|
+
alias="conferenceSolution",
|
|
171
|
+
)
|
|
172
|
+
entry_points: list[EventConferenceEntryPointIngress] = Field(
|
|
173
|
+
default_factory=list,
|
|
174
|
+
alias="entryPoints",
|
|
175
|
+
)
|
|
176
|
+
notes: str | None = None
|
|
177
|
+
signature: str | None = None
|
|
178
|
+
create_request: EventConferenceCreateRequestIngress | None = Field(
|
|
179
|
+
default=None,
|
|
180
|
+
alias="createRequest",
|
|
181
|
+
)
|
|
182
|
+
parameters: EventConferenceParametersIngress | None = None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class EventGadgetIngress(IngressModel):
|
|
186
|
+
type: str | None = None
|
|
187
|
+
title: str | None = None
|
|
188
|
+
link: str | None = None
|
|
189
|
+
icon_link: str | None = Field(default=None, alias="iconLink")
|
|
190
|
+
width: NonNegativeInt | None = None
|
|
191
|
+
height: NonNegativeInt | None = None
|
|
192
|
+
display: str | None = None
|
|
193
|
+
preferences: dict[str, str] = Field(default_factory=dict)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class EventReminderOverrideIngress(IngressModel):
|
|
197
|
+
method: str | None = None
|
|
198
|
+
minutes: NonNegativeInt | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class EventRemindersIngress(IngressModel):
|
|
202
|
+
use_default: bool | None = Field(default=None, alias="useDefault", strict=True)
|
|
203
|
+
overrides: list[EventReminderOverrideIngress] = Field(default_factory=list)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class EventSourceIngress(IngressModel):
|
|
207
|
+
url: str | None = None
|
|
208
|
+
title: str | None = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class EventExtendedPropertiesIngress(IngressModel):
|
|
212
|
+
private: dict[str, str] = Field(default_factory=dict)
|
|
213
|
+
shared: dict[str, str] = Field(default_factory=dict)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class EventOutOfOfficePropertiesIngress(IngressModel):
|
|
217
|
+
auto_decline_mode: str | None = Field(default=None, alias="autoDeclineMode")
|
|
218
|
+
decline_message: str | None = Field(default=None, alias="declineMessage")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class EventFocusTimePropertiesIngress(IngressModel):
|
|
222
|
+
auto_decline_mode: str | None = Field(default=None, alias="autoDeclineMode")
|
|
223
|
+
decline_message: str | None = Field(default=None, alias="declineMessage")
|
|
224
|
+
chat_status: str | None = Field(default=None, alias="chatStatus")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class EventWorkingLocationHomeOfficeIngress(IngressModel):
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class EventWorkingLocationCustomLocationIngress(IngressModel):
|
|
232
|
+
label: str | None = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class EventWorkingLocationOfficeLocationIngress(IngressModel):
|
|
236
|
+
building_id: str | None = Field(default=None, alias="buildingId")
|
|
237
|
+
floor_id: str | None = Field(default=None, alias="floorId")
|
|
238
|
+
floor_section_id: str | None = Field(default=None, alias="floorSectionId")
|
|
239
|
+
desk_id: str | None = Field(default=None, alias="deskId")
|
|
240
|
+
label: str | None = None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class EventWorkingLocationPropertiesIngress(IngressModel):
|
|
244
|
+
type: str | None = None
|
|
245
|
+
home_office: EventWorkingLocationHomeOfficeIngress | None = Field(
|
|
246
|
+
default=None,
|
|
247
|
+
alias="homeOffice",
|
|
248
|
+
)
|
|
249
|
+
custom_location: EventWorkingLocationCustomLocationIngress | None = Field(
|
|
250
|
+
default=None,
|
|
251
|
+
alias="customLocation",
|
|
252
|
+
)
|
|
253
|
+
office_location: EventWorkingLocationOfficeLocationIngress | None = Field(
|
|
254
|
+
default=None,
|
|
255
|
+
alias="officeLocation",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class EventBirthdayPropertiesIngress(IngressModel):
|
|
260
|
+
contact: str | None = None
|
|
261
|
+
type: str | None = None
|
|
262
|
+
custom_type_name: str | None = Field(default=None, alias="customTypeName")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class GoogleCalendarEventIngress(IngressModel):
|
|
266
|
+
kind: str | None = None
|
|
267
|
+
id: IdStr
|
|
268
|
+
etag: str | None = None
|
|
269
|
+
status: str | None = None
|
|
270
|
+
html_link: str | None = Field(default=None, alias="htmlLink")
|
|
271
|
+
created: AwareDatetime | None = None
|
|
272
|
+
updated: AwareDatetime | None = None
|
|
273
|
+
summary: str | None = None
|
|
274
|
+
description: str | None = None
|
|
275
|
+
location: str | None = None
|
|
276
|
+
color_id: str | None = Field(default=None, alias="colorId")
|
|
277
|
+
creator: EventPersonIngress | None = None
|
|
278
|
+
organizer: EventPersonIngress | None = None
|
|
279
|
+
start: EventDateTimeIngress | None = None
|
|
280
|
+
end: EventDateTimeIngress | None = None
|
|
281
|
+
end_time_unspecified: bool | None = Field(
|
|
282
|
+
default=None,
|
|
283
|
+
alias="endTimeUnspecified",
|
|
284
|
+
strict=True,
|
|
285
|
+
)
|
|
286
|
+
recurring_event_id: str | None = Field(default=None, alias="recurringEventId")
|
|
287
|
+
original_start_time: EventDateTimeIngress | None = Field(
|
|
288
|
+
default=None,
|
|
289
|
+
alias="originalStartTime",
|
|
290
|
+
)
|
|
291
|
+
transparency: str | None = None
|
|
292
|
+
visibility: str | None = None
|
|
293
|
+
i_cal_uid: str | None = Field(default=None, alias="iCalUID")
|
|
294
|
+
sequence: NonNegativeInt | None = None
|
|
295
|
+
attendees: list[EventAttendeeIngress] = Field(default_factory=list)
|
|
296
|
+
attendees_omitted: bool | None = Field(
|
|
297
|
+
default=None,
|
|
298
|
+
alias="attendeesOmitted",
|
|
299
|
+
strict=True,
|
|
300
|
+
)
|
|
301
|
+
hangout_link: str | None = Field(default=None, alias="hangoutLink")
|
|
302
|
+
conference_data: EventConferenceDataIngress | None = Field(
|
|
303
|
+
default=None,
|
|
304
|
+
alias="conferenceData",
|
|
305
|
+
)
|
|
306
|
+
gadget: EventGadgetIngress | None = None
|
|
307
|
+
anyone_can_add_self: bool | None = Field(
|
|
308
|
+
default=None,
|
|
309
|
+
alias="anyoneCanAddSelf",
|
|
310
|
+
strict=True,
|
|
311
|
+
)
|
|
312
|
+
guests_can_invite_others: bool | None = Field(
|
|
313
|
+
default=None,
|
|
314
|
+
alias="guestsCanInviteOthers",
|
|
315
|
+
strict=True,
|
|
316
|
+
)
|
|
317
|
+
guests_can_modify: bool | None = Field(
|
|
318
|
+
default=None,
|
|
319
|
+
alias="guestsCanModify",
|
|
320
|
+
strict=True,
|
|
321
|
+
)
|
|
322
|
+
guests_can_see_other_guests: bool | None = Field(
|
|
323
|
+
default=None,
|
|
324
|
+
alias="guestsCanSeeOtherGuests",
|
|
325
|
+
strict=True,
|
|
326
|
+
)
|
|
327
|
+
private_copy: bool | None = Field(default=None, alias="privateCopy", strict=True)
|
|
328
|
+
locked: bool | None = Field(default=None, strict=True)
|
|
329
|
+
reminders: EventRemindersIngress | None = None
|
|
330
|
+
source: EventSourceIngress | None = None
|
|
331
|
+
attachments: list[EventAttachmentIngress] = Field(default_factory=list)
|
|
332
|
+
event_type: str | None = Field(default=None, alias="eventType")
|
|
333
|
+
out_of_office_properties: EventOutOfOfficePropertiesIngress | None = Field(
|
|
334
|
+
default=None,
|
|
335
|
+
alias="outOfOfficeProperties",
|
|
336
|
+
)
|
|
337
|
+
focus_time_properties: EventFocusTimePropertiesIngress | None = Field(
|
|
338
|
+
default=None,
|
|
339
|
+
alias="focusTimeProperties",
|
|
340
|
+
)
|
|
341
|
+
working_location_properties: EventWorkingLocationPropertiesIngress | None = Field(
|
|
342
|
+
default=None,
|
|
343
|
+
alias="workingLocationProperties",
|
|
344
|
+
)
|
|
345
|
+
birthday_properties: EventBirthdayPropertiesIngress | None = Field(
|
|
346
|
+
default=None,
|
|
347
|
+
alias="birthdayProperties",
|
|
348
|
+
)
|
|
349
|
+
recurrence: list[str] = Field(default_factory=list)
|
|
350
|
+
extended_properties: EventExtendedPropertiesIngress | None = Field(
|
|
351
|
+
default=None,
|
|
352
|
+
alias="extendedProperties",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
@field_validator("created", "updated", mode="before")
|
|
356
|
+
@classmethod
|
|
357
|
+
def _coerce_event_datetimes(cls, value: object) -> object:
|
|
358
|
+
if not isinstance(value, str):
|
|
359
|
+
return value
|
|
360
|
+
return parse_utc_datetime_from_str(value)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class GoogleCalendarEventsListResponseIngress(IngressModel):
|
|
364
|
+
kind: str | None = None
|
|
365
|
+
etag: str | None = None
|
|
366
|
+
summary: str | None = None
|
|
367
|
+
description: str | None = None
|
|
368
|
+
updated: AwareDatetime | None = None
|
|
369
|
+
time_zone: str | None = Field(default=None, alias="timeZone")
|
|
370
|
+
access_role: str | None = Field(default=None, alias="accessRole")
|
|
371
|
+
default_reminders: list[EventReminderOverrideIngress] = Field(
|
|
372
|
+
default_factory=list,
|
|
373
|
+
alias="defaultReminders",
|
|
374
|
+
)
|
|
375
|
+
items: list[GoogleCalendarEventIngress] = Field(default_factory=list)
|
|
376
|
+
next_page_token: str | None = Field(default=None, alias="nextPageToken")
|
|
377
|
+
next_sync_token: str | None = Field(default=None, alias="nextSyncToken")
|
|
378
|
+
|
|
379
|
+
@field_validator("updated", mode="before")
|
|
380
|
+
@classmethod
|
|
381
|
+
def _coerce_response_updated(cls, value: object) -> object:
|
|
382
|
+
if not isinstance(value, str):
|
|
383
|
+
return value
|
|
384
|
+
return parse_utc_datetime_from_str(value)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Iterator
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from shared_plugins.models import IngressModel
|
|
8
|
+
from shared_plugins.values import require_non_empty_text
|
|
9
|
+
|
|
10
|
+
from .ctx import CalendarRow, EventRow
|
|
11
|
+
from .ingress import (
|
|
12
|
+
EventDateTimeIngress,
|
|
13
|
+
GoogleCalendarCalendarListItemIngress,
|
|
14
|
+
GoogleCalendarEventIngress,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def utc_now_iso() -> str:
|
|
19
|
+
return datetime.now(timezone.utc).isoformat()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def calendars_to_ctx_models(
|
|
23
|
+
binding_id: str,
|
|
24
|
+
calendars: Iterable[GoogleCalendarCalendarListItemIngress],
|
|
25
|
+
) -> Iterator[CalendarRow]:
|
|
26
|
+
for calendar in calendars:
|
|
27
|
+
yield CalendarRow(
|
|
28
|
+
ctx_binding_id=binding_id,
|
|
29
|
+
id=calendar.id,
|
|
30
|
+
etag=calendar.etag,
|
|
31
|
+
summary=calendar.summary,
|
|
32
|
+
summary_override=calendar.summary_override,
|
|
33
|
+
description=calendar.description,
|
|
34
|
+
location=calendar.location,
|
|
35
|
+
time_zone=calendar.time_zone,
|
|
36
|
+
access_role=calendar.access_role,
|
|
37
|
+
primary=calendar.primary,
|
|
38
|
+
selected=calendar.selected,
|
|
39
|
+
hidden=calendar.hidden,
|
|
40
|
+
is_deleted=calendar.deleted is True,
|
|
41
|
+
auto_accept_invitations=calendar.auto_accept_invitations,
|
|
42
|
+
data_owner=calendar.data_owner,
|
|
43
|
+
color_id=calendar.color_id,
|
|
44
|
+
background_color=calendar.background_color,
|
|
45
|
+
foreground_color=calendar.foreground_color,
|
|
46
|
+
default_reminders=_dump_model_list(calendar.default_reminders),
|
|
47
|
+
notification_settings=_dump_optional_model(calendar.notification_settings),
|
|
48
|
+
conference_properties=_dump_optional_model(calendar.conference_properties),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def events_to_ctx_models(
|
|
53
|
+
binding_id: str,
|
|
54
|
+
calendar_id: str,
|
|
55
|
+
events: Iterable[GoogleCalendarEventIngress],
|
|
56
|
+
) -> Iterator[EventRow]:
|
|
57
|
+
normalized_calendar_id = require_non_empty_text(
|
|
58
|
+
calendar_id,
|
|
59
|
+
label="calendar_id",
|
|
60
|
+
context="Google Calendar payload",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
for event in events:
|
|
64
|
+
event_id = require_non_empty_text(
|
|
65
|
+
event.id,
|
|
66
|
+
label="id",
|
|
67
|
+
context="Google Calendar payload",
|
|
68
|
+
)
|
|
69
|
+
is_recurring_instance = event.recurring_event_id is not None
|
|
70
|
+
if is_recurring_instance and not _has_original_start(event.original_start_time):
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
"Google Calendar recurring instance is missing originalStartTime."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
yield EventRow(
|
|
76
|
+
ctx_binding_id=binding_id,
|
|
77
|
+
ctx_source_updated_at=event.updated,
|
|
78
|
+
calendar_id=normalized_calendar_id,
|
|
79
|
+
id=event_id,
|
|
80
|
+
etag=event.etag,
|
|
81
|
+
status=event.status,
|
|
82
|
+
event_type=event.event_type,
|
|
83
|
+
is_cancelled=event.status == "cancelled",
|
|
84
|
+
is_all_day=event.start is not None and event.start.date is not None,
|
|
85
|
+
is_recurring_instance=is_recurring_instance,
|
|
86
|
+
i_cal_uid=event.i_cal_uid,
|
|
87
|
+
recurring_event_id=event.recurring_event_id,
|
|
88
|
+
sequence=event.sequence,
|
|
89
|
+
original_start_date=(
|
|
90
|
+
event.original_start_time.date if event.original_start_time else None
|
|
91
|
+
),
|
|
92
|
+
original_start_date_time=(
|
|
93
|
+
event.original_start_time.date_time
|
|
94
|
+
if event.original_start_time
|
|
95
|
+
else None
|
|
96
|
+
),
|
|
97
|
+
original_start_time_zone=(
|
|
98
|
+
event.original_start_time.time_zone
|
|
99
|
+
if event.original_start_time
|
|
100
|
+
else None
|
|
101
|
+
),
|
|
102
|
+
summary=event.summary,
|
|
103
|
+
description=event.description,
|
|
104
|
+
location=event.location,
|
|
105
|
+
color_id=event.color_id,
|
|
106
|
+
html_link=event.html_link,
|
|
107
|
+
hangout_link=event.hangout_link,
|
|
108
|
+
creator_email=event.creator.email if event.creator else None,
|
|
109
|
+
creator_display_name=event.creator.display_name if event.creator else None,
|
|
110
|
+
creator_self=event.creator.is_self if event.creator else None,
|
|
111
|
+
organizer_email=event.organizer.email if event.organizer else None,
|
|
112
|
+
organizer_display_name=(
|
|
113
|
+
event.organizer.display_name if event.organizer else None
|
|
114
|
+
),
|
|
115
|
+
organizer_self=event.organizer.is_self if event.organizer else None,
|
|
116
|
+
created_at=event.created,
|
|
117
|
+
updated_at=event.updated,
|
|
118
|
+
start_date=event.start.date if event.start else None,
|
|
119
|
+
start_date_time=event.start.date_time if event.start else None,
|
|
120
|
+
start_time_zone=event.start.time_zone if event.start else None,
|
|
121
|
+
end_date=event.end.date if event.end else None,
|
|
122
|
+
end_date_time=event.end.date_time if event.end else None,
|
|
123
|
+
end_time_zone=event.end.time_zone if event.end else None,
|
|
124
|
+
end_time_unspecified=event.end_time_unspecified,
|
|
125
|
+
transparency=event.transparency,
|
|
126
|
+
visibility=event.visibility,
|
|
127
|
+
locked=event.locked,
|
|
128
|
+
private_copy=event.private_copy,
|
|
129
|
+
attendees_omitted=event.attendees_omitted,
|
|
130
|
+
anyone_can_add_self=event.anyone_can_add_self,
|
|
131
|
+
guests_can_invite_others=event.guests_can_invite_others,
|
|
132
|
+
guests_can_modify=event.guests_can_modify,
|
|
133
|
+
guests_can_see_other_guests=event.guests_can_see_other_guests,
|
|
134
|
+
recurrence=list(event.recurrence) or None,
|
|
135
|
+
attendees=_dump_model_list(event.attendees),
|
|
136
|
+
attachments=_dump_model_list(event.attachments),
|
|
137
|
+
conference_data=_dump_optional_model(event.conference_data),
|
|
138
|
+
reminders=_dump_optional_model(event.reminders),
|
|
139
|
+
source=_dump_optional_model(event.source),
|
|
140
|
+
extended_properties_private=(
|
|
141
|
+
dict(event.extended_properties.private)
|
|
142
|
+
if event.extended_properties and event.extended_properties.private
|
|
143
|
+
else None
|
|
144
|
+
),
|
|
145
|
+
extended_properties_shared=(
|
|
146
|
+
dict(event.extended_properties.shared)
|
|
147
|
+
if event.extended_properties and event.extended_properties.shared
|
|
148
|
+
else None
|
|
149
|
+
),
|
|
150
|
+
out_of_office_properties=_dump_optional_model(
|
|
151
|
+
event.out_of_office_properties
|
|
152
|
+
),
|
|
153
|
+
focus_time_properties=_dump_optional_model(event.focus_time_properties),
|
|
154
|
+
working_location_properties=_dump_optional_model(
|
|
155
|
+
event.working_location_properties
|
|
156
|
+
),
|
|
157
|
+
birthday_properties=_dump_optional_model(event.birthday_properties),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _dump_optional_model(model: IngressModel | None) -> dict[str, Any] | None:
|
|
162
|
+
if model is None:
|
|
163
|
+
return None
|
|
164
|
+
dumped = model.model_dump(by_alias=True, exclude_none=True)
|
|
165
|
+
return dumped or None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _dump_model_list(models: Iterable[IngressModel]) -> list[dict[str, Any]] | None:
|
|
169
|
+
dumped = [model.model_dump(by_alias=True, exclude_none=True) for model in models]
|
|
170
|
+
return dumped or None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _has_original_start(value: EventDateTimeIngress | None) -> bool:
|
|
174
|
+
if value is None:
|
|
175
|
+
return False
|
|
176
|
+
return value.date is not None or value.date_time is not None
|
|
File without changes
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import dlt
|
|
9
|
+
from shared_plugins.exceptions import PluginCursorExpiredError
|
|
10
|
+
from shared_plugins.google_client.http_errors import extract_http_status_code
|
|
11
|
+
from shared_plugins.naming import (
|
|
12
|
+
dlt_resource_name,
|
|
13
|
+
dlt_source_name,
|
|
14
|
+
plugin_id_from_module,
|
|
15
|
+
)
|
|
16
|
+
from shared_plugins.resources import ctx_dlt_resource
|
|
17
|
+
from shared_plugins.values import coerce_non_empty_string_mapping
|
|
18
|
+
|
|
19
|
+
from ..models.ctx import CalendarRow, EventRow
|
|
20
|
+
from ..models.translators import (
|
|
21
|
+
calendars_to_ctx_models,
|
|
22
|
+
events_to_ctx_models,
|
|
23
|
+
utc_now_iso,
|
|
24
|
+
)
|
|
25
|
+
from ..utils.client import GoogleCalendarApiClient
|
|
26
|
+
|
|
27
|
+
ACTIVE_CALENDAR_IDS_KEY = "active_calendar_ids"
|
|
28
|
+
EVENTS_SYNC_TOKENS_KEY = "events_sync_tokens"
|
|
29
|
+
EVENTS_LAST_SYNCED_AT_KEY = "events_last_synced_at"
|
|
30
|
+
PLUGIN_ID = plugin_id_from_module(__file__)
|
|
31
|
+
JOB = "sync"
|
|
32
|
+
FORCE_FULL_SYNC = False
|
|
33
|
+
HARD_DELETE_ON_CANCELLED_EVENT = True
|
|
34
|
+
LOGGER = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def prune_events_sync_tokens(
|
|
38
|
+
active_calendar_ids: Iterable[str],
|
|
39
|
+
events_sync_tokens: Mapping[str, str],
|
|
40
|
+
) -> dict[str, str]:
|
|
41
|
+
active_set = {
|
|
42
|
+
calendar_id.strip()
|
|
43
|
+
for calendar_id in active_calendar_ids
|
|
44
|
+
if calendar_id.strip()
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
calendar_id: token
|
|
48
|
+
for calendar_id, token in events_sync_tokens.items()
|
|
49
|
+
if calendar_id in active_set and token
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def collect_calendar_snapshot(
|
|
54
|
+
*,
|
|
55
|
+
binding_id: str,
|
|
56
|
+
client: GoogleCalendarApiClient,
|
|
57
|
+
) -> tuple[list[CalendarRow], list[str]]:
|
|
58
|
+
rows: list[CalendarRow] = []
|
|
59
|
+
active_calendar_ids: set[str] = set()
|
|
60
|
+
page_count = 0
|
|
61
|
+
|
|
62
|
+
for page in client.iter_calendar_list_pages():
|
|
63
|
+
page_count += 1
|
|
64
|
+
for row in calendars_to_ctx_models(binding_id, page.items):
|
|
65
|
+
rows.append(row)
|
|
66
|
+
|
|
67
|
+
if not row.is_deleted:
|
|
68
|
+
active_calendar_ids.add(row.id)
|
|
69
|
+
|
|
70
|
+
sorted_active = sorted(active_calendar_ids)
|
|
71
|
+
LOGGER.info(
|
|
72
|
+
"google_calendar.calendars.snapshot binding_id=%s total=%d active=%d deleted=%d pages=%d",
|
|
73
|
+
binding_id,
|
|
74
|
+
len(rows),
|
|
75
|
+
len(sorted_active),
|
|
76
|
+
len(rows) - len(sorted_active),
|
|
77
|
+
page_count,
|
|
78
|
+
)
|
|
79
|
+
return rows, sorted_active
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def iter_events_for_active_calendars(
|
|
83
|
+
*,
|
|
84
|
+
binding_id: str,
|
|
85
|
+
client: GoogleCalendarApiClient,
|
|
86
|
+
active_calendar_ids: Iterable[str],
|
|
87
|
+
events_sync_tokens: Mapping[str, str],
|
|
88
|
+
next_sync_tokens: dict[str, str],
|
|
89
|
+
) -> Iterable[EventRow]:
|
|
90
|
+
sorted_ids = sorted(
|
|
91
|
+
{
|
|
92
|
+
calendar_id.strip()
|
|
93
|
+
for calendar_id in active_calendar_ids
|
|
94
|
+
if calendar_id.strip()
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
total_events = 0
|
|
98
|
+
calendars_synced = 0
|
|
99
|
+
calendars_skipped = 0
|
|
100
|
+
t0 = time.monotonic()
|
|
101
|
+
|
|
102
|
+
for calendar_idx, calendar_id in enumerate(sorted_ids, 1):
|
|
103
|
+
initial_sync_token = events_sync_tokens.get(calendar_id)
|
|
104
|
+
# Attempt plan per calendar:
|
|
105
|
+
# - existing token: incremental first, then full-sync fallback on stale cursor
|
|
106
|
+
# - no token: full sync only
|
|
107
|
+
attempt_tokens: tuple[str | None, ...] = (
|
|
108
|
+
(initial_sync_token, None) if initial_sync_token else (None,)
|
|
109
|
+
)
|
|
110
|
+
latest_sync_token = initial_sync_token
|
|
111
|
+
fell_back_from_stale_cursor = False
|
|
112
|
+
calendar_event_count = 0
|
|
113
|
+
|
|
114
|
+
sync_mode = "incremental" if initial_sync_token else "full"
|
|
115
|
+
LOGGER.info(
|
|
116
|
+
"google_calendar.events.calendar_start binding_id=%s calendar=%d/%d calendar_id=%s mode=%s",
|
|
117
|
+
binding_id,
|
|
118
|
+
calendar_idx,
|
|
119
|
+
len(sorted_ids),
|
|
120
|
+
calendar_id,
|
|
121
|
+
sync_mode,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
for attempt_sync_token in attempt_tokens:
|
|
126
|
+
latest_sync_token = attempt_sync_token
|
|
127
|
+
try:
|
|
128
|
+
for page in client.iter_event_pages(
|
|
129
|
+
calendar_id, sync_token=attempt_sync_token
|
|
130
|
+
):
|
|
131
|
+
if page.next_sync_token:
|
|
132
|
+
latest_sync_token = page.next_sync_token
|
|
133
|
+
|
|
134
|
+
for row in events_to_ctx_models(
|
|
135
|
+
binding_id, calendar_id, page.items
|
|
136
|
+
):
|
|
137
|
+
calendar_event_count += 1
|
|
138
|
+
total_events += 1
|
|
139
|
+
yield row
|
|
140
|
+
break
|
|
141
|
+
except PluginCursorExpiredError:
|
|
142
|
+
if attempt_sync_token is None:
|
|
143
|
+
raise
|
|
144
|
+
LOGGER.info(
|
|
145
|
+
"google_calendar.events.cursor_expired binding_id=%s calendar_id=%s fallback=full",
|
|
146
|
+
binding_id,
|
|
147
|
+
calendar_id,
|
|
148
|
+
)
|
|
149
|
+
fell_back_from_stale_cursor = True
|
|
150
|
+
continue
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
status_code = extract_http_status_code(exc)
|
|
153
|
+
LOGGER.warning(
|
|
154
|
+
"google_calendar.events.skip_calendar binding_id=%s calendar_id=%s status_code=%s reason=%s",
|
|
155
|
+
binding_id,
|
|
156
|
+
calendar_id,
|
|
157
|
+
status_code if status_code is not None else "-",
|
|
158
|
+
exc,
|
|
159
|
+
)
|
|
160
|
+
if initial_sync_token and not fell_back_from_stale_cursor:
|
|
161
|
+
next_sync_tokens[calendar_id] = initial_sync_token
|
|
162
|
+
calendars_skipped += 1
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
calendars_synced += 1
|
|
166
|
+
LOGGER.info(
|
|
167
|
+
"google_calendar.events.calendar_done binding_id=%s calendar_id=%s events=%d",
|
|
168
|
+
binding_id,
|
|
169
|
+
calendar_id,
|
|
170
|
+
calendar_event_count,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if latest_sync_token:
|
|
174
|
+
next_sync_tokens[calendar_id] = latest_sync_token
|
|
175
|
+
|
|
176
|
+
elapsed = time.monotonic() - t0
|
|
177
|
+
LOGGER.info(
|
|
178
|
+
"google_calendar.events.complete binding_id=%s calendars_synced=%d calendars_skipped=%d total_events=%d elapsed=%.1fs",
|
|
179
|
+
binding_id,
|
|
180
|
+
calendars_synced,
|
|
181
|
+
calendars_skipped,
|
|
182
|
+
total_events,
|
|
183
|
+
elapsed,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dlt.source(name=dlt_source_name(PLUGIN_ID, JOB))
|
|
188
|
+
def google_calendar_sync_source(
|
|
189
|
+
binding_id: str,
|
|
190
|
+
*,
|
|
191
|
+
client: GoogleCalendarApiClient,
|
|
192
|
+
) -> tuple[Any, ...]:
|
|
193
|
+
calendars_cache: list[CalendarRow] | None = None
|
|
194
|
+
active_calendar_ids_cache: list[str] | None = None
|
|
195
|
+
|
|
196
|
+
def get_calendar_snapshot() -> tuple[list[CalendarRow], list[str]]:
|
|
197
|
+
nonlocal calendars_cache, active_calendar_ids_cache
|
|
198
|
+
if calendars_cache is None or active_calendar_ids_cache is None:
|
|
199
|
+
calendars_cache, active_calendar_ids_cache = collect_calendar_snapshot(
|
|
200
|
+
binding_id=binding_id,
|
|
201
|
+
client=client,
|
|
202
|
+
)
|
|
203
|
+
return calendars_cache, active_calendar_ids_cache
|
|
204
|
+
|
|
205
|
+
@ctx_dlt_resource(
|
|
206
|
+
name=dlt_resource_name("calendars"),
|
|
207
|
+
write_disposition="merge",
|
|
208
|
+
primary_key=("_ctx_binding_id", "id"),
|
|
209
|
+
)
|
|
210
|
+
def calendars_resource() -> Iterable[CalendarRow]:
|
|
211
|
+
rows, _ = get_calendar_snapshot()
|
|
212
|
+
for row in rows:
|
|
213
|
+
yield row
|
|
214
|
+
|
|
215
|
+
@ctx_dlt_resource(
|
|
216
|
+
name=dlt_resource_name("events"),
|
|
217
|
+
write_disposition="merge",
|
|
218
|
+
primary_key=("_ctx_binding_id", "calendar_id", "id"),
|
|
219
|
+
columns={"is_cancelled": {"hard_delete": HARD_DELETE_ON_CANCELLED_EVENT}},
|
|
220
|
+
)
|
|
221
|
+
def events_resource() -> Iterable[EventRow]:
|
|
222
|
+
source_state = dlt.current.source_state()
|
|
223
|
+
_, active_calendar_ids = get_calendar_snapshot()
|
|
224
|
+
|
|
225
|
+
if FORCE_FULL_SYNC:
|
|
226
|
+
sync_tokens_for_run: dict[str, str] = {}
|
|
227
|
+
else:
|
|
228
|
+
existing_sync_tokens = coerce_non_empty_string_mapping(
|
|
229
|
+
source_state.get(EVENTS_SYNC_TOKENS_KEY)
|
|
230
|
+
)
|
|
231
|
+
sync_tokens_for_run = prune_events_sync_tokens(
|
|
232
|
+
active_calendar_ids, existing_sync_tokens
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
next_sync_tokens: dict[str, str] = {}
|
|
236
|
+
yield from iter_events_for_active_calendars(
|
|
237
|
+
binding_id=binding_id,
|
|
238
|
+
client=client,
|
|
239
|
+
active_calendar_ids=active_calendar_ids,
|
|
240
|
+
events_sync_tokens=sync_tokens_for_run,
|
|
241
|
+
next_sync_tokens=next_sync_tokens,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
source_state[ACTIVE_CALENDAR_IDS_KEY] = active_calendar_ids
|
|
245
|
+
source_state[EVENTS_SYNC_TOKENS_KEY] = next_sync_tokens
|
|
246
|
+
source_state[EVENTS_LAST_SYNCED_AT_KEY] = utc_now_iso()
|
|
247
|
+
|
|
248
|
+
return (calendars_resource, events_resource)
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any, Iterator, TypeVar
|
|
5
|
+
|
|
6
|
+
from googleapiclient.errors import HttpError
|
|
7
|
+
|
|
8
|
+
from shared_plugins.exceptions import PluginConfigurationError, PluginCursorExpiredError
|
|
9
|
+
from shared_plugins.google_client.http_errors import extract_http_status_code
|
|
10
|
+
|
|
11
|
+
from shared_plugins.models import IngressModel
|
|
12
|
+
|
|
13
|
+
from ..models.ingress import (
|
|
14
|
+
GoogleCalendarCalendarListResponseIngress,
|
|
15
|
+
GoogleCalendarEventsListResponseIngress,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
ModelT = TypeVar("ModelT", bound=IngressModel)
|
|
19
|
+
CALENDAR_LIST_MAX_RESULTS = 250
|
|
20
|
+
EVENTS_LIST_MAX_RESULTS = 2500
|
|
21
|
+
API_NUM_RETRIES = 5
|
|
22
|
+
INCLUDE_HIDDEN_CALENDARS = False
|
|
23
|
+
MIN_ACCESS_ROLE = "reader"
|
|
24
|
+
SINGLE_EVENTS = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GoogleCalendarApiClient:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
service: Any,
|
|
32
|
+
) -> None:
|
|
33
|
+
if service is None:
|
|
34
|
+
raise PluginConfigurationError(
|
|
35
|
+
"No Google Calendar service provided. Pass an authenticated service via service=..."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
self._service = service
|
|
39
|
+
|
|
40
|
+
def _execute(self, request: Any) -> dict[str, Any]:
|
|
41
|
+
payload = request.execute(num_retries=API_NUM_RETRIES)
|
|
42
|
+
if not isinstance(payload, Mapping):
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
"Google Calendar API returned a non-object response payload."
|
|
45
|
+
)
|
|
46
|
+
return dict(payload)
|
|
47
|
+
|
|
48
|
+
def _execute_model(self, request: Any, model_type: type[ModelT]) -> ModelT:
|
|
49
|
+
payload = self._execute(request)
|
|
50
|
+
return model_type.model_validate(payload)
|
|
51
|
+
|
|
52
|
+
def iter_calendar_list_pages(
|
|
53
|
+
self,
|
|
54
|
+
) -> Iterator[GoogleCalendarCalendarListResponseIngress]:
|
|
55
|
+
page_token: str | None = None
|
|
56
|
+
|
|
57
|
+
while True:
|
|
58
|
+
kwargs: dict[str, Any] = {
|
|
59
|
+
"maxResults": CALENDAR_LIST_MAX_RESULTS,
|
|
60
|
+
"showDeleted": True,
|
|
61
|
+
"showHidden": INCLUDE_HIDDEN_CALENDARS,
|
|
62
|
+
"minAccessRole": MIN_ACCESS_ROLE,
|
|
63
|
+
}
|
|
64
|
+
if page_token:
|
|
65
|
+
kwargs["pageToken"] = page_token
|
|
66
|
+
|
|
67
|
+
request = self._service.calendarList().list(**kwargs)
|
|
68
|
+
payload = self._execute_model(
|
|
69
|
+
request,
|
|
70
|
+
GoogleCalendarCalendarListResponseIngress,
|
|
71
|
+
)
|
|
72
|
+
yield payload
|
|
73
|
+
|
|
74
|
+
if not payload.next_page_token:
|
|
75
|
+
break
|
|
76
|
+
page_token = payload.next_page_token
|
|
77
|
+
|
|
78
|
+
def iter_event_pages(
|
|
79
|
+
self,
|
|
80
|
+
calendar_id: str,
|
|
81
|
+
*,
|
|
82
|
+
sync_token: str | None = None,
|
|
83
|
+
) -> Iterator[GoogleCalendarEventsListResponseIngress]:
|
|
84
|
+
page_token: str | None = None
|
|
85
|
+
|
|
86
|
+
while True:
|
|
87
|
+
kwargs: dict[str, Any] = {
|
|
88
|
+
"calendarId": calendar_id,
|
|
89
|
+
"maxResults": EVENTS_LIST_MAX_RESULTS,
|
|
90
|
+
"showDeleted": True,
|
|
91
|
+
"singleEvents": SINGLE_EVENTS,
|
|
92
|
+
}
|
|
93
|
+
if sync_token:
|
|
94
|
+
kwargs["syncToken"] = sync_token
|
|
95
|
+
if page_token:
|
|
96
|
+
kwargs["pageToken"] = page_token
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
request = self._service.events().list(**kwargs)
|
|
100
|
+
raw = self._execute(request)
|
|
101
|
+
payload = GoogleCalendarEventsListResponseIngress.model_validate(raw)
|
|
102
|
+
except HttpError as exc:
|
|
103
|
+
if extract_http_status_code(exc) == 410:
|
|
104
|
+
raise PluginCursorExpiredError(
|
|
105
|
+
"Google Calendar sync token is stale. Clear per-calendar token and rerun full sync."
|
|
106
|
+
) from exc
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
yield payload
|
|
110
|
+
|
|
111
|
+
if not payload.next_page_token:
|
|
112
|
+
break
|
|
113
|
+
page_token = payload.next_page_token
|