From daedc15af5d8f5def814d02d0dd22ef8a172f09f Mon Sep 17 00:00:00 2001 From: krcb197 <34693973+krcb197@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:32:10 +0000 Subject: [PATCH 1/8] Added support for updating the start and end for an event. closes #12 --- src/pytito/admin/_base_client.py | 32 ++++++++++++++++++++++++++++ src/pytito/admin/event.py | 36 +++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/pytito/admin/_base_client.py b/src/pytito/admin/_base_client.py index 4fd0ba3..f11cb9e 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 diff --git a/src/pytito/admin/event.py b/src/pytito/admin/event.py index 0d91378..df807c6 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]): + 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]: From e8f9c33766fe5b1776b6203ec4cc455628659087 Mon Sep 17 00:00:00 2001 From: krcb197 <34693973+krcb197@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:05:27 +0000 Subject: [PATCH 2/8] Added support for updating the title, start and end for an release. closes #13 --- src/pytito/admin/_base_client.py | 15 +++++++++++ src/pytito/admin/release.py | 46 +++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/pytito/admin/_base_client.py b/src/pytito/admin/_base_client.py index f11cb9e..0877aa3 100644 --- a/src/pytito/admin/_base_client.py +++ b/src/pytito/admin/_base_client.py @@ -163,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(f'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/release.py b/src/pytito/admin/release.py index 97332a0..2a34493 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]): + self._patch_reponse(value={'release': payload}) + for key, value in payload.items(): + self._json_content[key] = value + + def _update_slug(self, new_slug: str): + """ + 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.end_at is not None and value <= self.start_at: + raise ValueError(f'new end_at ({value}) is before the start_at ({self.end_at})') + value_str = datetime_to_json(value) + self._update({'end_at': value_str}) + @property def quantity(self) -> Optional[int]: """ From 1f874a1a9cb013dc33f049183d1a5b06af6c11ff Mon Sep 17 00:00:00 2001 From: krcb197 <34693973+krcb197@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:19:27 +0000 Subject: [PATCH 3/8] Fix a bug with the None checks --- src/pytito/admin/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytito/admin/release.py b/src/pytito/admin/release.py index 2a34493..4c3dd20 100644 --- a/src/pytito/admin/release.py +++ b/src/pytito/admin/release.py @@ -126,7 +126,7 @@ def end_at(self, value: Optional[datetime]) -> None: if value is None: self._update({'end_at': None}) else: - if self.end_at is not None and value <= self.start_at: + 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.end_at})') value_str = datetime_to_json(value) self._update({'end_at': value_str}) From 0a9c417cd2df2f31e1946d8456725190857311a3 Mon Sep 17 00:00:00 2001 From: krcb197 <34693973+krcb197@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:20:37 +0000 Subject: [PATCH 4/8] Increment version ready for next release --- src/pytito/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From fd7638d44e5fbe79be03f4c44e872baf66f1b91a Mon Sep 17 00:00:00 2001 From: krcb197 <34693973+krcb197@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:40:44 +0000 Subject: [PATCH 5/8] Correct the error message --- src/pytito/admin/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytito/admin/release.py b/src/pytito/admin/release.py index 4c3dd20..4dd2323 100644 --- a/src/pytito/admin/release.py +++ b/src/pytito/admin/release.py @@ -127,7 +127,7 @@ def end_at(self, value: Optional[datetime]) -> 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.end_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}) From 5b53cfeb5f5a84fa4ed63ce67e018c0f28d14809 Mon Sep 17 00:00:00 2001 From: krcb197 <34693973+krcb197@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:46:35 +0000 Subject: [PATCH 6/8] Put in support for changing the activity times and date --- src/pytito/admin/activity.py | 54 +++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/pytito/admin/activity.py b/src/pytito/admin/activity.py index e111280..18385e9 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]): + 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,57 @@ 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: + 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(f'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: + 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(f'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 From 72b9ec36876732df641fbac2bd13a6450265f9c4 Mon Sep 17 00:00:00 2001 From: krcb197 <34693973+krcb197@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:51:32 +0000 Subject: [PATCH 7/8] Linting cleanup --- src/pytito/admin/activity.py | 12 ++++++++---- src/pytito/admin/event.py | 4 ++-- src/pytito/admin/release.py | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/pytito/admin/activity.py b/src/pytito/admin/activity.py index 18385e9..6301d49 100644 --- a/src/pytito/admin/activity.py +++ b/src/pytito/admin/activity.py @@ -55,7 +55,7 @@ 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]): + 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 @@ -84,6 +84,7 @@ def start_at(self) -> Optional[datetime]: @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, ' @@ -94,7 +95,8 @@ def start_at(self, value: Optional[datetime]) -> None: self._json_content['start_at'] = None else: if self.end_at is not None and self.end_at.date() != value.date(): - raise ValueError(f'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') + 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 @@ -119,15 +121,17 @@ def end_at(self) -> Optional[datetime]: @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') + 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(f'The start_at and end_at must share a common 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 diff --git a/src/pytito/admin/event.py b/src/pytito/admin/event.py index df807c6..762c034 100644 --- a/src/pytito/admin/event.py +++ b/src/pytito/admin/event.py @@ -55,11 +55,11 @@ 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'] + 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]): + 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 diff --git a/src/pytito/admin/release.py b/src/pytito/admin/release.py index 4dd2323..0a30416 100644 --- a/src/pytito/admin/release.py +++ b/src/pytito/admin/release.py @@ -57,12 +57,12 @@ 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]): + 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): + 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 From 11f8e9885f9fc85abef9ff3304362a4733b4fe30 Mon Sep 17 00:00:00 2001 From: krcb197 <34693973+krcb197@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:54:34 +0000 Subject: [PATCH 8/8] Linting cleanup --- src/pytito/admin/_base_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pytito/admin/_base_client.py b/src/pytito/admin/_base_client.py index 0877aa3..ac6779e 100644 --- a/src/pytito/admin/_base_client.py +++ b/src/pytito/admin/_base_client.py @@ -175,7 +175,7 @@ def is_timezone_aware(dt: datetime) -> bool: 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(f'value must have a timezone to be successfully converted') + raise ValueError('value must have a timezone to be successfully converted') return value.isoformat() def optional_datetime_from_json(json_value: str) -> Optional[datetime]: