diff --git a/README.rst b/README.rst index 5d5b98fe..ed994396 100644 --- a/README.rst +++ b/README.rst @@ -199,30 +199,87 @@ Lost mode is slightly different to the "Play Sound" functionality in that it all Calendar ======== -The calendar webservice currently only supports fetching events. +The calendar webservice now supports fethcing, creating, and removing calendars and events. -Events +Calendars ****** +The calendar functionality is based around the CalendarObject dataclass. Every variable has a default value named according to the http payload parameters from the icloud API. The ``guid`` is a uuid4 identifier unique to each calendar. The class will create one automatically if it is left blank when the CalendarObject is instanced. the ``guid`` parameter should only be set when you know the guid of an existing calendar. The color is an rgb hex value and will be a random color if not set. + +Functions +------ +| **get_calendars(as_objs:bool=False) -> list** +| *returns a list of the user's calendars* +| if ``as_objs`` is set to ``True``, the returned list will be of CalendarObjects; else it will be of dictionaries. +| +| **add_calendar(calendar:CalendarObject) -> None:** +| *adds a calendar to the users apple calendar* +| +| **remove_calendar(cal_guid:str) -> None** +| *Removes a Calendar from the apple calendar given the provided guid* + +Examples +------ +*Create and add a new calendar:* + +.. code-block:: python -Returns this month's events: + api = login("username", "pass") + calendar_service = api.calendar + cal = calendar_service.CalendarObject(title="My Calendar", shareType="published") + cal.color = "#FF0000" + calendar_service.add_calendar(cal) + .. code-block:: python - api.calendar.events() +*Remove an existing calendar:* -Or, between a specific date range: +.. code-block:: python + cal = calendar_service.get_calendars(as_objs=True)[1] + calendar_service.remove_calendar(cal.guid) + .. code-block:: python - from_dt = datetime(2012, 1, 1) - to_dt = datetime(2012, 1, 31) - api.calendar.events(from_dt, to_dt) +Events +****** +The events functionality is based around the EventObject dataclass. ``guid`` is the unique identifier of each event, while ``pGuid`` is the identifier of the calendar to which this event belongs. ``pGuid`` is the only paramter that is not optional. Some of the functionality of Events, most notably Alarms, is not included here, but could be easily done had you the desire. The EventObject currently has one method you may use: ``add_invitees`` which takes a list of emails and adds them as invitees to this event. They should recieve an email when this event is created. + +Functions +------ +| **get_events(from_dt:datetime=None, to_dt:datetime=None, period:str="month", as_objs:bool=False)** +| *Returns a list of events from ``from_dt`` to ``to_dt``. If ``period`' is provided, it will return the events in that period refrencing ``from_dt`` if it was provided; else using today's date. IE if ``period`` is "month", the events for the entire month that ``from_dt`` falls within will be returned.* + +| **get_event_detail(pguid, guid, as_obj:bool=False)** +| *Returns a speciffic event given that event's ``guid`` and ``pGuid``* + +| **add_event(event:EventObject) -> None** +| *Adds an Event to a calendar specified by the event's ``pGuid``.* -Alternatively, you may fetch a single event's details, like so: +| **remove_event(event:EventObject) -> None** +| *Removes an Event from a calendar specified by the event's ``pGuid``.* + +Examples +------ +*Create, add, and remove an Event* + +.. code-block:: python + + calendar_service = api.calendar + cal = calendar_service.get_calendars(as_objs=True)[0] + event = calendar_service.EventObject("test", pGuid=cal.guid, startDate=datetime.today(), endDate=datetime.today() + timedelta(hours=1)) + calendar_service.add_event(event) + calendar_service.remove_event(event) + +.. code-block:: python + +*Get next weeks' events* .. code-block:: python - api.calendar.get_event_detail('CALENDAR', 'EVENT_ID') + calendar_service.get_events(from_dt=datetime.today() + timedelta(days=7) ,period="week", as_objs=True) + +.. code-block:: python Contacts diff --git a/pyicloud/base.py b/pyicloud/base.py index 6ac8bdbd..a7b769a5 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -129,7 +129,7 @@ def request(self, method, url, **kwargs): # pylint: disable=arguments-differ request_logger.debug(api_error) kwargs["retried"] = True return self.request(method, url, **kwargs) - + self._raise_error(response.status_code, response.reason) if content_type not in json_mimetypes: @@ -189,7 +189,7 @@ def _raise_error(self, code, reason): raise api_error -class PyiCloudService: +class PyiCloudService(object): """ A base authentication class for the iCloud service. Handles the authentication required to access iCloud services. @@ -334,6 +334,10 @@ def authenticate(self, force_refresh=False, service=None): self._authenticate_with_token() + self.params.update({ + "dsid" : self.data.get("dsInfo").get("dsid") + }) + self._webservices = self.data["webservices"] LOGGER.debug("Authentication completed successfully") @@ -602,7 +606,7 @@ def drive(self): return self._drive def __str__(self): - return f"iCloud API: {self.user.get('apple_id')}" + return f"iCloud API: {self.user.get('accountName')}" def __repr__(self): return f"<{self}>" diff --git a/pyicloud/services/calendar.py b/pyicloud/services/calendar.py index 1c7687fc..cac563cf 100644 --- a/pyicloud/services/calendar.py +++ b/pyicloud/services/calendar.py @@ -1,8 +1,12 @@ """Calendar service.""" -from datetime import datetime +from dataclasses import dataclass, asdict, field +from tzlocal import get_localzone_name from calendar import monthrange +from datetime import datetime, timedelta +from random import randint +from uuid import uuid4 +import json -from tzlocal import get_localzone_name class CalendarService: @@ -17,21 +21,39 @@ def __init__(self, service_root, session, params): self._calendar_endpoint = "%s/ca" % self._service_root self._calendar_refresh_url = "%s/events" % self._calendar_endpoint self._calendar_event_detail_url = f"{self._calendar_endpoint}/eventdetail" - self._calendars = "%s/startup" % self._calendar_endpoint + self._calendar_collections_url = f"{self._calendar_endpoint}/collections" + self._calendars_url = "%s/startup" % self._calendar_endpoint self.response = {} - def get_event_detail(self, pguid, guid): - """ - Fetches a single event's details by specifying a pguid - (a calendar) and a guid (an event's ID). - """ + @property + def default_params(self) -> dict: + today = datetime.today() + first_day, last_day = monthrange(today.year, today.month) + from_dt = datetime(today.year, today.month, first_day) + to_dt = datetime(today.year, today.month, last_day) params = dict(self.params) - params.update({"lang": "en-us", "usertz": get_localzone_name()}) - url = f"{self._calendar_event_detail_url}/{pguid}/{guid}" - req = self.session.get(url, params=params) - self.response = req.json() - return self.response["Event"][0] + params.update( + { + "lang": "en-us", + "usertz": get_localzone_name(), + "startDate": from_dt.strftime("%Y-%m-%d"), + "endDate": to_dt.strftime("%Y-%m-%d"), + } + ) + + return params + + def obj_from_dict(self, obj, _dict) -> object: + for key, value in _dict.items(): + setattr(obj, key, value) + + return obj + + def get_ctag(self, guid) -> str: + for cal in self.get_calendars(as_objs=False): + if cal.get("guid") == guid: + return cal.get("ctag") def refresh_client(self, from_dt=None, to_dt=None): """ @@ -57,30 +79,271 @@ def refresh_client(self, from_dt=None, to_dt=None): req = self.session.get(self._calendar_refresh_url, params=params) self.response = req.json() - def events(self, from_dt=None, to_dt=None): + @dataclass + class CalendarObject: + title: str = "Untitled" + guid: str = "" + shareType: str = None # can be (None, 'published', 'shared') where 'published' gens a public caldav link in the response. Shared is not supported here as it is rather complex. + symbolicColor: str = "__custom__" + supportedType: str = "Event" + objectType: str = "personal" + shareTitle: str = "" + sharedUrl: str = "" + color: str = "" + order: int = 7 + extendedDetailsAreIncluded: bool = True + readOnly: bool = False + enabled: bool = True + ignoreEventUpdates = None + emailNotification = None + lastModifiedDate = None + meAsParticipant = None + prePublishedUrl = None + participants = None + deferLoading = None + publishedUrl = None + removeAlarms = None + ignoreAlarms = None + description = None + removeTodos = None + isDefault = None + isFamily = None + etag = None + ctag = None + + def __post_init__(self) -> None: + if not self.guid: + self.guid = str(uuid4()).upper() + + if not self.color: + self.color = self.gen_random_color() + + def gen_random_color(self) -> str: + """ + Creates a random rgbhex color. + """ + return "#%02x%02x%02x" % tuple([randint(0,255) for _ in range(3)]) + + @property + def request_data(self) -> dict: + data = { + "Collection": asdict(self), + "ClientState": { + "Collection": [], + "fullState": False, + "userTime": 1234567890, + "alarmRange": 60 + } + } + return data + + def get_calendars(self, as_objs:bool=False) -> list: + """ + Retrieves calendars of this month. + """ + params = self.default_params + req = self.session.get(self._calendars_url, params=params) + self.response = req.json() + calendars = self.response["Collection"] + + if as_objs and calendars: + for idx, cal in enumerate(calendars): + calendars[idx] = self.obj_from_dict(self.CalendarObject(), cal) + + return calendars + + def add_calendar(self, calendar:CalendarObject) -> None: + """ + Adds a Calendar to the apple calendar. + """ + data = calendar.request_data + params = self.default_params + + req = self.session.post(self._calendar_collections_url + f"/{calendar.guid}", + params=params, data=json.dumps(data)) + self.response = req.json() + + def remove_calendar(self, cal_guid:str) -> None: + """ + Removes a Calendar from the apple calendar. + """ + params = self.default_params + params["methodOverride"] = "DELETE" + + req = self.session.post(self._calendar_collections_url + f"/{cal_guid}", + params=params, data=json.dumps({})) + self.response = req.json() + + @dataclass + class EventObject: + pGuid: str + title: str = "New Event" + startDate: datetime = datetime.today() + endDate: datetime = datetime.today() + timedelta(minutes=60) + localStartDate = None + localEndDate = None + duration: int = field(init=False) + icon:int = 0 + changeRecurring: str = None + tz: str = "US/Pacific" + guid: str = "" # event identifier + location: str = "" + extendedDetailsAreIncluded: bool = True + recurrenceException: bool = False + recurrenceMaster: bool = False + hasAttachments: bool = False + allDay: bool = False + isJunk: bool = False + + invitees:list[str] = field(init=False, default_factory=list) + + def __post_init__(self) -> None: + if not self.localStartDate: + self.localStartDate = self.startDate + if not self.localEndDate: + self.localEndDate = self.endDate + + if not self.guid: + self.guid = str(uuid4()).upper() + + self.duration = int((self.endDate.timestamp() - self.startDate.timestamp()) / 60) + + @property + def request_data(self) -> dict: + event_dict = asdict(self) + event_dict["startDate"] = self.dt_to_list(self.startDate) + event_dict["endDate"] = self.dt_to_list(self.endDate, False) + event_dict["localStartDate"] = self.dt_to_list(self.localStartDate) + event_dict["localEndDate"] = self.dt_to_list(self.localEndDate, False) + + data = { + "Event" : event_dict, + "ClientState": { + "Collection": [{ + "guid": self.guid, + "ctag": None + }], + "fullState": False, + "userTime": 1234567890, + "alarmRange": 60 + } + } + + if self.invitees: + data["Invitee"] = [ + { + "guid": email_guid, + "pGuid": self.pGuid, + "role": "REQ-PARTICIPANT", + "isOrganizer": False, + "email": email_guid.split(":")[-1], + "inviteeStatus": "NEEDS-ACTION", + "commonName": "", + "isMyId": False + } + for email_guid in self.invitees + ] + + return data + + def dt_to_list(self, dt:datetime, start:bool=True) -> list: + """ + Converts python datetime object into a list format used + by Apple's calendar. + """ + if start: + minutes = dt.hour * 60 + dt.minute + else: + minutes = (24 - dt.hour) * 60 + (60 - dt.minute) + + return [dt.strftime("%Y%m%d"), dt.year, dt.month, dt.day, dt.hour, dt.minute, minutes] + + def add_invitees(self, _invitees:list=[]) -> None: + """ + Adds a list of emails to invitees in the correct format + """ + self.invitees += ["{}:{}".format(self.guid, email) for email in _invitees] + + def get(self, var:str): + return getattr(self, var, None) + + def get_events(self, from_dt:datetime=None, to_dt:datetime=None, period:str="month", as_objs:bool=False) -> list: """ Retrieves events for a given date range, by default, this month. """ + if period != "month": + if from_dt: + today = datetime(from_dt.year, from_dt.month, from_dt.day) + else: + today = datetime.today() + + if period == "day": + if not from_dt: + from_dt = datetime(today.year, today.month, today.day) + to_dt = from_dt + timedelta(days=1) + elif period == "week": + if not from_dt: + from_dt = datetime(today.year, today.month, today.day) - timedelta(days=today.weekday() + 1 ) + to_dt = from_dt + timedelta(days=6) + + self.refresh_client(from_dt, to_dt) - return self.response.get("Event") + events = self.response.get("Event") + + if as_objs and events: + for idx, event in enumerate(events): + events[idx] = self.obj_from_dict(self.EventObject(""), event) - def calendars(self): + return events + + def get_event_detail(self, pguid, guid, as_obj:bool=False): """ - Retrieves calendars of this month. + Fetches a single event's details by specifying a pguid + (a calendar) and a guid (an event's ID). """ - today = datetime.today() - first_day, last_day = monthrange(today.year, today.month) - from_dt = datetime(today.year, today.month, first_day) - to_dt = datetime(today.year, today.month, last_day) params = dict(self.params) - params.update( - { - "lang": "en-us", - "usertz": get_localzone_name(), - "startDate": from_dt.strftime("%Y-%m-%d"), - "endDate": to_dt.strftime("%Y-%m-%d"), - } - ) - req = self.session.get(self._calendars, params=params) + params.update({"lang": "en-us", "usertz": get_localzone_name()}) + url = f"{self._calendar_event_detail_url}/{pguid}/{guid}" + req = self.session.get(url, params=params) self.response = req.json() - return self.response["Collection"] + event = self.response["Event"][0] + + if as_obj and event: + event = self.obj_from_dict(self.EventObject(), event) + + return event + + def add_event(self, event:EventObject) -> None: + """ + Adds an Event to a calendar. + """ + data = event.request_data + data["ClientState"]["Collection"][0]["ctag"] = self.get_ctag(event.guid) + params = self.default_params + + req = self.session.post(self._calendar_refresh_url + f"/{event.pGuid}/{event.guid}", + params=params, data=json.dumps(data)) + self.response = req.json() + + def remove_event(self, event:EventObject) -> None: + """ + Removes an Event from a calendar. The calendar's guid corresponds to the EventObject's pGuid + """ + data = event.request_data + data["ClientState"]["Collection"][0]["ctag"] = self.get_ctag(event.guid) + data["Event"] = {} + + params = self.default_params + params["methodOverride"] = "DELETE" + if not getattr(event, "etag", ""): + event.etag = self.get_event_detail(event.pGuid, event.guid, as_obj=False).get("etag") + params["ifMatch"] = event.etag + + req = self.session.post(self._calendar_refresh_url + f"/{event.pGuid}/{event.guid}", + params=params, data=json.dumps(data)) + self.response = req.json() + + + + +