From da82b98a176cb80d28edc7a600087c9a0a9a3af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Tue, 28 Feb 2023 11:28:55 +0100 Subject: [PATCH 1/4] feat!: migration to v9 api version --- toggl/api/base.py | 21 ++++++++------- toggl/api/models.py | 63 +++++++++++++++++++++---------------------- toggl/toggl.py | 2 +- toggl/utils/others.py | 2 +- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/toggl/api/base.py b/toggl/api/base.py index 7fdda30..d384b8f 100644 --- a/toggl/api/base.py +++ b/toggl/api/base.py @@ -125,7 +125,7 @@ def base_url(self): # type: (TogglSet) -> str if self.entity_cls is None: raise exceptions.TogglException('The TogglSet instance is not binded to any TogglEntity!') - return self.entity_cls.get_url() + return self.entity_cls.get_name() + 's' def build_list_url(self, caller, config, conditions): # type: (str, utils.Config, typing.Dict) -> str """ @@ -195,10 +195,10 @@ def get(self, id=None, config=None, **conditions): # type: (typing.Any, utils.C if self.can_get_detail: try: fetched_entity = utils.toggl(self.build_detail_url(id, config), 'get', config=config) - if fetched_entity['data'] is None: + if fetched_entity is None: return None - return self.entity_cls.deserialize(config=config, **fetched_entity['data']) + return self.entity_cls.deserialize(config=config, **fetched_entity) except exceptions.TogglNotFoundException: return None else: @@ -300,10 +300,12 @@ class WorkspaceTogglSet(TogglSet): def build_list_url(self, caller, config, conditions): # type: (str, utils.Config, typing.Dict) -> str if conditions.get('workspace') is not None: wid = conditions['workspace'].id + elif conditions.get('workspace_id') is not None: + wid = conditions['workspace_id'] else: wid = conditions.get('wid') or config.default_workspace.id - return '/workspaces/{}/{}'.format(wid, self.base_url) + return f'/workspaces/{wid}/{self.base_url}' class TogglEntityMeta(ABCMeta): @@ -337,7 +339,7 @@ def _make_fields(attrs, parents): # type: (typing.Dict, typing.List[typing.Type for key, field in attrs.items(): if isinstance(field, model_fields.TogglField): if key in fields: - logger.warning('Field \'{}\' is being overridden'.format(key)) + logger.warning(f'Field \'{key}\' is being overridden') field.name = key fields[key] = field @@ -466,7 +468,7 @@ def save(self, config=None): # type: (utils.Config) -> None self.__change_dict__ = {} # Reset tracking changes else: # Create data = utils.toggl('/{}'.format(self.get_url()), 'post', self.json(), config=config) - self.id = data['data']['id'] # Store the returned ID + self.id = data['id'] # Store the returned ID def delete(self, config=None): # type: (utils.Config) -> None """ @@ -492,7 +494,7 @@ def json(self, update=False): # type: (bool) -> str :param update: Specifies if the resulted JSON should contain only changed fields (for PUT call) or whole entity. """ - return json.dumps({self.get_name(): self.to_dict(serialized=True, changes_only=update)}) + return json.dumps(self.to_dict(serialized=True, changes_only=update)) def validate(self): # type: () -> None """ @@ -569,9 +571,8 @@ def get_name(cls, verbose=False): # type: (bool) -> str return name - @classmethod - def get_url(cls): # type: () -> str - return cls.get_name() + 's' + def get_url(self): # type: () -> str + return self.get_name() + 's' @classmethod def deserialize(cls, config=None, **kwargs): # type: (utils.Config, **typing.Any) -> typing.Generic[Entity] diff --git a/toggl/api/models.py b/toggl/api/models.py index e8fa0d3..520d636 100644 --- a/toggl/api/models.py +++ b/toggl/api/models.py @@ -39,11 +39,21 @@ class Workspace(base.TogglEntity): Whether only the admins can create projects or everybody """ + only_admins_may_create_tags = fields.BooleanField() + """ + Whether only the admins can create tags or everybody + """ + only_admins_see_billable_rates = fields.BooleanField() """ Whether only the admins can see billable rates or everybody """ + only_admins_see_team_dashboard = fields.BooleanField() + """ + Whether only the admins can see team dashboard or everybody + """ + rounding = fields.IntegerField() """ Type of rounding: @@ -69,6 +79,10 @@ class Workspace(base.TogglEntity): Default currency for workspace """ + ical_enabled = fields.BooleanField() + ical_url = fields.StringField() + logo_url = fields.StringField() + # As TogglEntityMeta is by default adding WorkspaceTogglSet to TogglEntity, # but we want vanilla TogglSet so defining it here explicitly. objects = base.TogglSet() @@ -83,7 +97,7 @@ def invite(self, *emails): # type: (str) -> None """ for email in emails: if not validate_email(email): - raise exceptions.TogglValidationException('Supplied email \'{}\' is not valid email!'.format(email)) + raise exceptions.TogglValidationException(f'Supplied email \'{email}\' is not valid email!') emails_json = json.dumps({'emails': emails}) data = utils.toggl("/workspaces/{}/invite".format(self.id), "post", emails_json, config=self._config) @@ -97,12 +111,15 @@ class WorkspacedEntity(base.TogglEntity): Abstract entity which has linked Workspace """ - workspace = fields.MappingField(Workspace, 'wid', write=False, - default=lambda config: config.default_workspace) + workspace = fields.MappingField(Workspace, 'workspace_id', write=False, + default=lambda config: config.default_workspace) # type: Workspace """ Workspace to which the resource is linked to. """ + def get_url(self): # type: () -> str + return f'workspaces/{self.workspace.id}/{self.get_name()}s' + # Premium Entity class PremiumEntity(WorkspacedEntity): @@ -112,7 +129,7 @@ class PremiumEntity(WorkspacedEntity): def save(self, config=None): # type: (utils.Config) -> None if not self.workspace.premium: - raise exceptions.TogglPremiumException('The entity {} requires to be associated with Premium workspace!') + raise exceptions.TogglPremiumException(f'The entity {self.get_name()} requires to be associated with Premium workspace!') super().save(config) @@ -130,11 +147,6 @@ class Client(WorkspacedEntity): Name of client (Required) """ - notes = fields.StringField() - """ - Notes about the client - """ - class Project(WorkspacedEntity): """ @@ -222,7 +234,7 @@ def current_user(self, config=None): # type: (utils.Config) -> 'User' Fetches details about the current user. """ fetched_entity = utils.toggl('/me', 'get', config=config) - return self.entity_cls.deserialize(config=config, **fetched_entity['data']) + return self.entity_cls.deserialize(config=config, **fetched_entity) class User(WorkspacedEntity): @@ -242,9 +254,13 @@ class User(WorkspacedEntity): (Returned only for User.objects.current_user() call.) """ - send_timer_notifications = fields.BooleanField() + has_password = fields.BooleanField() + country_id = fields.StringField() + intercom_hash = fields.StringField() + openid_email = fields.StringField() + openid_enabled = fields.BooleanField() - default_workspace = fields.MappingField(Workspace, 'default_wid') # type: Workspace + default_workspace = fields.MappingField(Workspace, 'default_workspace_id') # type: Workspace """ Default workspace for calls that does not specify Workspace. @@ -291,23 +307,6 @@ class User(WorkspacedEntity): May differ from one used in this tool, see toggl.utils.Config(). """ - # TODO: Add possibility to use this value in Config.time_format - timeofday_format = fields.ChoiceField({ - 'H:mm': '24-hour', - 'h:mm A': '12-hour' - }) - """ - Format of time used to display time. - """ - - # TODO: Add possibility to use this value in Config.datetime_format - date_format = fields.ChoiceField( - ["YYYY-MM-DD", "DD.MM.YYYY", "DD-MM-YYYY", "MM/DD/YYYY", "DD/MM/YYYY", "MM-DD-YYYY"] - ) - """ - Format of date used to display dates. - """ - objects = UserSet() @classmethod @@ -339,7 +338,7 @@ def signup(cls, email, password, timezone=None, created_with='TogglCLI', 'created_with': created_with }}) data = utils.toggl("/signups", "post", user_json, config=config) - return cls.deserialize(config=config, **data['data']) + return cls.deserialize(config=config, **data) def is_admin(self, workspace): wid = workspace.id if isinstance(workspace, Workspace) else workspace @@ -587,7 +586,7 @@ def current(self, config=None): # type: (utils.Config) -> typing.Optional[TimeE if fetched_entity.get('data') is None: return None - return self.entity_cls.deserialize(config=config, **fetched_entity['data']) + return self.entity_cls.deserialize(config=config, **fetched_entity) def _build_reports_url(self, start, stop, page, wid): url = '/details?user_agent=toggl_cli&workspace_id={}&page={}'.format(wid, page) @@ -731,7 +730,7 @@ def __init__(self, start, stop=None, duration=None, **kwargs): super().__init__(start=start, stop=stop, duration=duration, **kwargs) @classmethod - def get_url(cls): + def get_url(self): return 'time_entries' def to_dict(self, serialized=False, changes_only=False): diff --git a/toggl/toggl.py b/toggl/toggl.py index d56d860..b73100c 100644 --- a/toggl/toggl.py +++ b/toggl/toggl.py @@ -2,7 +2,7 @@ from toggl import cli -TOGGL_URL = "https://api.track.toggl.com/api/v8" +TOGGL_URL = "https://api.track.toggl.com/api/v9" REPORTS_URL = "https://api.track.toggl.com/reports/api/v2" WEB_CLIENT_ADDRESS = "https://track.toggl.com/" diff --git a/toggl/utils/others.py b/toggl/utils/others.py index 539c49d..4a700da 100644 --- a/toggl/utils/others.py +++ b/toggl/utils/others.py @@ -87,7 +87,7 @@ def convert_credentials_to_api_token(username, password): config.password = password data = toggl("/me", "get", config=config) - return data['data']['api_token'] + return data['api_token'] def handle_error(response): From 1c099e25fac7fd957a2be4fac146dd285f48ac95 Mon Sep 17 00:00:00 2001 From: Ola Herrdahl Date: Fri, 31 Mar 2023 09:56:55 +0200 Subject: [PATCH 2/4] fix: projects and tasks (#305) --- toggl/api/base.py | 5 ++++- toggl/api/models.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/toggl/api/base.py b/toggl/api/base.py index d384b8f..65c37f8 100644 --- a/toggl/api/base.py +++ b/toggl/api/base.py @@ -222,7 +222,10 @@ def _fetch_all(self, url, order, config): # type: (str, str, utils.Config) -> t Helper method that fetches all objects from given URL and deserialize them. """ fetched_entities = utils.toggl(url, 'get', config=config) - + + if isinstance(fetched_entities, dict): + fetched_entities = fetched_entities.get('data') + if fetched_entities is None: return [] diff --git a/toggl/api/models.py b/toggl/api/models.py index 520d636..55dab7a 100644 --- a/toggl/api/models.py +++ b/toggl/api/models.py @@ -195,7 +195,7 @@ class Project(WorkspacedEntity): (Available only for Premium workspaces) """ - color = fields.IntegerField() + color = fields.StringField() """ Id of the color selected for the project """ From 48d4b04a24623ce61245acef9fa186ae4a8d6604 Mon Sep 17 00:00:00 2001 From: abk16 Date: Thu, 30 May 2024 08:49:10 +0000 Subject: [PATCH 3/4] feat!: migration to v9 api version (tests passing) (#320) --- tests/helpers.py | 14 +- tests/integration/factories.py | 1 - tests/integration/test_projects.py | 26 +-- tests/integration/test_time_entries.py | 14 +- tests/unit/api/test_base.py | 23 ++- toggl/api/__init__.py | 14 +- toggl/api/base.py | 76 +++++---- toggl/api/fields.py | 7 +- toggl/api/models.py | 220 +++++++++++++++++++++---- toggl/cli/commands.py | 44 +++-- toggl/utils/others.py | 3 +- 11 files changed, 332 insertions(+), 110 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index ee68a41..b4bc8ea 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -114,11 +114,13 @@ class Cleanup: def _ids_cleanup(base, config=None, batch=False, *ids): config = config or get_config() + wid = config.default_workspace.id + workspace_url = '/workspaces/{}'.format(wid) if batch: - utils.toggl('/{}/{}'.format(base, ','.join([str(eid) for eid in ids])), 'delete', config=config) + utils.toggl('{}/{}/{}'.format(workspace_url, base, ','.join([str(eid) for eid in ids])), 'delete', config=config) else: for entity_id in ids: - utils.toggl('/{}/{}'.format(base, entity_id), 'delete', config=config) + utils.toggl('{}/{}/{}'.format(workspace_url, base, entity_id), 'delete', config=config) @staticmethod def _all_cleanup(cls, config=None): @@ -171,12 +173,16 @@ def time_entries(config=None, *ids): if not ids: config = config or get_config() entities = api.TimeEntry.objects.all(config=config) + current_entry = api.TimeEntry.objects.current(config=config) + if current_entry is not None: + current_entry.stop_and_save() + entities.append(current_entry) ids = [entity.id for entity in entities] if not ids: return - Cleanup._ids_cleanup('time_entries', config, True, *ids) + Cleanup._ids_cleanup('time_entries', config, False, *ids) @staticmethod def project_users(config=None, *ids): @@ -195,7 +201,7 @@ def projects(config=None, *ids): if not ids: return - Cleanup._ids_cleanup('projects', config, True, *ids) + Cleanup._ids_cleanup('projects', config, False, *ids) @staticmethod def tasks(config=None, *ids): diff --git a/tests/integration/factories.py b/tests/integration/factories.py index 9459a38..7634b0b 100644 --- a/tests/integration/factories.py +++ b/tests/integration/factories.py @@ -40,7 +40,6 @@ class Meta: # client = factory.SubFactory(ClientFactory) active = True is_private = True - billable = False class TaskFactory(TogglFactory): diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index ce2ccf0..ff7c04f 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -34,12 +34,12 @@ def test_add_full_non_premium(self, cmd, fake, factories, config): assert result.obj.exit_code == 0 assert Project.objects.get(result.created_id(), config=config).client == client - result = cmd('projects add --name \'{}\' --private --color 2'.format(fake.word())) + result = cmd('projects add --name \'{}\' --private --color #c9806b'.format(fake.word())) assert result.obj.exit_code == 0 prj = Project.objects.get(result.created_id(), config=config) # type: Project assert prj.is_private is True - assert prj.color == 2 + assert prj.color == '#c9806b' with pytest.raises(exceptions.TogglPremiumException): cmd('projects add --name \'{}\' --billable'.format(fake.word())) @@ -52,49 +52,49 @@ def test_add_full_premium(self, cmd, fake): def test_get(self, cmd, fake, factories): name = fake.word() client = factories.ClientFactory() - project = factories.ProjectFactory(name=name, is_private=False, color=2, client=client) + project = factories.ProjectFactory(name=name, is_private=False, color='#c9806b', client=client) result = cmd('projects get \'{}\''.format(project.id)) id_parsed = result.parse_detail() assert id_parsed['name'] == name - assert id_parsed['billable'] == 'False' - assert id_parsed['auto_estimates'] == 'False' + assert not bool(id_parsed['billable']) + assert not bool(id_parsed['auto_estimates']) assert id_parsed['active'] == 'True' assert id_parsed['is_private'] == 'False' - assert id_parsed['color'] == '2' + assert id_parsed['color'] == '#c9806b' assert str(client.id) in id_parsed['client'] result = cmd('projects get \'{}\''.format(name)) name_parsed = result.parse_detail() assert name_parsed['name'] == name - assert name_parsed['billable'] == 'False' - assert name_parsed['auto_estimates'] == 'False' + assert not bool(name_parsed['billable']) + assert not bool(name_parsed['auto_estimates']) assert name_parsed['active'] == 'True' assert name_parsed['is_private'] == 'False' - assert name_parsed['color'] == '2' + assert name_parsed['color'] == '#c9806b' assert str(client.id) in name_parsed['client'] def test_update(self, cmd, fake, config, factories): name = fake.name() - project = factories.ProjectFactory(name=name, is_private=False, color=2) + project = factories.ProjectFactory(name=name, is_private=False, color='#c9806b') new_name = fake.name() new_client = factories.ClientFactory() - result = cmd('projects update --name \'{}\' --client \'{}\' --private --color 1 \'{}\''.format(new_name, new_client.name, name)) + result = cmd('projects update --name \'{}\' --client \'{}\' --private --color #0b83d9 \'{}\''.format(new_name, new_client.name, name)) assert result.obj.exit_code == 0 project_obj = Project.objects.get(project.id, config=config) assert project_obj.name == new_name assert project_obj.client == new_client - assert project_obj.color == 1 + assert project_obj.color == '#0b83d9' assert project_obj.is_private is True @pytest.mark.premium def test_update_premium(self, cmd, fake, config, factories): name = fake.name() - project = factories.ProjectFactory(name=name, is_private=False, color=2) + project = factories.ProjectFactory(name=name, is_private=False, color='#c9806b') result = cmd('projects update --billable --rate 10.10 --auto-estimates \'{}\''.format(name)) assert result.obj.exit_code == 0 diff --git a/tests/integration/test_time_entries.py b/tests/integration/test_time_entries.py index 0c9f271..f6eea49 100644 --- a/tests/integration/test_time_entries.py +++ b/tests/integration/test_time_entries.py @@ -69,7 +69,7 @@ def test_add_basic(self, cmd, fake, config): assert result.obj.exit_code == 0 entry = TimeEntry.objects.get(result.created_id(), config=config) # type: TimeEntry - assert entry.start == start + assert entry.start == start.replace(microsecond=0) assert (entry.stop - entry.start).seconds == 3722 def test_add_tags(self, cmd, fake, config): @@ -179,8 +179,16 @@ def test_now(self, cmd, config, factories): def test_continue(self, cmd, config, factories): some_entry = factories.TimeEntryFactory() - start = pendulum.now('utc') - stop = start + pendulum.duration(seconds=10) + # Stop and remove any running and recent time entries first + pre_running_entry = TimeEntry.objects.current(config=config) + if pre_running_entry is not None: + pre_running_entry.stop_and_save() + recent_entries = TimeEntry.objects.filter(order="desc", config=config, start=pendulum.now('utc') - pendulum.duration(minutes=2), stop=pendulum.now('utc')) + for to_delete_entry in recent_entries: + to_delete_entry.delete(config=config) + + stop = pendulum.now('utc') - pendulum.duration(seconds=1) + start = stop - pendulum.duration(seconds=10) last_entry = factories.TimeEntryFactory(start=start, stop=stop) result = cmd('continue') diff --git a/tests/unit/api/test_base.py b/tests/unit/api/test_base.py index 853464b..d795ea7 100644 --- a/tests/unit/api/test_base.py +++ b/tests/unit/api/test_base.py @@ -11,6 +11,8 @@ class RandomEntity(base.TogglEntity): + _endpoints_name = 'random_entities' + some_field = fields.StringField() @@ -149,14 +151,14 @@ def test_unbound_set(self): tset.filter() with pytest.raises(exceptions.TogglException): - tset.base_url + tset.entity_endpoints_name def test_url(self): tset = base.TogglSet(url='http://some-url.com') - assert tset.base_url == 'http://some-url.com' + assert tset.entity_endpoints_name == 'http://some-url.com' tset = base.TogglSet(RandomEntity) - assert tset.base_url == 'random_entitys' + assert tset.entity_endpoints_name == 'random_entities' def test_can_get_detail(self): tset = base.TogglSet(can_get_detail=False) @@ -189,9 +191,8 @@ def test_can_get_list(self): def test_get_detail_basic(self, mocker): mocker.patch.object(utils, 'toggl') utils.toggl.return_value = { - 'data': { - 'some_field': 'asdf' - } + 'id': 123, + 'some_field': 'asdf' } tset = base.TogglSet(RandomEntity) @@ -202,9 +203,7 @@ def test_get_detail_basic(self, mocker): def test_get_detail_none(self, mocker): mocker.patch.object(utils, 'toggl') - utils.toggl.return_value = { - 'data': None - } + utils.toggl.return_value = None tset = base.TogglSet(RandomEntity) obj = tset.get(id=123) @@ -477,6 +476,8 @@ class ExtendedMetaTestEntityWithConflicts(MetaTestEntity): ## TogglEntity class Entity(base.TogglEntity): + _endpoints_name = "entities" + string = fields.StringField() integer = fields.IntegerField() boolean = fields.BooleanField() @@ -616,9 +617,7 @@ def test_copy(self): def test_save_create(self, mocker): mocker.patch.object(utils, 'toggl') utils.toggl.return_value = { - 'data': { - 'id': 333 - } + 'id': 333 } obj = Entity(string='asd', integer=123) diff --git a/toggl/api/__init__.py b/toggl/api/__init__.py index 12e0387..bc082e6 100644 --- a/toggl/api/__init__.py +++ b/toggl/api/__init__.py @@ -1 +1,13 @@ -from toggl.api.models import Client, Workspace, Project, User, WorkspaceUser, ProjectUser, TimeEntry, Task, Tag +from toggl.api.models import ( + Client, + Workspace, + Project, + User, + WorkspaceUser, + ProjectUser, + TimeEntry, + Task, + Tag, + InvitationResult, + Organization, +) diff --git a/toggl/api/base.py b/toggl/api/base.py index 65c37f8..f9add46 100644 --- a/toggl/api/base.py +++ b/toggl/api/base.py @@ -115,7 +115,7 @@ def bind_to_class(self, cls): # type: (Entity) -> None self.entity_cls = cls @property - def base_url(self): # type: (TogglSet) -> str + def entity_endpoints_name(self): # type: (TogglSet) -> str """ Returns base URL which will be used for building listing or detail URLs. """ @@ -125,7 +125,7 @@ def base_url(self): # type: (TogglSet) -> str if self.entity_cls is None: raise exceptions.TogglException('The TogglSet instance is not binded to any TogglEntity!') - return self.entity_cls.get_name() + 's' + return self.entity_cls.get_endpoints_name() def build_list_url(self, caller, config, conditions): # type: (str, utils.Config, typing.Dict) -> str """ @@ -136,17 +136,18 @@ def build_list_url(self, caller, config, conditions): # type: (str, utils.Confi :param conditions: If caller == 'filter' then contain conditions for filtering. Passed as reference, therefore any modifications will result modifications """ - return '/{}'.format(self.base_url) + return '/me/{}'.format(self.entity_endpoints_name) - def build_detail_url(self, eid, config): # type: (int, utils.Config) -> str + def build_detail_url(self, eid, config, conditions): # type: (int, utils.Config, typing.Dict) -> str """ Build the detail URL. :param eid: ID of the entity to fetch. :param config: Config + :param conditions: If caller == 'filter' then contain conditions for filtering. Passed as reference, + therefore any modifications will result modifications """ - return '/{}/{}'.format(self.base_url, eid) - + return '/me/{}/{}'.format(self.entity_endpoints_name, eid) @property def can_get_detail(self): # type: (TogglSet) -> bool @@ -174,7 +175,7 @@ def can_get_list(self): # type: (TogglSet) -> bool return True - def get(self, id=None, config=None, **conditions): # type: (typing.Any, utils.Config, **typing.Any) -> Entity + def get(self, id=None, config=None, **conditions): # type: (typing.Any, utils.Config, **typing.Any) -> typing.Optional[Entity] """ Method for fetching detail object of the entity. it fetches the object based on specified conditions. @@ -194,7 +195,7 @@ def get(self, id=None, config=None, **conditions): # type: (typing.Any, utils.C if id is not None: if self.can_get_detail: try: - fetched_entity = utils.toggl(self.build_detail_url(id, config), 'get', config=config) + fetched_entity = utils.toggl(self.build_detail_url(id, config, conditions), 'get', config=config) if fetched_entity is None: return None @@ -222,23 +223,16 @@ def _fetch_all(self, url, order, config): # type: (str, str, utils.Config) -> t Helper method that fetches all objects from given URL and deserialize them. """ fetched_entities = utils.toggl(url, 'get', config=config) - + if isinstance(fetched_entities, dict): fetched_entities = fetched_entities.get('data') - + if fetched_entities is None: return [] - output = [] - i = 0 if order == 'asc' else len(fetched_entities) - 1 - while 0 <= i < len(fetched_entities): - output.append(self.entity_cls.deserialize(config=config, **fetched_entities[i])) - - if order == 'asc': - i += 1 - else: - i -= 1 - + output = [self.entity_cls.deserialize(config=config, **entry) for entry in fetched_entities] + if order == 'desc': + return output[::-1] return output def filter(self, order='asc', config=None, contain=False, **conditions): # type: (str, utils.Config, bool, **typing.Any) -> typing.List[Entity] @@ -295,20 +289,33 @@ def __str__(self): return 'TogglSet<{}>'.format(self.entity_cls.__name__) -class WorkspaceTogglSet(TogglSet): +class WorkspacedTogglSet(TogglSet): """ Specialized TogglSet for Workspaced entries. """ - def build_list_url(self, caller, config, conditions): # type: (str, utils.Config, typing.Dict) -> str + @classmethod + def _get_workspace_id(cls, config, conditions): # type: (utils.Config, typing.Dict) -> int if conditions.get('workspace') is not None: - wid = conditions['workspace'].id + return conditions['workspace'].id elif conditions.get('workspace_id') is not None: - wid = conditions['workspace_id'] + return conditions['workspace_id'] else: - wid = conditions.get('wid') or config.default_workspace.id + return conditions.get('wid') or config.default_workspace.id - return f'/workspaces/{wid}/{self.base_url}' + def build_list_url(self, caller, config, conditions): # type: (str, utils.Config, typing.Dict) -> str + wid = self._get_workspace_id(config, conditions) + return f'/workspaces/{wid}/{self.entity_endpoints_name}' + + def build_detail_url(self, eid, config, conditions): # type: (int, utils.Config, typing.Dict) -> str + """ + Build the detail URL. + + :param eid: ID of the entity to fetch. + :param config: Config + """ + wid = self._get_workspace_id(config, conditions) + return f"/workspaces/{wid}/{self.entity_endpoints_name}/{eid}" class TogglEntityMeta(ABCMeta): @@ -379,7 +386,7 @@ def __new__(mcs, name, bases, attrs, **kwargs): # Add objects only if they are not defined to allow custom TogglSet implementations if 'objects' not in new_class.__dict__: - setattr(new_class, 'objects', WorkspaceTogglSet(new_class)) + setattr(new_class, 'objects', WorkspacedTogglSet(new_class)) else: try: new_class.objects.bind_to_class(new_class) @@ -403,6 +410,7 @@ class TogglEntity(metaclass=TogglEntityMeta): __signature__ = Signature() __fields__ = OrderedDict() + _endpoints_name = None _validate_workspace = True _can_create = True _can_update = True @@ -520,6 +528,10 @@ def to_dict(self, serialized=False, changes_only=False): # type: (bool, bool) - :param serialized: If True, the returned dict contains only Python primitive types and no objects (eq. so JSON serialization could happen) :param changes_only: If True, the returned dict contains only changes to the instance since last call of save() method. """ + from .models import WorkspacedEntity + workspace = self.workspace if isinstance(self, WorkspacedEntity) else self + allow_premium = getattr(workspace, "premium", False) + source_dict = self.__change_dict__ if changes_only else self.__fields__ entity_dict = {} for field_name in source_dict.keys(): @@ -528,6 +540,9 @@ def to_dict(self, serialized=False, changes_only=False): # type: (bool, bool) - except KeyError: field = self.__mapped_fields__[field_name] + if field.premium and not allow_premium: + continue + try: value = field._get_value(self) except AttributeError: @@ -574,8 +589,13 @@ def get_name(cls, verbose=False): # type: (bool) -> str return name + @classmethod + def get_endpoints_name(self): # type: () -> str + assert self._endpoints_name is not None + return self._endpoints_name + def get_url(self): # type: () -> str - return self.get_name() + 's' + return self.get_endpoints_name() @classmethod def deserialize(cls, config=None, **kwargs): # type: (utils.Config, **typing.Any) -> typing.Generic[Entity] diff --git a/toggl/api/fields.py b/toggl/api/fields.py index 0eb6241..d66ced3 100644 --- a/toggl/api/fields.py +++ b/toggl/api/fields.py @@ -70,7 +70,7 @@ class TogglField(typing.Generic[T]): read = True # type: bool """ Attribute 'read' specifies if user can get value from the field. - + It represents fields that are not returned from server, but you can only pass value to them. It is allowed to read from the field once you set some value to it, but not before """ @@ -212,7 +212,7 @@ def __get__(self, instance, owner): # type: (typing.Optional['base.Entity'], ty raise exceptions.TogglNotAllowedException('You are not allowed to read from \'{}\' attribute!' .format(self.name)) - # When instance is None, then the descriptor as accessed directly from class and not its instance + # When instance is None, then the descriptor as accessed directly from class and not its instance # ==> return the descriptors instance. if instance is None: return self @@ -332,6 +332,9 @@ def __set__(self, instance, value): # type: (typing.Optional['base.Entity'], ty super().__set__(instance, value) def parse(self, value, instance): # type: (str, base.Entity) -> pendulum.DateTime + if value is None: + return super().parse(value, instance) + config = getattr(instance, '_config', None) or utils.Config.factory() if isinstance(value, datetime.datetime): diff --git a/toggl/api/models.py b/toggl/api/models.py index 55dab7a..a790c7a 100644 --- a/toggl/api/models.py +++ b/toggl/api/models.py @@ -2,6 +2,7 @@ import logging import typing from copy import copy +from typing import TypedDict from urllib.parse import quote_plus from validate_email import validate_email @@ -14,8 +15,150 @@ logger = logging.getLogger('toggl.api.models') +class OrganizationToggleSet(base.TogglSet): + """ + Specialized TogglSet for organization entities + """ + + def build_detail_url(self, eid, config, conditions): # type: (int, utils.Config, typing.Dict) -> str + return '/{}/{}'.format(self.entity_endpoints_name, eid) + + +class InvitationResult(TypedDict): + """ + API result for creating new invitations + """ + email: str + invitation_id: int + invite_url: str + organization_id: int + recipient_id: int + sender_id: int + + +# Organization entity +class Organization(base.TogglEntity): + _endpoints_name = "organizations" + _can_create = False # TODO: implement + _can_delete = False # TODO: implement + + name = fields.StringField(required=True) + """ + Name of the organization + """ + + admin = fields.BooleanField() + """ + Shows whether the current request user is an admin of the organization + """ + + at = fields.StringField() + """ + Organization's last modification date + """ + + created_at = fields.StringField() + """ + Organization's creation date + """ + + is_multi_workspace_enabled = fields.BooleanField() + """ + Is true when the organization option is_multi_workspace_enabled is set + """ + + is_unified = fields.BooleanField() + + max_data_retention_days = fields.IntegerField() + """ + How far back free workspaces in this org can access data. + """ + + max_workspaces = fields.IntegerField() + """ + Maximum number of workspaces allowed for the organization + """ + + owner = fields.BooleanField() + """ + Whether the requester is a the owner of the organization + """ + + payment_methods = fields.StringField() + """ + Organization's subscription payment methods. Omitted if empty. + """ + + permissions = fields.StringField() + + pricing_plan_enterprise = fields.BooleanField() + """ + The subscription plan is an enterprise plan + """ + + pricing_plan_id = fields.IntegerField() # TODO: map entity? + """ + Organization plan ID + """ + + pricing_plan_name = fields.StringField() + """ + The subscription plan name the org is currently on. Free or any plan name coming from payment provider + """ + + server_deleted_at = fields.StringField() + """ + Organization's delete date + """ + + suspended_at = fields.StringField() + """ + Whether the organization is currently suspended + """ + + user_count = fields.IntegerField() + """ + Number of organization users + """ + + objects = OrganizationToggleSet() + + def invite(self, workspace, *emails, admin=False, role=None): # type: (Workspace, typing.Collection[str], bool, typing.Optional[str]) -> list[InvitationResult] + """ + Invites users defined by email addresses. The users does not have to have account in Toggl, in that case after + accepting the invitation, they will go through process of creating the account in the Toggl web. + + :param workspace: The workspace to invite users to. + :param emails: List of emails to invite. + :param admin: Whether the invited users should be admins. + :param role: Role of the invited users. + :return: None + """ + for email in emails: + if not validate_email(email): + raise exceptions.TogglValidationException(f'Supplied email \'{email}\' is not valid email!') + + workspace_invite_data = {'workspace_id': workspace.id, 'admin': admin} + if role: + workspace_invite_data['role'] = role + json_data = json.dumps({'emails': emails, 'workspaces': [workspace_invite_data]}) + + result = utils.toggl("/organizations/{}/invitations".format(self.id), "post", json_data, config=self._config) + return [InvitationResult(**invite) for invite in result['data']] + + +class WorkspaceToggleSet(base.TogglSet): + """ + Specialized TogglSet for workspace entities (not to be confused with :class:`base.WorkspacedTogglSet` + """ + + def build_detail_url(self, eid, config, conditions): # type: (int, utils.Config, typing.Dict) -> str + return '/{}/{}'.format(self.entity_endpoints_name, eid) + + # Workspace entity class Workspace(base.TogglEntity): + _endpoints_name = "workspaces" _can_create = False _can_delete = False @@ -54,6 +197,8 @@ class Workspace(base.TogglEntity): Whether only the admins can see team dashboard or everybody """ + organization = fields.MappingField(Organization, 'organization_id') # type: Organization + rounding = fields.IntegerField() """ Type of rounding: @@ -83,27 +228,21 @@ class Workspace(base.TogglEntity): ical_url = fields.StringField() logo_url = fields.StringField() - # As TogglEntityMeta is by default adding WorkspaceTogglSet to TogglEntity, - # but we want vanilla TogglSet so defining it here explicitly. - objects = base.TogglSet() + # As TogglEntityMeta is by default adding WorkspacedTogglSet to TogglEntity, + # but we want WorkspaceToggleSet so defining it here explicitly. + objects = WorkspaceToggleSet() - def invite(self, *emails): # type: (str) -> None + def invite(self, *emails, admin=False, role=None): # type: (Workspace, typing.Collection[str], bool, typing.Optional[str]) -> list[InvitationResult] """ Invites users defined by email addresses. The users does not have to have account in Toggl, in that case after accepting the invitation, they will go through process of creating the account in the Toggl web. :param emails: List of emails to invite. + :param admin: Whether the invited users should be admins. + :param role: Role of the invited users. :return: None """ - for email in emails: - if not validate_email(email): - raise exceptions.TogglValidationException(f'Supplied email \'{email}\' is not valid email!') - - emails_json = json.dumps({'emails': emails}) - data = utils.toggl("/workspaces/{}/invite".format(self.id), "post", emails_json, config=self._config) - - if 'notifications' in data and data['notifications']: - raise exceptions.TogglException(data['notifications']) + return self.organization.invite(self, *emails, admin=admin, role=role) class WorkspacedEntity(base.TogglEntity): @@ -118,7 +257,7 @@ class WorkspacedEntity(base.TogglEntity): """ def get_url(self): # type: () -> str - return f'workspaces/{self.workspace.id}/{self.get_name()}s' + return f'workspaces/{self.workspace.id}/{self.get_endpoints_name()}' # Premium Entity @@ -129,7 +268,7 @@ class PremiumEntity(WorkspacedEntity): def save(self, config=None): # type: (utils.Config) -> None if not self.workspace.premium: - raise exceptions.TogglPremiumException(f'The entity {self.get_name()} requires to be associated with Premium workspace!') + raise exceptions.TogglPremiumException(f'The entity {self.get_name(verbose=True)} requires to be associated with Premium workspace!') super().save(config) @@ -142,23 +281,30 @@ class Client(WorkspacedEntity): Client entity """ + _endpoints_name = "clients" + name = fields.StringField(required=True) """ Name of client (Required) """ + notes = fields.StringField() + + class Project(WorkspacedEntity): """ Project entity """ + _endpoints_name = "projects" + name = fields.StringField(required=True) """ Name of the project. (Required) """ - client = fields.MappingField(Client, 'cid') + client = fields.MappingField(Client, 'client_id') """ Client associated to the project. """ @@ -197,11 +343,6 @@ class Project(WorkspacedEntity): color = fields.StringField() """ - Id of the color selected for the project - """ - - hex_color = fields.StringField() - """ Hex code of the color selected for the project """ @@ -227,7 +368,7 @@ def add_user(self, user, manager=False, rate=None) : # type: (User, bool, typin return project_user -class UserSet(base.WorkspaceTogglSet): +class UserSet(base.WorkspacedTogglSet): def current_user(self, config=None): # type: (utils.Config) -> 'User' """ @@ -242,6 +383,7 @@ class User(WorkspacedEntity): User entity. """ + _endpoints_name = "users" _can_create = False _can_update = False _can_delete = False @@ -337,7 +479,7 @@ def signup(cls, email, password, timezone=None, created_with='TogglCLI', 'timezone': timezone, 'created_with': created_with }}) - data = utils.toggl("/signups", "post", user_json, config=config) + data = utils.toggl("/signup", "post", user_json, config=config) return cls.deserialize(config=config, **data) def is_admin(self, workspace): @@ -358,6 +500,7 @@ class WorkspaceUser(WorkspacedEntity): It additionally configures access rights and several other things. """ + _endpoints_name = "workspace_users" _can_get_detail = False _can_create = False @@ -392,6 +535,8 @@ class ProjectUser(WorkspacedEntity): Similarly to WorkspaceUser, it is entity which represents assignment of specific User into Project. It additionally configures access rights and several other things. """ + + _endpoints_name = "project_users" _can_get_detail = False rate = fields.FloatField(admin_only=True) @@ -427,6 +572,8 @@ class Task(PremiumEntity): This entity is available only for Premium workspaces. """ + _endpoints_name = "tasks" + name = fields.StringField(required=True) """ Name of task @@ -463,6 +610,7 @@ class Tag(WorkspacedEntity): Tag entity """ + _endpoints_name = "tags" _can_get_detail = False name = fields.StringField(required=True) @@ -506,7 +654,7 @@ def get_duration(name, instance): # type: (str, base.Entity) -> int if instance.is_running: return instance.start.int_timestamp * -1 - return int((instance.stop - instance.start).in_seconds()) + return int((instance.stop.replace(microsecond=0) - instance.start.replace(microsecond=0)).in_seconds()) def set_duration(name, instance, value, init=False): # type: (str, base.Entity, typing.Optional[int], bool) -> typing.Optional[bool] @@ -549,14 +697,14 @@ def format_duration(value, config=None): # type: (int, utils.Config) -> str datetime_type = typing.Union[datetime.datetime, pendulum.DateTime] -class TimeEntrySet(base.TogglSet): +class TimeEntrySet(base.WorkspacedTogglSet): """ TogglSet which is extended by current() method which returns the currently running TimeEntry. Moreover it extends the filtrating mechanism by native filtering according start and/or stop time. """ def build_list_url(self, caller, config, conditions): # type: (str, utils.Config, typing.Dict) -> str - url = '/{}'.format(self.base_url) + url = '/me/{}'.format(self.entity_endpoints_name) if caller == 'filter': start = conditions.pop('start', None) @@ -573,6 +721,14 @@ def build_list_url(self, caller, config, conditions): # type: (str, utils.Confi return url + def build_detail_url(self, eid, config, conditions): # type: (int, utils.Config, typing.Dict) -> str + return '/me/{}/{}'.format(self.entity_endpoints_name, eid) + + def _fetch_all(self, url, order, config): # type: (str, str, utils.Config) -> typing.List[base.Entity] + output = super()._fetch_all(url, order, config) + output.sort(key=lambda e: e.start, reverse=(order == 'desc')) + return output + def current(self, config=None): # type: (utils.Config) -> typing.Optional[TimeEntry] """ Method that returns currently running TimeEntry or None if there is no currently running time entry. @@ -581,9 +737,9 @@ def current(self, config=None): # type: (utils.Config) -> typing.Optional[TimeE :return: """ config = config or utils.Config.factory() - fetched_entity = utils.toggl('/time_entries/current', 'get', config=config) + fetched_entity = utils.toggl('/me/time_entries/current', 'get', config=config) - if fetched_entity.get('data') is None: + if fetched_entity is None: return None return self.entity_cls.deserialize(config=config, **fetched_entity) @@ -665,12 +821,14 @@ def all_from_reports(self, start=None, stop=None, workspace=None, config=None): class TimeEntry(WorkspacedEntity): + _endpoints_name = "time_entries" + description = fields.StringField() """ Description of the entry. """ - project = fields.MappingField(Project, 'pid') + project = fields.MappingField(Project, 'project_id') """ Project to which the Time entry is linked to. """ @@ -729,10 +887,6 @@ def __init__(self, start, stop=None, duration=None, **kwargs): super().__init__(start=start, stop=stop, duration=duration, **kwargs) - @classmethod - def get_url(self): - return 'time_entries' - def to_dict(self, serialized=False, changes_only=False): # Enforcing serialize duration when start or stop changes if changes_only and (self.__change_dict__.get('start') or self.__change_dict__.get('stop')): diff --git a/toggl/cli/commands.py b/toggl/cli/commands.py index 8d2fbdf..ec72ff0 100644 --- a/toggl/cli/commands.py +++ b/toggl/cli/commands.py @@ -219,9 +219,11 @@ def entry_ls(ctx, fields, today, use_reports, limit, **conditions): """ Lists time entries the user has access to. - By default the command uses API call that has limited number of time entries fetched with 1000 entries in - last 9 days. If you want to overcome this limitation use --use-reports flag, that will use different - API call, which overcomes this limitations but currently support only --start/--stop filtration. + By default the entries of the last 9 days are fetched, to keep compatibility with older API versions. + If you want to select a different time range, use --start and --stop flags. + In general, the --start/--stop API is limited to entries from the last 3 months, # TODO check + if you want to overcome this limitation use --use-reports flag, that will use different + API call. The list visible through this utility and on toggl's web client might differ in the range @@ -238,6 +240,11 @@ def entry_ls(ctx, fields, today, use_reports, limit, **conditions): conditions['start'] = pendulum.today() conditions['stop'] = pendulum.tomorrow() + if not conditions.get('start'): + conditions['start'] = pendulum.now() - pendulum.duration(days=9) + if not conditions.get("stop"): + conditions['stop'] = pendulum.now() + entities = get_entries(ctx, use_reports, **conditions) if limit: @@ -533,12 +540,15 @@ def entry_continue(ctx, descr, start): The underhood behaviour of Toggl is that it actually creates a new entry with the same description. """ + config = ctx.obj['config'] entry = None try: if descr is None: - entry = api.TimeEntry.objects.all(order='desc', config=ctx.obj['config'])[0] + entry = api.TimeEntry.objects.current(config=config) + if entry is None: + entry = api.TimeEntry.objects.all(order='desc', config=config)[0] else: - entry = api.TimeEntry.objects.filter(contain=True, description=descr, config=ctx.obj['config'])[0] + entry = api.TimeEntry.objects.filter(contain=True, description=descr, config=config)[0] except IndexError: click.echo('You don\'t have any time entries in past 9 days!') exit(1) @@ -662,7 +672,7 @@ def projects(ctx, workspace): help='Specifies whether the estimated hours should be automatically calculated based on task estimations ' '(Premium only)') @click.option('--rate', '-r', type=click.FLOAT, help='Hourly rate of the project (Premium only)') -@click.option('--color', type=click.INT, default=1, help='ID of color used for the project') +@click.option('--color', type=click.STRING, default="#0b83d9", help='Hex code of color used for the project') @click.pass_context def projects_add(ctx, public=None, **kwargs): """ @@ -693,7 +703,7 @@ def projects_add(ctx, public=None, **kwargs): help='Specifies whether the estimated hours are automatically calculated based on task estimations or' ' manually fixed based on the value of \'estimated_hours\' (Premium only)') @click.option('--rate', '-r', type=click.FLOAT, help='Hourly rate of the project (Premium only)') -@click.option('--color', type=click.INT, help='ID of color used for the project') +@click.option('--color', type=click.STRING, default="#0b83d9", help='Hex code of color used for the project') @click.pass_context def projects_update(ctx, spec, **kwargs): """ @@ -862,11 +872,11 @@ def workspace_users(ctx, workspace): ctx.obj['workspace'] = workspace or ctx.obj['config'].default_workspace +# TODO: fix with newer organization API @workspace_users.command('invite', short_help='invite an user into workspace') -@click.option('--email', '-e', help='Email address of the user to invite into the workspace', - prompt='Email address of the user to invite into the workspace') +@click.argument('emails', nargs=-1) @click.pass_context -def workspace_users_invite(ctx, email): +def workspace_users_invite(ctx, emails): """ Invites an user into the workspace. @@ -874,9 +884,19 @@ def workspace_users_invite(ctx, email): After the invitation is sent, the user needs to accept invitation to be fully part of the workspace. """ workspace = ctx.obj['workspace'] - workspace.invite(email) + invitations = workspace.invite(*emails) - click.echo("User '{}' was successfully invited! He needs to accept the invitation now.".format(email)) + click.echo( + "Invites successfully sent! Invited users need to accept the invitation now." + ) + click.echo( + "Created invites IDs:\n{}".format( + "\n".join( + "- #{}: email {}".format(invite["invitation_id"], invite["email"]) + for invite in invitations + ) + ) + ) @workspace_users.command('ls', short_help='list workspace\'s users') diff --git a/toggl/utils/others.py b/toggl/utils/others.py index 4a700da..5e8969f 100644 --- a/toggl/utils/others.py +++ b/toggl/utils/others.py @@ -91,6 +91,7 @@ def convert_credentials_to_api_token(username, password): def handle_error(response): + logger.debug(f"Handling error for {response.status_code}: {response.text}") if response.status_code == 402: raise exceptions.TogglPremiumException( "Request tried to utilized Premium functionality on workspace which is not Premium!" @@ -165,7 +166,7 @@ def toggl(url, method, data=None, headers=None, config=None, address=None): try: logger.debug('Default workspace: {}'.format(config._default_workspace)) response = _toggl_request(url, method, data, headers, config.get_auth()) - response_json = response.json() + response_json = response.json() if response.text else None logger.debug('Response {}:\n{}'.format(response.status_code, pformat(response_json))) return response_json except (exceptions.TogglThrottlingException, requests.exceptions.ConnectionError) as e: From 9084654942deab2fafd3af34a85a2c1358da0978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Mon, 3 Jun 2024 08:19:24 +0200 Subject: [PATCH 4/4] feat: finishing --- CONTRIBUTING.md | 15 ++++++++++++--- pytest.ini | 2 +- requirements.txt | 6 +++--- tests/configs/non-premium.config | 3 +-- tests/configs/premium.config | 2 +- toggl/api/base.py | 7 +++++++ toggl/api/models.py | 20 +++++++++++++++++--- toggl/cli/commands.py | 4 ++-- toggl/toggl.py | 3 +++ toggl/utils/config.py | 6 +++--- toggl/utils/migrations.py | 2 +- 11 files changed, 51 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e3839a..aaef633 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,11 +4,20 @@ Any contribution are welcomed. For submitting PRs, they need to have test coverage which pass the full run in Travis CI. +## Developing + +If you want to run the toggl CLI during development, I recommend you to use flow where you `pip install -e .`, which +symlinks locally the package and then you can simply use the CLI like `toggl ls`. + +Also, if you find yourself with non-descriptive exception, you can set env. variable `export TOGGL_EXCEPTIONS=1` which +then will give you then the full stack trace. + ## Tests For running integration tests you need dummy account on Toggl, where **you don't have any important data** as the data will be messed up with and eventually **deleted**! Get API token for this test account and set it as an environmental variable -***TOGGL_API_TOKEN***. +`TOGGL_API_TOKEN`. Also figure out the Workspace ID of your account (`toggl workspace ls`) and set is as `TOGGL_WORKSPACE` +environmental variable. There are two sets of integration tests: normal and premium. To be able to run the premium set you have to have payed workspace. As this is quiet unlikely you can leave the testing on Travis CI as it runs also the premium tests set. @@ -20,8 +29,8 @@ Tests are written using `pytest` framework and are split into three categories ( ## Running tests -In order to run tests first you need to have required packages installed. You can install them using `pip install togglCli[test]` or -`python setup.py test`. +In order to run tests first you need to have required packages installed. You can install them using `pip install togglCli[test]`, +`python setup.py test` or `pip install -r test-requirements.txt`. By default unit and integration tests are run without the one testing premium functionality, as most probably you don't have access to Premium workspace for testing purposes. If you want to run just specific category you can do so using for example`pytest -m unit` for only unit tests. diff --git a/pytest.ini b/pytest.ini index 337cef2..49f038e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ addopts = --cov toggl -m "not premium" --maxfail=20 markers = unit: Unit tests testing framework. No outside dependencies (no out-going requests) integration: Integration tests which tests end to end coherence of API wrapper. Requires connectivity to Toggl API. - premium: Subcategory of Integration tests that requires to have Premium/Paid workspace for the tests. \ No newline at end of file + premium: Subcategory of Integration tests that requires to have Premium/Paid workspace for the tests. diff --git a/requirements.txt b/requirements.txt index 762e25a..64cc805 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ setuptools==69.0.3 pendulum==3.0.0 requests>=2.23.0 -click==8.1.3 -inquirer==2.9.1 +click==8.1.7 +inquirer==3.2.4 prettytable==3.6.0 validate_email==1.3 click-completion==0.5.2 -pbr==5.8.0 +pbr==6.0.0 notify-py==0.3.42 diff --git a/tests/configs/non-premium.config b/tests/configs/non-premium.config index dabf634..b5b7b47 100644 --- a/tests/configs/non-premium.config +++ b/tests/configs/non-premium.config @@ -3,5 +3,4 @@ version = 2.0.0 [options] tz = utc -default_wid = 3057440 - +default_wid = 8379305 diff --git a/tests/configs/premium.config b/tests/configs/premium.config index 489f0e6..b5b7b47 100644 --- a/tests/configs/premium.config +++ b/tests/configs/premium.config @@ -3,4 +3,4 @@ version = 2.0.0 [options] tz = utc -default_wid = 2609276 +default_wid = 8379305 diff --git a/toggl/api/base.py b/toggl/api/base.py index f9add46..172a846 100644 --- a/toggl/api/base.py +++ b/toggl/api/base.py @@ -24,6 +24,8 @@ def evaluate_conditions(conditions, entity, contain=False): # type: (typing.Dic :param conditions: dict :return: """ + logger.debug(f'EvaluatingConditions: Filtering based on conditions: {conditions}') + for key, value in conditions.items(): try: field = entity.__fields__[key] @@ -31,6 +33,7 @@ def evaluate_conditions(conditions, entity, contain=False): # type: (typing.Dic try: field = entity.__mapped_fields__[key] except KeyError: + logger.debug(f'EvaluatingConditions: Field {key} not found in entity {entity}') return False if isinstance(field, model_fields.MappingField): @@ -47,6 +50,7 @@ def evaluate_conditions(conditions, entity, contain=False): # type: (typing.Dic continue if value != mapped_entity_id: + logger.debug(f'EvaluatingConditions: Mapped entity\'s ID does not match') return False continue @@ -82,6 +86,7 @@ def evaluate_conditions(conditions, entity, contain=False): # type: (typing.Dic continue if str(entity_value) != str(value): + logger.debug(f'EvaluatingConditions: String values do not match: {entity_value} != {value}') return False return True @@ -259,6 +264,8 @@ def filter(self, order='asc', config=None, contain=False, **conditions): # type if fetched_entities is None: return [] + logger.debug(f'Filter: Fetched {fetched_entities} entities') + # There are no specified conditions ==> return all if not conditions: return fetched_entities diff --git a/toggl/api/models.py b/toggl/api/models.py index a790c7a..543c827 100644 --- a/toggl/api/models.py +++ b/toggl/api/models.py @@ -259,6 +259,20 @@ class WorkspacedEntity(base.TogglEntity): def get_url(self): # type: () -> str return f'workspaces/{self.workspace.id}/{self.get_endpoints_name()}' +class OldWorkspacedEntity(base.TogglEntity): + """ + Abstract entity which has linked Workspace + """ + + workspace = fields.MappingField(Workspace, 'wid', write=False, + default=lambda config: config.default_workspace) # type: Workspace + """ + Workspace to which the resource is linked to. + """ + + def get_url(self): # type: () -> str + return f'workspaces/{self.workspace.id}/{self.get_endpoints_name()}' + # Premium Entity class PremiumEntity(WorkspacedEntity): @@ -276,7 +290,7 @@ def save(self, config=None): # type: (utils.Config) -> None # ---------------------------------------------------------------------------- # Entities definitions # ---------------------------------------------------------------------------- -class Client(WorkspacedEntity): +class Client(OldWorkspacedEntity): """ Client entity """ @@ -551,12 +565,12 @@ class ProjectUser(WorkspacedEntity): Admin rights for this project """ - project = fields.MappingField(Project, 'pid', write=False) + project = fields.MappingField(Project, 'project_id', write=False) """ Project to which the User is assigned. """ - user = fields.MappingField(User, 'uid', write=False) + user = fields.MappingField(User, 'user_id', write=False) """ User which is linked to Project. """ diff --git a/toggl/cli/commands.py b/toggl/cli/commands.py index ec72ff0..bd67d13 100644 --- a/toggl/cli/commands.py +++ b/toggl/cli/commands.py @@ -622,7 +622,7 @@ def clients_ls(ctx): def clients_get(ctx, spec): """ Gets details of a client specified by SPEC argument. SPEC can be either ID or Name of the client. - Be aware that if you use specify SPEC using Name you won't get note for this client. + Be aware that if you specify SPEC using Name you won't get note for this client. If SPEC is Name, then the lookup is done in the default workspace, unless --workspace is specified. """ @@ -769,7 +769,7 @@ def project_users_ls(ctx, fields): project = ctx.obj['project'] src = api.ProjectUser.objects.filter(project=project, config=ctx.obj['config']) - helpers.entity_listing(src, fields) + helpers.entity_listing(src, fields, obj=ctx.obj) @project_users.command('add', short_help='add a user into the project') diff --git a/toggl/toggl.py b/toggl/toggl.py index b73100c..81558b4 100644 --- a/toggl/toggl.py +++ b/toggl/toggl.py @@ -10,3 +10,6 @@ def main(args=None): """Main entry point for Toggl CLI application""" cli.entrypoint(args or sys.argv[1:]) + +if __name__ == "__main__": + main() diff --git a/toggl/utils/config.py b/toggl/utils/config.py index 805e81e..472e12b 100644 --- a/toggl/utils/config.py +++ b/toggl/utils/config.py @@ -64,15 +64,15 @@ class IniConfigMixin: } _old_file_path = Path.expanduser(Path('~/.togglrc')) - + if "XDG_CONFIG_HOME" in os.environ: _new_file_path = Path(os.environ["XDG_CONFIG_HOME"]).joinpath(".togglrc") - + if _new_file_path.exists() or not _old_file_path.exists(): DEFAULT_CONFIG_PATH = _new_file_path else: DEFAULT_CONFIG_PATH = _old_file_path - + else: DEFAULT_CONFIG_PATH = _old_file_path diff --git a/toggl/utils/migrations.py b/toggl/utils/migrations.py index e011138..fd68896 100644 --- a/toggl/utils/migrations.py +++ b/toggl/utils/migrations.py @@ -101,7 +101,7 @@ def migrate_timezone(parser): # type: (configparser.ConfigParser) -> None click.echo('We have not recognized your timezone!') new_tz = inquirer.shortcuts.text( 'Please enter valid timezone. Default is your system\'s timezone.', - default='local', validate=lambda _, i: i in pendulum.timezones or i == 'local') + default='local', validate=lambda _, i: i in pendulum.timezones() or i == 'local') parser.set('options', 'tz', new_tz) @classmethod