Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds organisation API support #30

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@ This repository contains a Python module for the Charge Amps' electric vehicle c

The module is developed by [Kirei AB](https://www.kirei.se) and is not supported by [Charge Amps AB](https://chargeamps.com).

## How to use

The simplest way is to install via `pip`, as in:

```
pip install chargeamps
```

If you need access to the organisation API calls, you need to specify this feature upon installation:

```
pip install chargeamps["organisations"]
```

## External API Key

You need an API key to use the Charge Amps external API. Contact [Charge Amps Support](mailto:[email protected]) for extensive API documentation and API key.


## References

- [Charge Amps External REST API](https://eapi.charge.space/swagger/)
26 changes: 26 additions & 0 deletions chargeamps/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
ChargePointStatus,
ChargingSession,
StartAuth,
User,
Partner,
)

API_BASE_URL = "https://eapi.charge.space"
Expand All @@ -44,6 +46,7 @@ def __init__(
self._token = None
self._token_expire = 0
self._refresh_token = None
self._user: User = None

async def shutdown(self) -> None:
await self._session.close()
Expand Down Expand Up @@ -72,6 +75,7 @@ async def _ensure_token(self) -> None:
except HTTPException:
self._logger.warning("Token refresh failed")
self._token = None
self._user = None
self._refresh_token = None
else:
self._token = None
Expand All @@ -89,6 +93,7 @@ async def _ensure_token(self) -> None:
except HTTPException as exc:
self._logger.error("Login failed")
self._token = None
self._user = None
self._refresh_token = None
self._token_expire = 0
raise exc
Expand All @@ -100,6 +105,7 @@ async def _ensure_token(self) -> None:
response_payload = await response.json()

self._token = response_payload["token"]
self._user = response_payload["user"]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure consistent typing of self._user

The instance variable self._user is annotated as User but is assigned a dictionary from response_payload["user"]. This can lead to type inconsistencies and potential errors.

Apply this diff to validate and assign the user data correctly:

-            self._user = response_payload["user"]
+            self._user = User.model_validate(response_payload["user"])

This ensures self._user is always a User instance.

Additionally, in the get_logged_in_user method, you can return self._user directly:

-        user_id = self._user["id"]
-        request_uri = f"/api/{API_VERSION}/users/{user_id}"
-        response = await self._get(request_uri)
-        payload = await response.json()
-        return User.model_validate(payload)
+        return self._user

Committable suggestion skipped: line range outside the PR's diff.

self._refresh_token = response_payload["refreshToken"]

token_payload = jwt.decode(self._token, options={"verify_signature": False})
Expand Down Expand Up @@ -209,6 +215,13 @@ async def set_chargepoint_connector_settings(
request_uri = f"/api/{API_VERSION}/chargepoints/{charge_point_id}/connectors/{connector_id}/settings"
await self._put(request_uri, json=payload)

async def get_partner(self, charge_point_id: str) -> Partner:
"""Get partner details"""
request_uri = f"/api/{API_VERSION}/chargepoints/{charge_point_id}/partner"
response = await self._get(request_uri)
payload = await response.json()
return Partner.model_validate(payload)
Comment on lines +218 to +223
Copy link

@coderabbitai coderabbitai bot Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance error handling and documentation for get_partner method

The method needs better error handling and documentation of possible exceptions.

     async def get_partner(self, charge_point_id: str) -> Partner:
-        """Get partner details"""
+        """Get partner details for a specific charge point.
+        
+        Args:
+            charge_point_id: The ID of the charge point
+            
+        Returns:
+            Partner: The partner details
+            
+        Raises:
+            HTTPException: If the API request fails
+            ValidationError: If the response data is invalid
+        """
         request_uri = f"/api/{API_VERSION}/chargepoints/{charge_point_id}/partner"
-        response = await self._get(request_uri)
-        payload = await response.json()
-        return Partner.model_validate(payload)
+        try:
+            response = await self._get(request_uri)
+            payload = await response.json()
+            return Partner.model_validate(payload)
+        except Exception as e:
+            self._logger.error(f"Failed to get partner details: {e}")
+            raise
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def get_partner(self, charge_point_id: str) -> Partner:
"""Get partner details"""
request_uri = f"/api/{API_VERSION}/chargepoints/{charge_point_id}/partner"
response = await self._get(request_uri)
payload = await response.json()
return Partner.model_validate(payload)
async def get_partner(self, charge_point_id: str) -> Partner:
"""Get partner details for a specific charge point.
Args:
charge_point_id: The ID of the charge point
Returns:
Partner: The partner details
Raises:
HTTPException: If the API request fails
ValidationError: If the response data is invalid
"""
request_uri = f"/api/{API_VERSION}/chargepoints/{charge_point_id}/partner"
try:
response = await self._get(request_uri)
payload = await response.json()
return Partner.model_validate(payload)
except Exception as e:
self._logger.error(f"Failed to get partner details: {e}")
raise

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why just this function needed more detailed documentation, but similarly to above I will keep both the docstring and error handling consistent with the rest of the module.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yarcod, understood. Maintaining consistency in docstrings and error handling across the module makes sense. Thank you for clarifying.


✏️ Learnings added
Learnt from: yarcod
PR: kirei/python-chargeamps#30
File: chargeamps/external.py:218-223
Timestamp: 2024-12-17T22:36:20.401Z
Learning: In `chargeamps/external.py`, ensure that docstrings and error handling remain consistent with the rest of the module, avoiding the addition of detailed documentation or extensive error handling to individual functions unless it's applied uniformly.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


async def remote_start(
self, charge_point_id: str, connector_id: int, start_auth: StartAuth
) -> None:
Expand All @@ -226,3 +239,16 @@ async def reboot(self, charge_point_id) -> None:
"""Reboot chargepoint"""
request_uri = f"/api/{API_VERSION}/chargepoints/{charge_point_id}/reboot"
await self._put(request_uri, json="{}")

async def get_logged_in_user(self) -> User:
"""Get authenticated user info"""
if not self._user or not isinstance(self._user, dict):
raise ValueError("No user is currently logged in")

user_id = self._user["id"]

request_uri = f"/api/{API_VERSION}/users/{user_id}"
response = await self._get(request_uri)
payload = await response.json()

return User.model_validate(payload)
68 changes: 68 additions & 0 deletions chargeamps/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
]


def feature_required(feature_flag):
def decorator(cls):
if feature_flag == "organisations":
return cls
else:
raise ImportError(f"Feature '{feature_flag}' is not enabled")

return decorator


class FrozenBaseSchema(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
Expand Down Expand Up @@ -94,3 +104,61 @@ class StartAuth(FrozenBaseSchema):
rfid_format: str
rfid: str
external_transaction_id: str


class RfidTag(FrozenBaseSchema):
active: bool
rfid: str | None
rfidDec: str | None
rfidDecReverse: str | None


class User(FrozenBaseSchema):
id: str
first_name: str | None
last_name: str | None
email: str | None
mobile: str | None
rfid_tags: Optional[list[RfidTag]]
user_status: str


class Partner(FrozenBaseSchema):
id: int
name: str
description: str
email: str
phone: str


# Only way to register new RFID seems to be through an Organization
@feature_required("organisations")
class Rfid(FrozenBaseSchema):
rfid: str | None
rfidDec: str | None
rfidDecReverse: str | None


@feature_required("organisations")
class Organisation(FrozenBaseSchema):
id: str
name: str
description: str


@feature_required("organisations")
class OrganisationChargingSession(FrozenBaseSchema):
id: int
charge_point_id: Optional[str]
connector_id: int
user_id: str
rfid: Optional[str]
rfidDec: Optional[str]
rfidDecReverse: Optional[str]
organisation_id: Optional[str]
session_type: str
start_time: Optional[CustomDateTime] = None
end_time: Optional[CustomDateTime] = None
external_transaction_id: Optional[str]
total_consumption_kwh: float
external_id: Optional[str]
Loading