Skip to content

Commit

Permalink
Allow to change user's password, manage preferences and fetch feature…
Browse files Browse the repository at this point in the history
…s list (#49)

- Add BaseSchema for all pydantic schemas
- Add common helper for response status check
  • Loading branch information
nifadyev authored Jul 29, 2024
1 parent d4d9aba commit d7f075e
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 25 deletions.
34 changes: 32 additions & 2 deletions tests/responses/me_get.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
"intercom_hash": "78hcsq59lsca33ivsd5iwy42yu3gdf0sctutuku5gvjfk1qbj71puu7r1z74dzdp",
"openid_email": None,
"openid_enabled": False,
"options": [],
"timezone": "Europe/London",
"toggl_accounts_id": "uWGsHAeXZGhJvQ3XjdY63h",
"updated_at": "2024-05-16T12:01:24.447981Z",
Expand All @@ -65,7 +64,6 @@
"intercom_hash": "78hcsq59lsca33ivsd5iwy42yu3gdf0sctutuku5gvjfk1qbj71puu7r1z74dzdp",
"openid_email": None,
"openid_enabled": False,
"options": [],
"timezone": "Europe/London",
"toggl_accounts_id": "uWGsHAeXZGhJvQ3XjdY63h",
"updated_at": "2024-05-16T12:01:24.447981Z",
Expand All @@ -75,3 +73,35 @@
"tags": [],
"workspaces": [],
}

ME_FEATURES_RESPONSE: List[Dict[str, Union[int, List[Dict]]]] = [
{
"features": [
{"enabled": True, "feature_id": 0, "name": "free"},
{"enabled": False, "feature_id": 13, "name": "pro"},
{"enabled": False, "feature_id": 15, "name": "business"},
{"enabled": False, "feature_id": 55, "name": "tracking_reminders"},
{"enabled": False, "feature_id": 64, "name": "tasks"},
{"enabled": False, "feature_id": 65, "name": "project_dashboard"},
],
"workspace_id": 43644207,
}
]

ME_PREFERENCES_RESPONSE: Dict[str, Union[int, str, List[Dict]]] = {
"BeginningOfWeek": 1,
"alpha_features": [
{"code": "paging_project_list", "enabled": False},
{"code": "jira_v2", "enabled": False},
{"code": "alerts_v2", "enabled": True},
{"code": "analytics", "enabled": True},
],
"date_format": "MM/DD/YYYY",
"duration_format": "improved",
"pg_time_zone_name": "Europe/Moscow",
"record_timeline": False,
"send_product_emails": False,
"send_timer_notifications": True,
"send_weekly_report": False,
"timeofday_format": "H:mm",
}
1 change: 0 additions & 1 deletion tests/responses/me_put.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"image_url": "https://assets.track.toggl.com/images/profile.png",
"openid_email": None,
"openid_enabled": False,
"options": [],
"timezone": "Europe/London",
"toggl_accounts_id": "uWGsHAeXZGhJvQ3XjdY63h",
"toggl_accounts_updated_at": "2024-05-16T12:01:24.447981Z",
Expand Down
160 changes: 157 additions & 3 deletions tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,23 @@
from toggl_python.entities.user import CurrentUser
from toggl_python.exceptions import BadRequest
from toggl_python.schemas.current_user import (
DateFormat,
DurationFormat,
MeFeaturesResponse,
MePreferencesResponse,
MeResponse,
MeResponseWithRelatedData,
TimeFormat,
UpdateMeResponse,
)

from tests.responses.me_get import ME_RESPONSE, ME_RESPONSE_SHORT, ME_RESPONSE_WITH_RELATED_DATA
from tests.responses.me_get import (
ME_FEATURES_RESPONSE,
ME_PREFERENCES_RESPONSE,
ME_RESPONSE,
ME_RESPONSE_SHORT,
ME_RESPONSE_WITH_RELATED_DATA,
)
from tests.responses.me_put import UPDATE_ME_RESPONSE


Expand All @@ -40,7 +51,7 @@ def test_logged__exception_is_raised(
return_value=httpx.Response(status_code=403),
)

with pytest.raises(httpx.HTTPStatusError):
with pytest.raises(BadRequest):
_ = authed_current_user.logged()

assert mocked_route.called is True
Expand All @@ -62,7 +73,7 @@ def test_me__ok__with_empty_fields(response_mock: MockRouter) -> None:
mocked_route = response_mock.get("/me").mock(
return_value=httpx.Response(status_code=200, json=ME_RESPONSE_SHORT),
)
auth = BasicAuth(username="username", password="pass") # noqa: S106
auth = BasicAuth(username="username", password="pass")
user = CurrentUser(auth=auth)
expected_result = MeResponse.model_validate(ME_RESPONSE_SHORT)

Expand Down Expand Up @@ -211,3 +222,146 @@ def test_update_me__invalid_fullname(authed_current_user: CurrentUser) -> None:

with pytest.raises(ValidationError, match=error_message):
_ = authed_current_user.update_me(fullname="")


def test_change_password__ok(response_mock: MockRouter, authed_current_user: CurrentUser) -> None:
mocked_route = response_mock.put("/me").mock(
return_value=httpx.Response(status_code=200, json=UPDATE_ME_RESPONSE),
)

result = authed_current_user.change_password(
current_password="paSsw0rd",
new_password="neW_passw0rd",
)

assert mocked_route.called is True
assert result is True


def test_change_password__equal_current_and_new_passwords(
authed_current_user: CurrentUser,
) -> None:
error_message = "New password should differ from current password"

with pytest.raises(ValidationError, match=error_message):
_ = authed_current_user.change_password(
current_password="current_Passw0rd", new_password="current_Passw0rd"
)


def test_change_password__invalid_current_password(
response_mock: MockRouter, authed_current_user: CurrentUser
) -> None:
error_message = "Current password is not valid"
mocked_route = response_mock.put("/me").mock(
return_value=httpx.Response(status_code=400, text=error_message),
)

with pytest.raises(BadRequest, match=error_message):
_ = authed_current_user.change_password(
current_password="4incorrect_passworD",
new_password="New_passw0rd",
)

assert mocked_route.called is True


@pytest.mark.parametrize(
argnames=("value"),
argvalues=["1", "12345678", "12345Qw"],
ids=("Too short", "No symbols and chars", "No symbols"),
)
def test_update_me__weak_new_password(authed_current_user: CurrentUser, value: str) -> None:
error_message = "Password is too weak"

with pytest.raises(ValidationError, match=error_message):
_ = authed_current_user.change_password(
current_password="current_password",
new_password=value,
)


def test_features__ok(response_mock: MockRouter, authed_current_user: CurrentUser) -> None:
mocked_route = response_mock.get("/me/features").mock(
return_value=httpx.Response(status_code=200, json=ME_FEATURES_RESPONSE),
)
expected_result = [
MeFeaturesResponse.model_validate(workspace_features)
for workspace_features in ME_FEATURES_RESPONSE
]

result = authed_current_user.features()

assert mocked_route.called is True
assert result == expected_result


def test_preferences__ok(response_mock: MockRouter, authed_current_user: CurrentUser) -> None:
mocked_route = response_mock.get("/me/preferences").mock(
return_value=httpx.Response(status_code=200, json=ME_PREFERENCES_RESPONSE),
)
expected_result = MePreferencesResponse.model_validate(ME_PREFERENCES_RESPONSE)

result = authed_current_user.preferences()

assert mocked_route.called is True
assert result == expected_result


@pytest.mark.parametrize(
argnames=("field_name", "field_value"),
argvalues=[
("date_format", DateFormat.dmy_slash.value),
("duration_format", DurationFormat.classic.value),
("time_format", TimeFormat.hour_24.value),
],
)
def test_update_preferences__ok(
response_mock: MockRouter,
authed_current_user: CurrentUser,
field_name: str,
field_value: str,
) -> None:
payload = {field_name: field_value}
fake_response = ME_PREFERENCES_RESPONSE.copy()
fake_response.update(**payload)
mocked_route = response_mock.put("/me/preferences").mock(
return_value=httpx.Response(status_code=200, json=fake_response),
)
expected_result = MePreferencesResponse.model_validate(fake_response)

result = authed_current_user.update_preferences(**payload)

assert mocked_route.called is True
assert result == expected_result


def test_update_preferences__invalid_duration_format(authed_current_user: CurrentUser) -> None:
all_values = ", ".join(f"'{item.value}'" for item in DurationFormat)
last_value = DurationFormat.decimal.value
allowed_values = all_values.replace(f", '{last_value}'", f" or '{last_value}'")
error_message = f"Input should be {allowed_values}"

with pytest.raises(ValidationError, match=error_message):
_ = authed_current_user.update_preferences(duration_format="extended")


def test_update_preferences__invalid_time_format(authed_current_user: CurrentUser) -> None:
all_values = ", ".join(f"'{item.value}'" for item in TimeFormat)
last_value = TimeFormat.hour_24.value
allowed_values = all_values.replace(f", '{last_value}'", f" or '{last_value}'")
error_message = f"Input should be {allowed_values}"

with pytest.raises(ValidationError, match=error_message):
_ = authed_current_user.update_preferences(time_format="hh:mm B")


def test_update_preferences__invalid_date_format(authed_current_user: CurrentUser) -> None:
all_values = ", ".join(f"'{item.value}'" for item in DateFormat)
last_value = DateFormat.dmy_dot.value
allowed_values = all_values.replace(f", '{last_value}'", f" or '{last_value}'")
error_message = f"Input should be {allowed_values}"


with pytest.raises(ValidationError, match=error_message):
_ = authed_current_user.update_preferences(date_format="DDMMYY")
11 changes: 10 additions & 1 deletion toggl_python/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from typing import TYPE_CHECKING

from httpx import Client
from httpx import Client, HTTPStatusError, Response

from toggl_python.exceptions import BadRequest


if TYPE_CHECKING:
Expand All @@ -20,3 +22,10 @@ def __init__(self, auth: BasicAuth | TokenAuth) -> None:
headers=COMMON_HEADERS,
http2=True,
)

def raise_for_status(self, response: Response) -> None:
"""Disable exception chaining to avoid huge not informative traceback."""
try:
_ = response.raise_for_status()
except HTTPStatusError as base_exception:
raise BadRequest(base_exception.response.text) from None
81 changes: 69 additions & 12 deletions toggl_python/entities/user.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Optional

from httpx import HTTPStatusError
from typing import TYPE_CHECKING, List, Optional

from toggl_python.api import ApiWrapper
from toggl_python.exceptions import BadRequest
from toggl_python.schemas.current_user import (
DateFormat,
DurationFormat,
MeFeaturesResponse,
MePreferencesResponse,
MeResponse,
MeResponseWithRelatedData,
TimeFormat,
UpdateMePasswordRequest,
UpdateMePreferencesRequest,
UpdateMeRequest,
UpdateMeResponse,
)


if TYPE_CHECKING:
from pydantic import EmailStr


class CurrentUser(ApiWrapper):
prefix: str = "/me"

def logged(self) -> bool:
response = self.client.get(url=f"{self.prefix}/logged")
_ = response.raise_for_status()
self.raise_for_status(response)

# Returns 200 OK and empty response body
return response.is_success
Expand All @@ -32,7 +38,7 @@ def me(self, with_related_data: bool = False) -> MeResponse:
url=self.prefix,
params={"with_related_data": with_related_data},
)
_ = response.raise_for_status()
self.raise_for_status(response)

response_body = response.json()

Expand Down Expand Up @@ -62,12 +68,63 @@ def update_me(
payload = payload_schema.model_dump(mode="json", exclude_none=True, exclude_unset=True)

response = self.client.put(url=self.prefix, json=payload)

try:
_ = response.raise_for_status()
except HTTPStatusError as base_exception:
# Disable exception chaining to avoid huge not informative traceback
raise BadRequest(base_exception.response.text) from None
self.raise_for_status(response)

response_body = response.json()
return UpdateMeResponse.model_validate(response_body)

def change_password(self, current_password: str, new_password: str) -> bool:
"""Validate and change user password.
API response does not indicate about successful password change,
that is why return if response is successful.
"""
payload_schema = UpdateMePasswordRequest(
current_password=current_password, new_password=new_password
)
payload = payload_schema.model_dump_json()

response = self.client.put(url=self.prefix, content=payload)
self.raise_for_status(response)

return response.is_success

def features(self) -> List[MeFeaturesResponse]:
response = self.client.get(url=f"{self.prefix}/features")
self.raise_for_status(response)
response_body = response.json()

return [
MeFeaturesResponse.model_validate(workspace_features)
for workspace_features in response_body
]

def preferences(self) -> MePreferencesResponse:
response = self.client.get(url=f"{self.prefix}/preferences")
self.raise_for_status(response)
response_body = response.json()

return MePreferencesResponse.model_validate(response_body)

def update_preferences(
self,
date_format: Optional[DateFormat] = None,
duration_format: Optional[DurationFormat] = None,
time_format: Optional[TimeFormat] = None,
) -> MePreferencesResponse:
"""Update different formats using pre-defined Enums.
API documentation is not up to date, available fields to update are found manually.
"""
payload_schema = UpdateMePreferencesRequest(
date_format=date_format,
duration_format=duration_format,
time_format=time_format,
)
payload = payload_schema.model_dump_json(exclude_none=True)

response = self.client.put(url=f"{self.prefix}/preferences", json=payload)
self.raise_for_status(response)

response_body = response.json()
return MePreferencesResponse.model_validate(response_body)
5 changes: 5 additions & 0 deletions toggl_python/schemas/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel


class BaseSchema(BaseModel):
pass
Loading

0 comments on commit d7f075e

Please sign in to comment.