diff --git a/src/pytito/__about__.py b/src/pytito/__about__.py index f25606c..48ba2b4 100644 --- a/src/pytito/__about__.py +++ b/src/pytito/__about__.py @@ -17,4 +17,4 @@ Variables that describes the Package """ -__version__ = "0.0.9" +__version__ = "0.0.10" diff --git a/src/pytito/admin/_base_client.py b/src/pytito/admin/_base_client.py index 4fd0ba3..ac6779e 100644 --- a/src/pytito/admin/_base_client.py +++ b/src/pytito/admin/_base_client.py @@ -17,6 +17,7 @@ This file provides the base class for the AdminAPI classses """ +import json import os from abc import ABC from typing import Any, Optional @@ -37,6 +38,11 @@ class UnauthorizedException(Exception): Exception for the request not being authenticated """ +class ForbiddenException(Exception): + """ + Exception for the request being authenticated but forbidden + """ + class AdminAPIBase(ABC): """ @@ -95,11 +101,37 @@ def _get_response(self, endpoint: str) -> dict[str, Any]: if response.status_code == 401: raise UnauthorizedException(response.json()['message']) + if response.status_code == 403: + detail = json.loads(response.text) + raise ForbiddenException(detail['errors']['detail']) + if not response.status_code == 200: raise RuntimeError(f'Hello failed with status code: {response.status_code}') return response.json() + def _patch_reponse(self, value: dict[str, Any]) -> None: + + response = requests.patch( + url=self._end_point, + headers={"Accept" : "application/json", + "Authorization" : f"Token token={self.__api_key()}"}, + json=value, + timeout=10.0 + ) + + if response.status_code == 401: + raise UnauthorizedException(response.json()['message']) + + if response.status_code == 403: + detail = json.loads(response.text) + raise ForbiddenException(detail['errors']['detail']) + + if not response.status_code == 200: + raise RuntimeError(f'patch failed with status code: {response.status_code}') + + + class EventChildAPIBase(AdminAPIBase, ABC): """ Base Class for the children of an event e.g. Tickets, Releases, Actvities @@ -131,6 +163,21 @@ def datetime_from_json(json_value: str) -> datetime: """ return datetime.fromisoformat(json_value) +def datetime_to_json(value: datetime) -> str: + """ + convert a datetime object to the isoformat string datetime used in the json content + """ + + def is_timezone_aware(dt: datetime) -> bool: + return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None + + if not isinstance(value, datetime): + raise TypeError(f'value must be a datetime, got {type(value)}') + # Check the value has a timezone specified + if not is_timezone_aware(value): + raise ValueError('value must have a timezone to be successfully converted') + return value.isoformat() + def optional_datetime_from_json(json_value: str) -> Optional[datetime]: """ convert the isoformat datetime from the json content to a python object, with support for diff --git a/src/pytito/admin/activity.py b/src/pytito/admin/activity.py index e111280..6301d49 100644 --- a/src/pytito/admin/activity.py +++ b/src/pytito/admin/activity.py @@ -20,7 +20,7 @@ from typing import Optional, Any from datetime import datetime -from ._base_client import EventChildAPIBase, optional_datetime_from_json +from ._base_client import EventChildAPIBase, optional_datetime_from_json, datetime_to_json class Activity(EventChildAPIBase): """ @@ -55,6 +55,11 @@ def _populate_json(self) -> None: if self._json_content['view'] != 'extended': raise ValueError('expected the extended view of the ticket') + def _update(self, payload: dict[str, Any]) -> None: + self._patch_reponse(value={'activity': payload}) + for key, value in payload.items(): + self._json_content[key] = value + @property def name(self) -> str: """ @@ -77,10 +82,61 @@ def start_at(self) -> Optional[datetime]: json_value = self._json_content['start_at'] return optional_datetime_from_json(json_value=json_value) + @start_at.setter + def start_at(self, value: Optional[datetime]) -> None: + payload : dict[str, Any] + if value is None: + if self.end_at is not None: + raise RuntimeError('The activity is not allowed end time without a start, ' + 'set the end_at to None first') + payload = {'date': None, + 'start_time': None} + self._patch_reponse(value={'activity': payload}) + self._json_content['start_at'] = None + else: + if self.end_at is not None and self.end_at.date() != value.date(): + raise ValueError('The start_at and end_at must share a common date, ' + 'you may need to set the end date to None to mke this change') + if self.end_at is not None and value >= self.end_at: + raise ValueError(f'new start_at ({value}) is after the end_at ({self.end_at})') + # the start_at can not be changed directly, instead it is necessary to modify the + # date and time + payload = {'date': value.strftime("%Y-%m-%d"), + 'start_time': value.strftime("%H:%M")} + self._patch_reponse(value={'activity': payload}) + value_str = datetime_to_json(value) + self._json_content['start_at'] = value_str + @property def end_at(self) -> Optional[datetime]: """ End date and time for the activity """ + # There is an anomaly that the end_at reports a value if the `end_time` is none but the + # date is set to sometime + if self._json_content['end_time'] is None: + return None json_value = self._json_content['end_at'] return optional_datetime_from_json(json_value=json_value) + + @end_at.setter + def end_at(self, value: Optional[datetime]) -> None: + payload: dict[str, Any] + if value is None: + payload = {'end_time': None} + self._patch_reponse(value={'activity': payload}) + self._json_content['end_at'] = None + else: + if self.start_at is None: + raise ValueError('An activity needs to have a start time to allow an end time' + ' to be sent, please configure the start_at first') + if self.start_at.date() != value.date(): + raise ValueError('The start_at and end_at must share a common date') + if value <= self.start_at: + raise ValueError(f'new end_at ({value}) is before the start_at ({self.start_at})') + # the start_at can not be changed directly, instead it is necessary to modify the + # date and time + payload = {'end_time': value.strftime("%H:%M")} + self._patch_reponse(value={'activity': payload}) + value_str = datetime_to_json(value) + self._json_content['end_at'] = value_str diff --git a/src/pytito/admin/event.py b/src/pytito/admin/event.py index 0d91378..762c034 100644 --- a/src/pytito/admin/event.py +++ b/src/pytito/admin/event.py @@ -21,7 +21,7 @@ from datetime import datetime -from ._base_client import AdminAPIBase, datetime_from_json +from ._base_client import AdminAPIBase, datetime_from_json, datetime_to_json from .ticket import Ticket from .release import Release from .activity import Activity @@ -54,6 +54,16 @@ def _event_slug(self) -> str: def _end_point(self) -> str: return super()._end_point + f'/{self._account_slug}/{self._event_slug}' + def _populate_json(self) -> None: + self._json_content = self._get_response(endpoint='')['event'] + if self._json_content['_type'] != "event": + raise ValueError('JSON content type was expected to be ticket') + + def _update(self, payload: dict[str, Any]) -> None: + self._patch_reponse(value={'event': payload}) + for key, value in payload.items(): + self._json_content[key] = value + @property def title(self) -> str: """ @@ -86,6 +96,18 @@ def start_at(self) -> datetime: json_content = self._json_content['start_at'] return datetime_from_json(json_value=json_content) + @start_at.setter + def start_at(self, value: datetime) -> None: + if value >= self.end_at: + raise ValueError(f'new start_at ({value}) is after the end_at ({self.end_at})') + # the start_at can not be changed directly, instead it is necessary to modify the + # date and time + payload = {'start_date': value.strftime("%Y-%m-%d"), + 'start_time': value.strftime("%H:%M")} + self._patch_reponse(value={'event': payload}) + value_str = datetime_to_json(value) + self._json_content['start_at'] = value_str + @property def end_at(self) -> datetime: """ @@ -94,6 +116,18 @@ def end_at(self) -> datetime: json_content = self._json_content['end_at'] return datetime_from_json(json_value=json_content) + @end_at.setter + def end_at(self, value: datetime) -> None: + if value <= self.start_at: + raise ValueError(f'new end_at ({value}) is before the start_at ({self.start_at})') + # the end_at can not be changed directly, instead it is necessary to modify the + # date and time + payload = {'end_date': value.strftime("%Y-%m-%d"), + 'end_time': value.strftime("%H:%M")} + self._patch_reponse(value={'event': payload}) + value_str = datetime_to_json(value) + self._json_content['end_at'] = value_str + def __release_getter(self) -> dict[str, Release]: def release_factory(json_content:dict[str, Any]) -> tuple[str, Release]: diff --git a/src/pytito/admin/release.py b/src/pytito/admin/release.py index 97332a0..0a30416 100644 --- a/src/pytito/admin/release.py +++ b/src/pytito/admin/release.py @@ -20,7 +20,7 @@ from typing import Optional, Any from datetime import datetime -from ._base_client import EventChildAPIBase, optional_datetime_from_json +from ._base_client import EventChildAPIBase, optional_datetime_from_json, datetime_to_json class Release(EventChildAPIBase): """ @@ -57,6 +57,26 @@ def _populate_json(self) -> None: if self._json_content['_type'] != "release": raise ValueError('JSON content type was expected to be release') + def _update(self, payload: dict[str, Any]) -> None: + self._patch_reponse(value={'release': payload}) + for key, value in payload.items(): + self._json_content[key] = value + + def _update_slug(self, new_slug: str) -> None: + """ + The Slug is a unique component of the data used to reference the release in the API. + It is sometimes desirable to change this + + .. Warning:: + Changing the slug may break things, especially if it clashes with another slug. + Use this method with caution. In particular, the slug is used to key other + dictionaries within the data model. Once changing the clug it is recommended that + the whole data model is refreshed + """ + self._update({'slug': new_slug}) + self.__release_slug = new_slug + + @property def title(self) -> str: """ @@ -64,6 +84,10 @@ def title(self) -> str: """ return self._json_content['title'] + @title.setter + def title(self, value: str) -> None: + self._update({'title': value}) + @property def secret(self) -> bool: """ @@ -79,6 +103,16 @@ def start_at(self) -> Optional[datetime]: json_value = self._json_content['start_at'] return optional_datetime_from_json(json_value=json_value) + @start_at.setter + def start_at(self, value: Optional[datetime]) -> None: + if value is None: + self._update({'start_at': None}) + else: + if self.end_at is not None and value >= self.end_at: + raise ValueError(f'new start_at ({value}) is after the end_at ({self.end_at})') + value_str = datetime_to_json(value) + self._update({'start_at': value_str}) + @property def end_at(self) -> Optional[datetime]: """ @@ -87,6 +121,16 @@ def end_at(self) -> Optional[datetime]: json_value = self._json_content['end_at'] return optional_datetime_from_json(json_value=json_value) + @end_at.setter + def end_at(self, value: Optional[datetime]) -> None: + if value is None: + self._update({'end_at': None}) + else: + if self.start_at is not None and value <= self.start_at: + raise ValueError(f'new end_at ({value}) is before the start_at ({self.start_at})') + value_str = datetime_to_json(value) + self._update({'end_at': value_str}) + @property def quantity(self) -> Optional[int]: """