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: