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

Add hotspot vouchers #554

Merged
merged 59 commits into from
Apr 21, 2024
Merged
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
5b1131f
Add hotspot vouchers
ufozone Dec 16, 2023
3086a3e
Remove unused imports
ufozone Dec 16, 2023
b88738a
Remove unused imports
ufozone Dec 16, 2023
54d1fe4
Fix wrong return type for status_expires model
ufozone Dec 16, 2023
45e328f
Fix expiration input from given Voucher model
ufozone Dec 16, 2023
89065c7
mypi and ruff fixes
bmos Feb 16, 2024
5e17d97
more type fixes
bmos Feb 16, 2024
759190f
use f-string in code method
bmos Feb 16, 2024
2ac284f
Merge pull request #1 from bmos/master
ufozone Feb 24, 2024
8ed62ca
Merge branch 'Kane610:master' into master
ufozone Feb 24, 2024
d79d090
Fix voucher format
ufozone Feb 24, 2024
d08890b
Merge branch 'Kane610:master' into master
ufozone Mar 11, 2024
15ec646
Add voucher interface to gateway class
ufozone Mar 11, 2024
06d9956
Add voucher to test controller
ufozone Mar 11, 2024
04db869
Add tests for vouchers
ufozone Mar 11, 2024
5e2d896
Update aiounifi/models/voucher.py
ufozone Apr 3, 2024
b66e569
Merge branch 'Kane610:master' into master
ufozone Apr 3, 2024
31acc29
Fix RUFF warnings
ufozone Apr 3, 2024
81e897c
Mark some attributes as not required
ufozone Apr 7, 2024
4fc8417
Update DocString and Typing
ufozone Apr 7, 2024
e8b2933
Add test for voucher deletion
ufozone Apr 7, 2024
76ed566
Fix RUFF warnings in voucher tests
ufozone Apr 7, 2024
a973d1a
Update tests/test_vouchers.py
ufozone Apr 8, 2024
5c3b32d
Update tests/test_vouchers.py
ufozone Apr 8, 2024
0a98af2
Update conftest.py
ufozone Apr 8, 2024
d8e1525
Update tests/test_vouchers.py
ufozone Apr 8, 2024
47174da
Update tests/conftest.py
ufozone Apr 8, 2024
19b5280
Update tests/conftest.py
ufozone Apr 8, 2024
8c35b4b
Update tests/conftest.py
ufozone Apr 8, 2024
fe0532e
Update tests/conftest.py
ufozone Apr 8, 2024
310dde4
Update tests/test_controller.py
ufozone Apr 8, 2024
3a9d6fa
Update tests/test_vouchers.py
ufozone Apr 8, 2024
de385c9
Update aiounifi/models/voucher.py
ufozone Apr 8, 2024
ea5bfb1
Update aiounifi/models/voucher.py
ufozone Apr 8, 2024
a8dfd1c
Update tests/fixtures.py
ufozone Apr 8, 2024
0ccbe77
Update tests/test_vouchers.py
ufozone Apr 8, 2024
64fab7b
Update aiounifi/models/voucher.py
ufozone Apr 8, 2024
4eb795b
Improve fixture for voucher response
ufozone Apr 8, 2024
d266d63
Match optional properties with raw.gets in class methods
ufozone Apr 8, 2024
350927e
Update tests/test_vouchers.py
ufozone Apr 9, 2024
f82d9fe
Documentation voucher.duration
ufozone Apr 9, 2024
6e7770c
Test coverage vouchers
ufozone Apr 9, 2024
bdad257
Documentation voucher.status_expires
ufozone Apr 9, 2024
e1f4174
Test coverage vouchers
ufozone Apr 9, 2024
4e6741a
Tests vouchers
ufozone Apr 9, 2024
b6ead16
Test coverage vouchers
ufozone Apr 9, 2024
2bc1922
Test coverage vouchers
ufozone Apr 9, 2024
77d9bc0
Fix voucher tests and fixtures
ufozone Apr 9, 2024
47e13c2
Update voucher tests
ufozone Apr 9, 2024
43e7f78
Fix voucher tests
ufozone Apr 10, 2024
632b764
Fix voucher tests
ufozone Apr 10, 2024
e960312
Update docstring for voucher model
ufozone Apr 19, 2024
c5d3e1a
Update docstring of voucher model
ufozone Apr 19, 2024
137e644
Update docstring of voucher model
ufozone Apr 19, 2024
29d99e2
Remove message keys of voucher commands
ufozone Apr 20, 2024
688eb20
Fix RUFF warning
ufozone Apr 20, 2024
b5de641
Simplify voucher code generation
ufozone Apr 21, 2024
eefe2c1
Update tests for vouchers
ufozone Apr 21, 2024
88105a0
Fix tests for vouchers
ufozone Apr 21, 2024
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
3 changes: 3 additions & 0 deletions aiounifi/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .interfaces.system_information import SystemInformationHandler
from .interfaces.traffic_routes import TrafficRoutes
from .interfaces.traffic_rules import TrafficRules
from .interfaces.vouchers import Vouchers
from .interfaces.wlans import Wlans
from .models.configuration import Configuration

Expand Down Expand Up @@ -53,6 +54,7 @@ def __init__(self, config: Configuration) -> None:
self.system_information = SystemInformationHandler(self)
self.traffic_rules = TrafficRules(self)
self.traffic_routes = TrafficRoutes(self)
self.vouchers = Vouchers(self)
self.wlans = Wlans(self)

self.update_handlers: tuple[Callable[[], Coroutine[Any, Any, None]], ...] = (
Expand All @@ -66,6 +68,7 @@ def __init__(self, config: Configuration) -> None:
self.system_information.update,
self.traffic_rules.update,
self.traffic_routes.update,
self.vouchers.update,
self.wlans.update,
)

Expand Down
46 changes: 46 additions & 0 deletions aiounifi/interfaces/vouchers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Hotspot vouchers as part of a UniFi network."""

from ..models.api import TypedApiResponse
from ..models.message import MessageKey
from ..models.voucher import (
Voucher,
VoucherCreateRequest,
VoucherDeleteRequest,
VoucherListRequest,
)
from .api_handlers import APIHandler


class Vouchers(APIHandler[Voucher]):
"""Represents Hotspot vouchers."""

obj_id_key = "_id"
item_cls = Voucher
process_messages = (MessageKey.VOUCHER_CREATED,)
remove_messages = (MessageKey.VOUCHER_DELETED,)
api_request = VoucherListRequest.create()

async def create(self, voucher: Voucher) -> TypedApiResponse:
"""Create voucher on controller."""
return await self.controller.request(
VoucherCreateRequest.create(
number=1,
Copy link
Owner

Choose a reason for hiding this comment

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

Number is a required item to create an instance of Voucher, is there a reason 1 is hardcoded?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Number is not an attribute of Voucher. Number is the number of vouchers to be create in one request

Copy link
Owner

Choose a reason for hiding this comment

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

Alright, I would just add the default value to the create request and remove this instead.

Suggested change
number=1,

quota=voucher.quota,
expire_number=int(
voucher.duration.total_seconds() / 60 # Get minutes.
),
expire_unit=1,
Copy link
Owner

Choose a reason for hiding this comment

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

As the default value is 1 for expire unit would this be ok, or is there a specific reason this is 1

Suggested change
expire_unit=1,
expire_unit=voucher.expire_unit,

Copy link
Contributor Author

Choose a reason for hiding this comment

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

exire_unit is not an attribute of Voucher. 1 = minute, because expire_number came from voucher.duration in Seconds / 60 = Minutes

The attributes for vouchers of the return values of the API are called differently than those for creating.
For example, when creating, you pass the number of vouchers to be created. But these are then created as separate vouchers.
And when creating, you specify the expiry time and in which unit you specify the expiry time. In the return, you always get the duration time in seconds. However, you cannot create a voucher in seconds ....

Copy link
Owner

Choose a reason for hiding this comment

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

Alright, yes indeed, thanks, as it has the default value assigned this can just be removed.

Suggested change
expire_unit=1,

usage_quota=voucher.qos_usage_quota,
rate_max_up=voucher.qos_rate_max_up,
rate_max_down=voucher.qos_rate_max_down,
note=voucher.note,
)
)

async def delete(self, voucher: Voucher) -> TypedApiResponse:
"""Delete voucher from controller."""
return await self.controller.request(
VoucherDeleteRequest.create(
obj_id=voucher.id,
)
)
2 changes: 2 additions & 0 deletions aiounifi/models/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class MessageKey(enum.Enum):
VPN_CONNECT = "vpn:connect"
VPN_DISCONNECT = "vpn:disconnect"
VPN_CONNECTION_UPDATED = "vpn-connection:sync"
VOUCHER_CREATED = "voucher:create"
VOUCHER_DELETED = "voucher:delete"
WLAN_CONF_ADDED = "wlanconf:add"
WLAN_CONF_UPDATED = "wlanconf:sync"
WLAN_CONF_DELETED = "wlanconf:delete"
Expand Down
219 changes: 219 additions & 0 deletions aiounifi/models/voucher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Hotspot vouchers as part of a UniFi network."""

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import NotRequired, Self, TypedDict

from .api import ApiItem, ApiRequest


class TypedVoucher(TypedDict):
"""Voucher type definition."""

_id: str
site_id: str
note: NotRequired[str]
code: str
ufozone marked this conversation as resolved.
Show resolved Hide resolved
quota: int
duration: float
qos_overwrite: NotRequired[bool]
qos_usage_quota: NotRequired[str]
qos_rate_max_up: NotRequired[int]
qos_rate_max_down: NotRequired[int]
used: int
create_time: float
start_time: NotRequired[float]
end_time: NotRequired[float]
for_hotspot: NotRequired[bool]
admin_name: str
status: str
status_expires: float


@dataclass
class VoucherListRequest(ApiRequest):
"""Request object for voucher list."""

@classmethod
def create(cls) -> Self:
"""Create voucher list request."""
return cls(
method="get",
path="/stat/voucher",
)


@dataclass
class VoucherCreateRequest(ApiRequest):
"""Request object for voucher create."""

@classmethod
def create(
cls,
number: int,
quota: int,
expire_number: int,
Kane610 marked this conversation as resolved.
Show resolved Hide resolved
expire_unit: int = 1,
usage_quota: int | None = None,
rate_max_up: int | None = None,
rate_max_down: int | None = None,
note: str | None = None,
) -> Self:
"""Create voucher create request.

:param number: number of vouchers
:param quota: number of using; 0 = unlimited
:param expire_number: expiration of voucher per expire_unit
:param expire_unit: scale of expire_number, 1 = minute, 60 = hour, 3600 = day
:param usage_quota: quantity of bytes allowed in MB
:param rate_max_up: up speed allowed in kbps
:param rate_max_down: down speed allowed in kbps
:param note: description
"""
data = {
"cmd": "create-voucher",
"n": number,
"quota": quota,
"expire_number": expire_number,
"expire_unit": expire_unit,
}
if usage_quota:
ufozone marked this conversation as resolved.
Show resolved Hide resolved
data["bytes"] = usage_quota
if rate_max_up:
ufozone marked this conversation as resolved.
Show resolved Hide resolved
data["up"] = rate_max_up
if rate_max_down:
ufozone marked this conversation as resolved.
Show resolved Hide resolved
data["down"] = rate_max_down
if note:
data["note"] = note

return cls(
method="post",
path="/cmd/hotspot",
data=data,
)


@dataclass
class VoucherDeleteRequest(ApiRequest):
"""Request object for voucher delete."""

@classmethod
def create(
cls,
obj_id: str,
) -> Self:
"""Create voucher delete request."""
data = {
"cmd": "delete-voucher",
"_id": obj_id,
}
return cls(
method="post",
path="/cmd/hotspot",
data=data,
)


class Voucher(ApiItem):
"""Represents a voucher."""

raw: TypedVoucher

@property
def id(self) -> str:
"""ID of voucher."""
return self.raw["_id"]

@property
def site_id(self) -> str:
"""Site ID."""
return self.raw["site_id"]

@property
def note(self) -> str:
"""Note describing voucher."""
return self.raw.get("note") or ""

@property
def code(self) -> str:
"""Code in known format 00000-00000."""
ufozone marked this conversation as resolved.
Show resolved Hide resolved
if len(c := self.raw.get("code", "")) > 5:
ufozone marked this conversation as resolved.
Show resolved Hide resolved
ufozone marked this conversation as resolved.
Show resolved Hide resolved
# API returns the code without a hyphen. But this is necessary. Separate the API string after the fifth digit.
return f"{c[:5]}-{c[5:]}"
return c

@property
def quota(self) -> int:
"""Allowed usages (0 = unlimited)."""
return self.raw["quota"]

@property
def duration(self) -> timedelta:
"""Expiration of voucher."""
return timedelta(minutes=self.raw["duration"])

@property
def qos_overwrite(self) -> bool:
"""QoS defaults overwritten."""
return self.raw.get("qos_overwrite", False)

@property
def qos_usage_quota(self) -> int:
"""Quantity of bytes allowed in MB."""
return int(self.raw.get("qos_usage_quota", 0))
ufozone marked this conversation as resolved.
Show resolved Hide resolved

@property
def qos_rate_max_up(self) -> int:
"""Up speed allowed in kbps."""
return self.raw.get("qos_rate_max_up", 0)

@property
def qos_rate_max_down(self) -> int:
"""Down speed allowed in kbps."""
return self.raw.get("qos_rate_max_down", 0)

@property
def used(self) -> int:
"""Number of usages."""
return self.raw["used"]

@property
def create_time(self) -> datetime:
"""Create datetime."""
return datetime.fromtimestamp(self.raw["create_time"])

@property
def start_time(self) -> datetime | None:
"""Start datetime of first usage."""
if "start_time" in self.raw:
return datetime.fromtimestamp(self.raw["start_time"])
return None

@property
def end_time(self) -> datetime | None:
"""End datetime of latest usage."""
if "end_time" in self.raw:
return datetime.fromtimestamp(self.raw["end_time"])
return None

@property
def for_hotspot(self) -> bool:
"""For hotspot."""
return self.raw.get("for_hotspot", False)

@property
def admin_name(self) -> str:
"""Admin name."""
return self.raw["admin_name"]

@property
def status(self) -> str:
"""Status."""
return self.raw["status"]

@property
def status_expires(self) -> timedelta | None:
"""Status expires in seconds."""
if (status_expiry := self.raw["status_expires"]) > 0:
return timedelta(seconds=status_expiry)
return None
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def _endpoint_fixture(
traffic_rule_payload: list[dict[str, Any]],
traffic_rule_status: int,
wlan_payload: list[dict[str, Any]],
voucher_payload: list[dict[str, Any]],
) -> None:
"""Use fixtures to mock all endpoints."""

Expand Down Expand Up @@ -177,6 +178,11 @@ def mock_get_request(
"/proxy/network/api/s/default/rest/wlanconf",
wlan_payload,
)
mock_get_request(
"/api/s/default/stat/voucher",
"/proxy/network/api/s/default/stat/voucher",
voucher_payload,
)


@pytest.fixture(name="response_payload")
Expand Down Expand Up @@ -255,3 +261,9 @@ def traffic_rule_status_fixture() -> int:
def wlan_data_fixture() -> list[dict[str, Any]]:
"""WLAN data."""
return []


@pytest.fixture(name="voucher_payload")
def voucher_data_fixture() -> list[dict[str, Any]]:
"""Vouchers data."""
return []
38 changes: 38 additions & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -4110,6 +4110,44 @@
},
]

VOUCHERS = [
{
"_id": "657e370a4543a555901865c7",
"admin_name": "Admin",
"code": "7470075124",
"create_time": 1638342818,
"duration": 5184000,
"end_time": 1949382832,
"for_hotspot": False,
"note": "auto-generated",
"qos_overwrite": False,
"quota": 0,
"site_id": "5a32aa4ee4b0412345678910",
"start_time": 1638342832,
"status": "USED_MULTIPLE",
"status_expires": 244679302,
"used": 2,
},
{
"_id": "61facea3873fdb075ce28d71",
"admin_name": "Admin",
"code": "44703",
"create_time": 1643826851,
"duration": 480,
"for_hotspot": False,
"note": None,
"qos_overwrite": True,
"qos_rate_max_down": 5000,
"qos_rate_max_up": 2000,
"qos_usage_quota": 1000,
"quota": 1,
"site_id": "5a32aa4ee4b0412345678910",
"status": "VALID_ONE",
"status_expires": 0,
"used": 0,
},
]

WLANS = [
{
"_id": "012345678910111213141516",
Expand Down
Loading
Loading