diff --git a/atlassian/__init__.py b/atlassian/__init__.py index 14e3ec33f..b65309e43 100644 --- a/atlassian/__init__.py +++ b/atlassian/__init__.py @@ -6,6 +6,7 @@ from .bitbucket import Bitbucket from .bitbucket import Bitbucket as Stash from .cloud_admin import CloudAdmin, CloudAdminOrgs, CloudAdminUsers +from .company_calendar import CompanyCalendar from .compass import Compass from .confluence import Confluence, ConfluenceBase, ConfluenceCloud, ConfluenceServer from .confluence.cloud.cloud import ConfluenceCloud as ConfluenceV2 @@ -47,6 +48,7 @@ def create_confluence(url, *args, api_version=1, **kwargs): "CloudAdminOrgs", "CloudAdminUsers", "CloudAdmin", + "CompanyCalendar", "Compass", "Portfolio", "Bamboo", diff --git a/atlassian/company_calendar.py b/atlassian/company_calendar.py new file mode 100644 index 000000000..2f429e3cc --- /dev/null +++ b/atlassian/company_calendar.py @@ -0,0 +1,215 @@ +# coding=utf-8 +"""Company Calendar for Jira REST API client. + +See the vendor documentation: +https://brizoit.atlassian.net/wiki/spaces/CCJDOCS/pages/4018044966/REST+API +""" + +import logging + +from .rest_client import AtlassianRestAPI + +log = logging.getLogger(__name__) + + +class CompanyCalendar(AtlassianRestAPI): + """Client for the Company Calendar for Jira REST API. + + Company Calendar for Jira exposes its REST API below + ``rest/brizo-calendar/api/1``. Authentication is done through a Bearer + API token that is generated and assigned to one or more calendars by an + administrator. + + On Cloud the base URL is fixed to + ``https://ccj.brizoit.com/ccj/rest/brizo-calendar``, while on Data Center + it is ``/rest/brizo-calendar``. + + Examples + -------- + >>> calendar = CompanyCalendar( + ... "https://ccj.brizoit.com/ccj", + ... token="API-TOKEN", + ... ) + >>> calendar.get_calendars() + """ + + def __init__(self, *args, **kwargs): + kwargs["api_root"] = "rest/brizo-calendar" + super(CompanyCalendar, self).__init__(*args, **kwargs) + + def _resource_url(self, resource): + return self.url_joiner(self.api_root, f"api/1/{resource}") + + ################################################################################################ + # Calendars + ################################################################################################ + + def get_calendars(self): + """ + Return a list of all calendars associated with the token. + + :return: list[dict] - Calendars. + """ + return self.get(self._resource_url("calendar")) + + def get_calendar(self, calendar_id): + """ + Return details for a specific calendar, including permissions. + + :param calendar_id: int/str - The calendar ID. + :return: dict - Calendar details. + """ + return self.get(self._resource_url(f"calendar/{calendar_id}")) + + def create_calendar(self, data): + """ + Create a new calendar. + + :param data: dict - The calendar payload. See the Company Calendar REST API + documentation for the ``publicCalendar``/``permCalendarView``/``value`` fields + required for public and private calendars. + :return: dict - The created calendar. + """ + return self.post(self._resource_url("calendar"), data=data) + + def update_calendar(self, data): + """ + Update an existing calendar. + + Important: when updating, the full ``value`` JSON object must be provided. + Any property that is not included will be overwritten or removed. + + :param data: dict - The calendar payload, including its ``id``. + :return: dict - The updated calendar. + """ + return self.put(self._resource_url("calendar"), data=data) + + def delete_calendar(self, calendar_id): + """ + Delete the specified calendar. + + Deleting a calendar is possible only when the token has permission to delete it. + + :param calendar_id: int/str - The calendar ID. + :return: response + """ + return self.delete(self._resource_url(f"calendar/{calendar_id}")) + + ################################################################################################ + # Event types + ################################################################################################ + + def get_event_types(self): + """ + Return all system and custom event types. + + :return: list[dict] - Event types. + """ + return self.get(self._resource_url("eventypes")) + + ################################################################################################ + # Sources + ################################################################################################ + + def get_source(self, source_id): + """ + Return details for a specific source. + + :param source_id: int/str - The source ID. + :return: dict - Source details. + """ + return self.get(self._resource_url(f"source/{source_id}")) + + def create_source(self, data): + """ + Create a new source for a calendar. + + :param data: dict - The source payload, e.g. ``eventTypeId``, + ``calendarConfigurationId``, ``emailsEnabled`` and ``value``. + :return: dict - The created source. + """ + return self.post(self._resource_url("source"), data=data) + + def update_source(self, data): + """ + Update an existing source. + + Important: always send the full ``value`` JSON object. Any omitted + property will be overwritten or removed. + + :param data: dict - The source payload, including its ``id``. + :return: dict - The updated source. + """ + return self.put(self._resource_url("source"), data=data) + + def delete_source(self, source_id): + """ + Delete a source. + + Deleting a source is possible only when the token has permission to delete it. + + :param source_id: int/str - The source ID. + :return: response + """ + return self.delete(self._resource_url(f"source/{source_id}")) + + ################################################################################################ + # Events + ################################################################################################ + + def create_event(self, data): + """ + Create a new event (all-day, timed or recurring). + + :param data: dict - The event payload, e.g. ``{"event": {...}, "mode": "add"}``. + :return: dict - The created event. + """ + return self.post(self._resource_url("event"), data=data) + + def update_event(self, data): + """ + Update an existing event (single or recurring). + + :param data: dict - The event payload, e.g. ``{"event": {...}, "mode": "edit"}``. + :return: dict - The updated event. + """ + return self.put(self._resource_url("event"), data=data) + + def delete_event(self, event_id): + """ + Delete an event. + + :param event_id: int/str - The event ID. + :return: response + """ + return self.delete(self._resource_url(f"event/{event_id}")) + + def delete_recurring_event(self, event_id, data): + """ + Delete a recurring event occurrence, exception, or the entire recurrence chain. + + Depending on ``data``, this can delete the full recurring series, a single + occurrence, or exclude a specific instance from the recurrence by adding an + exception date. See the Company Calendar REST API documentation for the + required ``mode``, ``select`` and ``exdate`` fields. + + :param event_id: int/str - The event ID (occurrence or parent, depending on scenario). + :param data: dict - The deletion payload. + :return: response + """ + return self.delete(self._resource_url(f"event/{event_id}/recur"), data=data) + + ################################################################################################ + # Search + ################################################################################################ + + def search_events(self, data): + """ + Search for events within a date range, optionally scoped to a calendar or source. + + :param data: dict - The search payload. Accepts either Unix timestamps + (``startDate``/``endDate``) or ISO dates (``startDateIso``/``endDateIso``), + combined with an optional ``calendarId`` or ``sourceId``. + :return: dict - Paginated search results. + """ + return self.post(self._resource_url("event/search"), data=data) diff --git a/docs/company_calendar.rst b/docs/company_calendar.rst new file mode 100644 index 000000000..e7838d21f --- /dev/null +++ b/docs/company_calendar.rst @@ -0,0 +1,78 @@ +Company Calendar for Jira +========================== + +The ``CompanyCalendar`` client wraps the Company Calendar for Jira REST API +at ``rest/brizo-calendar/api/1``. See the vendor documentation for the full +reference: +https://brizoit.atlassian.net/wiki/spaces/CCJDOCS/pages/4018044966/REST+API + +Authentication is done with a Bearer API token generated and assigned to one +or more calendars by an administrator. + +.. code-block:: python + + from atlassian import CompanyCalendar + + # Cloud + calendar = CompanyCalendar( + "https://ccj.brizoit.com/ccj", + token="API-TOKEN", + ) + + # Data Center + calendar = CompanyCalendar( + "https://your-jira-instance.company.com", + token="API-TOKEN", + ) + + calendars = calendar.get_calendars() + calendar_details = calendar.get_calendar(calendars[0]["id"]) + +Calendars, sources, and events can be created, updated, and deleted: + +.. code-block:: python + + calendar.create_calendar( + { + "publicCalendar": True, + "calendarEditable": True, + "eventsEditable": True, + "value": '{"name":"New public calendar","usersFromEvents":true}', + "user": "userID", + } + ) + + calendar.create_source( + { + "eventTypeId": 5, + "calendarConfigurationId": 1959, + "emailsEnabled": False, + "value": '{"color":"#0fff04","name":"New source"}', + } + ) + + calendar.create_event( + { + "event": { + "summary": "All-day Rest API event", + "eventTypeConfigId": "sourceId", + "userKeys": ["account-id"], + "allDayStart": "2024-09-29", + "allDayEnd": "2024-09-29", + }, + "mode": "add", + } + ) + +Events can be searched by date range, optionally scoped to a calendar or a +source: + +.. code-block:: python + + calendar.search_events( + { + "startDate": 1672531200000, + "endDate": 1680307200000, + "calendarId": 1959, + } + ) diff --git a/tests/test_company_calendar.py b/tests/test_company_calendar.py new file mode 100644 index 000000000..ccd17b9c6 --- /dev/null +++ b/tests/test_company_calendar.py @@ -0,0 +1,121 @@ +from unittest.mock import patch + +from atlassian import CompanyCalendar + + +def test_company_calendar_uses_brizo_calendar_api_root(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + assert calendar.api_root == "rest/brizo-calendar" + + +def test_get_calendars(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + with patch.object(calendar, "get", return_value=[]) as get: + assert calendar.get_calendars() == [] + get.assert_called_once_with("rest/brizo-calendar/api/1/calendar") + + +def test_get_calendar(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + with patch.object(calendar, "get", return_value={"id": 1959}) as get: + assert calendar.get_calendar(1959) == {"id": 1959} + get.assert_called_once_with("rest/brizo-calendar/api/1/calendar/1959") + + +def test_create_calendar(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + payload = {"publicCalendar": True, "value": "{}", "user": "userID"} + with patch.object(calendar, "post", return_value={"id": 1}) as post: + assert calendar.create_calendar(payload) == {"id": 1} + post.assert_called_once_with("rest/brizo-calendar/api/1/calendar", data=payload) + + +def test_update_calendar(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + payload = {"id": 1959, "value": "{}"} + with patch.object(calendar, "put", return_value=payload) as put: + assert calendar.update_calendar(payload) == payload + put.assert_called_once_with("rest/brizo-calendar/api/1/calendar", data=payload) + + +def test_delete_calendar(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + with patch.object(calendar, "delete", return_value={}) as delete: + assert calendar.delete_calendar(1959) == {} + delete.assert_called_once_with("rest/brizo-calendar/api/1/calendar/1959") + + +def test_get_event_types(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + with patch.object(calendar, "get", return_value=[]) as get: + assert calendar.get_event_types() == [] + get.assert_called_once_with("rest/brizo-calendar/api/1/eventypes") + + +def test_get_source(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + with patch.object(calendar, "get", return_value={"id": 5655}) as get: + assert calendar.get_source(5655) == {"id": 5655} + get.assert_called_once_with("rest/brizo-calendar/api/1/source/5655") + + +def test_create_source(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + payload = {"eventTypeId": 5, "calendarConfigurationId": 1959, "value": "{}"} + with patch.object(calendar, "post", return_value={"id": 1}) as post: + assert calendar.create_source(payload) == {"id": 1} + post.assert_called_once_with("rest/brizo-calendar/api/1/source", data=payload) + + +def test_update_source(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + payload = {"id": 5655, "value": "{}"} + with patch.object(calendar, "put", return_value=payload) as put: + assert calendar.update_source(payload) == payload + put.assert_called_once_with("rest/brizo-calendar/api/1/source", data=payload) + + +def test_delete_source(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + with patch.object(calendar, "delete", return_value={}) as delete: + assert calendar.delete_source(5655) == {} + delete.assert_called_once_with("rest/brizo-calendar/api/1/source/5655") + + +def test_create_event(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + payload = {"event": {"summary": "Test event"}, "mode": "add"} + with patch.object(calendar, "post", return_value={"id": 1}) as post: + assert calendar.create_event(payload) == {"id": 1} + post.assert_called_once_with("rest/brizo-calendar/api/1/event", data=payload) + + +def test_update_event(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + payload = {"event": {"id": 1}, "mode": "edit"} + with patch.object(calendar, "put", return_value=payload) as put: + assert calendar.update_event(payload) == payload + put.assert_called_once_with("rest/brizo-calendar/api/1/event", data=payload) + + +def test_delete_event(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + with patch.object(calendar, "delete", return_value={}) as delete: + assert calendar.delete_event(60709) == {} + delete.assert_called_once_with("rest/brizo-calendar/api/1/event/60709") + + +def test_delete_recurring_event(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + payload = {"event": {"eventTypeConfigId": 5655}, "select": "current", "exdate": "20240906"} + with patch.object(calendar, "delete", return_value={}) as delete: + assert calendar.delete_recurring_event(60707, payload) == {} + delete.assert_called_once_with("rest/brizo-calendar/api/1/event/60707/recur", data=payload) + + +def test_search_events(): + calendar = CompanyCalendar("https://ccj.brizoit.com/ccj", token="API-TOKEN") + payload = {"startDate": 1672531200000, "endDate": 1680307200000, "calendarId": 1959} + with patch.object(calendar, "post", return_value={"total": 0, "values": []}) as post: + assert calendar.search_events(payload) == {"total": 0, "values": []} + post.assert_called_once_with("rest/brizo-calendar/api/1/event/search", data=payload)