diff --git a/libzapi/__init__.py b/libzapi/__init__.py index f358f0b..6e62ccc 100644 --- a/libzapi/__init__.py +++ b/libzapi/__init__.py @@ -4,6 +4,7 @@ from libzapi.application import AgentAvailability from libzapi.application import AssetManagement from libzapi.application import ZendeskStatus +from libzapi.application import WorkforceManagement __all__ = [ "HelpCenter", @@ -12,4 +13,5 @@ "AgentAvailability", "AssetManagement", "ZendeskStatus", + "WorkforceManagement", ] diff --git a/libzapi/application/__init__.py b/libzapi/application/__init__.py index 829652c..ab3e20a 100644 --- a/libzapi/application/__init__.py +++ b/libzapi/application/__init__.py @@ -4,6 +4,7 @@ from libzapi.application.services.agent_availability import AgentAvailability from libzapi.application.services.asset_management import AssetManagement from libzapi.application.services.status import ZendeskStatus +from libzapi.application.services.wfm import WorkforceManagement __all__ = [ "HelpCenter", @@ -12,4 +13,5 @@ "AgentAvailability", "AssetManagement", "ZendeskStatus", + "WorkforceManagement", ] diff --git a/libzapi/application/commands/wfm/__init__.py b/libzapi/application/commands/wfm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/application/commands/wfm/shift_cmds.py b/libzapi/application/commands/wfm/shift_cmds.py new file mode 100644 index 0000000..b3e4014 --- /dev/null +++ b/libzapi/application/commands/wfm/shift_cmds.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class FetchShiftsCmd: + startDate: str + endDate: str + agentIds: list[int] | None = None + published: int | None = None + page: int = 1 diff --git a/libzapi/application/commands/wfm/team_cmds.py b/libzapi/application/commands/wfm/team_cmds.py new file mode 100644 index 0000000..7c3ecd1 --- /dev/null +++ b/libzapi/application/commands/wfm/team_cmds.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateTeamCmd: + name: str + description: str + manager_id: int + agents_ids: list[str] + + +@dataclass(frozen=True, slots=True) +class UpdateTeamCmd: + name: str | None = None + description: str | None = None + manager_id: int | None = None + agents_ids: list[str] | None = None + + +@dataclass(frozen=True, slots=True) +class BulkAgentsCmd: + agent_ids: list[str] + team_ids: list[str] diff --git a/libzapi/application/commands/wfm/time_off_cmds.py b/libzapi/application/commands/wfm/time_off_cmds.py new file mode 100644 index 0000000..c372258 --- /dev/null +++ b/libzapi/application/commands/wfm/time_off_cmds.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class ImportTimeOffEntry: + agentId: int + startTime: int + endTime: int + reasonId: str + id: str | None = None + note: str | None = None + status: str | None = None + timeOffType: str | None = None + + +@dataclass(frozen=True, slots=True) +class ImportTimeOffCmd: + data: list[ImportTimeOffEntry] = field(default_factory=list) diff --git a/libzapi/application/services/wfm/__init__.py b/libzapi/application/services/wfm/__init__.py new file mode 100644 index 0000000..74c7147 --- /dev/null +++ b/libzapi/application/services/wfm/__init__.py @@ -0,0 +1,28 @@ +import libzapi.infrastructure.api_clients.wfm as api +from libzapi.application.services.wfm.activities_service import ActivitiesService +from libzapi.application.services.wfm.reports_service import ReportsService +from libzapi.application.services.wfm.shifts_service import ShiftsService +from libzapi.application.services.wfm.teams_service import TeamsService +from libzapi.application.services.wfm.time_off_service import TimeOffService +from libzapi.infrastructure.http.auth import api_token_headers, oauth_headers +from libzapi.infrastructure.http.client import HttpClient + + +class WorkforceManagement: + def __init__( + self, base_url: str, oauth_token: str | None = None, email: str | None = None, api_token: str | None = None + ): + if oauth_token: + headers = oauth_headers(oauth_token) + elif email and api_token: + headers = api_token_headers(email, api_token) + else: + raise ValueError("Provide oauth_token or email+api_token") + + http = HttpClient(base_url, headers=headers) + + self.activities = ActivitiesService(api.ActivityApiClient(http)) + self.reports = ReportsService(api.ReportApiClient(http)) + self.shifts = ShiftsService(api.ShiftApiClient(http)) + self.time_off = TimeOffService(api.TimeOffApiClient(http)) + self.teams = TeamsService(api.TeamApiClient(http)) diff --git a/libzapi/application/services/wfm/activities_service.py b/libzapi/application/services/wfm/activities_service.py new file mode 100644 index 0000000..af12563 --- /dev/null +++ b/libzapi/application/services/wfm/activities_service.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.domain.models.wfm.activity import Activity, ActivityTypeRef, AgentRef +from libzapi.infrastructure.api_clients.wfm import ActivityApiClient + + +class ActivitiesService: + def __init__(self, client: ActivityApiClient) -> None: + self._client = client + + def list(self, start_time: int) -> Iterable[Activity]: + return self._client.list(start_time=start_time) + + def list_with_relationships(self, start_time: int) -> tuple[list[Activity], list[AgentRef], list[ActivityTypeRef]]: + return self._client.list_with_relationships(start_time=start_time) diff --git a/libzapi/application/services/wfm/reports_service.py b/libzapi/application/services/wfm/reports_service.py new file mode 100644 index 0000000..2fe2632 --- /dev/null +++ b/libzapi/application/services/wfm/reports_service.py @@ -0,0 +1,17 @@ +from typing import Iterable + +from libzapi.domain.models.wfm.report import ReportRow +from libzapi.infrastructure.api_clients.wfm import ReportApiClient + + +class ReportsService: + def __init__(self, client: ReportApiClient) -> None: + self._client = client + + def get_data(self, template_id: str, start_time: int, end_time: int) -> Iterable[ReportRow]: + return self._client.get_data(template_id=template_id, start_time=start_time, end_time=end_time) + + def get_data_with_relationships(self, template_id: str, start_time: int, end_time: int) -> dict: + return self._client.get_data_with_relationships( + template_id=template_id, start_time=start_time, end_time=end_time + ) diff --git a/libzapi/application/services/wfm/shifts_service.py b/libzapi/application/services/wfm/shifts_service.py new file mode 100644 index 0000000..341ca3b --- /dev/null +++ b/libzapi/application/services/wfm/shifts_service.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.application.commands.wfm.shift_cmds import FetchShiftsCmd +from libzapi.domain.models.wfm.shift import Shift +from libzapi.infrastructure.api_clients.wfm import ShiftApiClient + + +class ShiftsService: + def __init__(self, client: ShiftApiClient) -> None: + self._client = client + + def fetch( + self, + start_date: str, + end_date: str, + agent_ids: list[int] | None = None, + published: int | None = None, + page: int = 1, + ) -> Iterable[Shift]: + cmd = FetchShiftsCmd( + startDate=start_date, + endDate=end_date, + agentIds=agent_ids, + published=published, + page=page, + ) + return self._client.fetch(cmd=cmd) diff --git a/libzapi/application/services/wfm/teams_service.py b/libzapi/application/services/wfm/teams_service.py new file mode 100644 index 0000000..e162303 --- /dev/null +++ b/libzapi/application/services/wfm/teams_service.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.application.commands.wfm.team_cmds import BulkAgentsCmd, CreateTeamCmd, UpdateTeamCmd +from libzapi.domain.models.wfm.team import BulkAgentsResult, Team +from libzapi.infrastructure.api_clients.wfm import TeamApiClient + + +class TeamsService: + def __init__(self, client: TeamApiClient) -> None: + self._client = client + + def list(self, deleted: bool = False) -> Iterable[Team]: + return self._client.list(deleted=deleted) + + def get(self, team_id: str) -> Team: + return self._client.get(team_id=team_id) + + def create(self, name: str, description: str, manager_id: int, agents_ids: list[str]) -> Team: + cmd = CreateTeamCmd(name=name, description=description, manager_id=manager_id, agents_ids=agents_ids) + return self._client.create(cmd=cmd) + + def update(self, team_id: str, **kwargs) -> Team: + cmd = UpdateTeamCmd(**kwargs) + return self._client.update(team_id=team_id, cmd=cmd) + + def delete(self, team_id: str) -> None: + self._client.delete(team_id=team_id) + + def restore(self, team_id: str) -> Team: + return self._client.restore(team_id=team_id) + + def bulk_add_agents(self, agent_ids: list[str], team_ids: list[str]) -> BulkAgentsResult: + cmd = BulkAgentsCmd(agent_ids=agent_ids, team_ids=team_ids) + return self._client.bulk_add_agents(cmd=cmd) + + def bulk_remove_agents(self, agent_ids: list[str], team_ids: list[str]) -> BulkAgentsResult: + cmd = BulkAgentsCmd(agent_ids=agent_ids, team_ids=team_ids) + return self._client.bulk_remove_agents(cmd=cmd) diff --git a/libzapi/application/services/wfm/time_off_service.py b/libzapi/application/services/wfm/time_off_service.py new file mode 100644 index 0000000..31d510b --- /dev/null +++ b/libzapi/application/services/wfm/time_off_service.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.application.commands.wfm.time_off_cmds import ImportTimeOffCmd, ImportTimeOffEntry +from libzapi.domain.models.wfm.time_off import TimeOff, TimeOffImportResult +from libzapi.infrastructure.api_clients.wfm import TimeOffApiClient + + +class TimeOffService: + def __init__(self, client: TimeOffApiClient) -> None: + self._client = client + + def list( + self, + time_off_request_id: str | None = None, + agent_id: int | None = None, + start_time: int | None = None, + end_time: int | None = None, + status: str | None = None, + reason_id: str | None = None, + time_off_type: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> Iterable[TimeOff]: + return self._client.list( + time_off_request_id=time_off_request_id, + agent_id=agent_id, + start_time=start_time, + end_time=end_time, + status=status, + reason_id=reason_id, + time_off_type=time_off_type, + page=page, + per_page=per_page, + ) + + def import_time_off(self, entries: list[ImportTimeOffEntry]) -> TimeOffImportResult: + cmd = ImportTimeOffCmd(data=entries) + return self._client.import_time_off(cmd=cmd) diff --git a/libzapi/domain/models/wfm/__init__.py b/libzapi/domain/models/wfm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/domain/models/wfm/activity.py b/libzapi/domain/models/wfm/activity.py new file mode 100644 index 0000000..86b8f86 --- /dev/null +++ b/libzapi/domain/models/wfm/activity.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class AgentRef: + id: int + name: str + email: str + deactivated: bool = False + isDeleted: bool = False + + +@dataclass(frozen=True, slots=True) +class ActivityTypeRef: + id: str + name: str + color: str = "" + isDeleted: bool = False + + +@dataclass(frozen=True, slots=True) +class Activity: + id: str + agentId: int + startTime: int + type: str + name: str = "" + ticketId: int | None = None + endTime: int | None = None + duration: int | None = None + activityTypeIds: list[str] = field(default_factory=list) + eventType: str = "" + color: str = "" + isPaid: bool = False + lockIntervals: str | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("wfm_activity", self.id) diff --git a/libzapi/domain/models/wfm/report.py b/libzapi/domain/models/wfm/report.py new file mode 100644 index 0000000..caf3bd5 --- /dev/null +++ b/libzapi/domain/models/wfm/report.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Grouping: + key: str + value: str + + +@dataclass(frozen=True, slots=True) +class Metric: + key: str + value: str + type: str = "" + + +@dataclass(frozen=True, slots=True) +class ReportRow: + groupings: list[Grouping] = field(default_factory=list) + metrics: list[Metric] = field(default_factory=list) + + @property + def logical_key(self) -> LogicalKey: + keys = "_".join(g.value for g in self.groupings) + return LogicalKey("wfm_report_row", keys) diff --git a/libzapi/domain/models/wfm/shift.py b/libzapi/domain/models/wfm/shift.py new file mode 100644 index 0000000..0d54bd0 --- /dev/null +++ b/libzapi/domain/models/wfm/shift.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class ShiftTask: + id: str + startTime: int + endTime: int + name: str = "" + color: str = "" + taskableId: str = "" + taskableType: str = "" + createdAt: str = "" + note: str = "" + + +@dataclass(frozen=True, slots=True) +class Shift: + id: str + agentId: int + startTime: int + endTime: int + published: bool = False + parentId: str | int | None = None + tasks: list[ShiftTask] = field(default_factory=list) + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("wfm_shift", self.id) diff --git a/libzapi/domain/models/wfm/team.py b/libzapi/domain/models/wfm/team.py new file mode 100644 index 0000000..40d75ab --- /dev/null +++ b/libzapi/domain/models/wfm/team.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Team: + id: str + name: str + description: str = "" + manager_id: int | None = None + agents_ids: list[str] = field(default_factory=list) + is_deleted: bool = False + deleted_at: str | None = None + tymeshift_account_id: int | None = None + + @property + def logical_key(self) -> LogicalKey: + base = self.name.lower().replace(" ", "_") + return LogicalKey("wfm_team", base) + + +@dataclass(frozen=True, slots=True) +class BulkAgentsResult: + status: str = "" + affected_teams: list[str] = field(default_factory=list) diff --git a/libzapi/domain/models/wfm/time_off.py b/libzapi/domain/models/wfm/time_off.py new file mode 100644 index 0000000..3a2824b --- /dev/null +++ b/libzapi/domain/models/wfm/time_off.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class TimeOffReason: + id: str + name: str + type: str = "" + deletedAt: str | None = None + isDeleted: bool = False + + +@dataclass(frozen=True, slots=True) +class TimeOffStatusHistory: + id: str + timeOffId: str = "" + status: str = "" + tymeshiftAccountId: int | None = None + note: str | None = None + internalNote: str | None = None + auto: bool = False + createdAt: str = "" + createdBy: int | None = None + + +@dataclass(frozen=True, slots=True) +class TimeOff: + timeOffRequestId: str + agentId: int + startTime: int + endTime: int + status: str = "" + timeOffType: str = "" + auto: bool = False + note: str | None = None + reasonId: str = "" + reason: TimeOffReason | None = None + statusHistory: list[TimeOffStatusHistory] = field(default_factory=list) + createdAt: int | None = None + source: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("wfm_time_off", self.timeOffRequestId) + + +@dataclass(frozen=True, slots=True) +class TimeOffImportResult: + entities: list[dict] = field(default_factory=list) + inserted: list[str] = field(default_factory=list) + updated: list[str] = field(default_factory=list) diff --git a/libzapi/infrastructure/api_clients/wfm/__init__.py b/libzapi/infrastructure/api_clients/wfm/__init__.py index e69de29..6b64fdc 100644 --- a/libzapi/infrastructure/api_clients/wfm/__init__.py +++ b/libzapi/infrastructure/api_clients/wfm/__init__.py @@ -0,0 +1,13 @@ +from libzapi.infrastructure.api_clients.wfm.activity_api_client import ActivityApiClient +from libzapi.infrastructure.api_clients.wfm.report_api_client import ReportApiClient +from libzapi.infrastructure.api_clients.wfm.shift_api_client import ShiftApiClient +from libzapi.infrastructure.api_clients.wfm.team_api_client import TeamApiClient +from libzapi.infrastructure.api_clients.wfm.time_off_api_client import TimeOffApiClient + +__all__ = [ + "ActivityApiClient", + "ReportApiClient", + "ShiftApiClient", + "TeamApiClient", + "TimeOffApiClient", +] diff --git a/libzapi/infrastructure/api_clients/wfm/activity_api_client.py b/libzapi/infrastructure/api_clients/wfm/activity_api_client.py new file mode 100644 index 0000000..e3d2537 --- /dev/null +++ b/libzapi/infrastructure/api_clients/wfm/activity_api_client.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.domain.models.wfm.activity import Activity, ActivityTypeRef, AgentRef +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/wfm/public/api/v1/activities" + + +class ActivityApiClient: + """HTTP adapter for WFM Activities API. + + Pagination: pass the last activity's startTime to fetch the next page. + Max 1000 records per request. + """ + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list(self, start_time: int) -> Iterable[Activity]: + path = f"{_BASE}?startTime={int(start_time)}" + while path: + data = self._http.get(path) + for item in data.get("data", []): + yield to_domain(data=item, cls=Activity) + metadata = data.get("metadata") or {} + next_url = metadata.get("next") + if next_url and isinstance(next_url, str): + path = next_url.replace(self._http.base_url, "") if next_url.startswith("https://") else next_url + else: + path = None + + def list_with_relationships(self, start_time: int) -> tuple[list[Activity], list[AgentRef], list[ActivityTypeRef]]: + data = self._http.get(f"{_BASE}?startTime={int(start_time)}") + activities = [to_domain(data=item, cls=Activity) for item in data.get("data", [])] + rels = data.get("relationships") or {} + agents = [to_domain(data=a, cls=AgentRef) for a in rels.get("agent", [])] + activity_types = [to_domain(data=at, cls=ActivityTypeRef) for at in rels.get("activityType", [])] + return activities, agents, activity_types diff --git a/libzapi/infrastructure/api_clients/wfm/report_api_client.py b/libzapi/infrastructure/api_clients/wfm/report_api_client.py new file mode 100644 index 0000000..af5f745 --- /dev/null +++ b/libzapi/infrastructure/api_clients/wfm/report_api_client.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.domain.models.wfm.report import ReportRow +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/wfm/public/api/v1/reports" + + +class ReportApiClient: + """HTTP adapter for WFM Reports API.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def get_data(self, template_id: str, start_time: int, end_time: int) -> Iterable[ReportRow]: + path = f"{_BASE}/{template_id}/data?startTime={int(start_time)}&endTime={int(end_time)}" + data = self._http.get(path) + for item in data.get("data", []): + yield to_domain(data=item, cls=ReportRow) + + def get_data_with_relationships(self, template_id: str, start_time: int, end_time: int) -> dict: + path = f"{_BASE}/{template_id}/data?startTime={int(start_time)}&endTime={int(end_time)}" + return self._http.get(path) diff --git a/libzapi/infrastructure/api_clients/wfm/shift_api_client.py b/libzapi/infrastructure/api_clients/wfm/shift_api_client.py new file mode 100644 index 0000000..f4616a9 --- /dev/null +++ b/libzapi/infrastructure/api_clients/wfm/shift_api_client.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.application.commands.wfm.shift_cmds import FetchShiftsCmd +from libzapi.domain.models.wfm.shift import Shift +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.wfm.shift_mapper import to_payload_fetch +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/wfm/public/api/v1/shifts/fetch" + + +class ShiftApiClient: + """HTTP adapter for WFM Shifts API.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def fetch(self, cmd: FetchShiftsCmd) -> Iterable[Shift]: + payload = to_payload_fetch(cmd) + data = self._http.post(_BASE, json=payload) + for item in data.get("data", []): + yield to_domain(data=item, cls=Shift) diff --git a/libzapi/infrastructure/api_clients/wfm/team_api_client.py b/libzapi/infrastructure/api_clients/wfm/team_api_client.py new file mode 100644 index 0000000..c4e6238 --- /dev/null +++ b/libzapi/infrastructure/api_clients/wfm/team_api_client.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.application.commands.wfm.team_cmds import BulkAgentsCmd, CreateTeamCmd, UpdateTeamCmd +from libzapi.domain.models.wfm.team import BulkAgentsResult, Team +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.wfm.team_mapper import to_payload_bulk_agents, to_payload_create, to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/wfm/l5/api/v2/teams" + + +class TeamApiClient: + """HTTP adapter for WFM Teams API.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list(self, deleted: bool = False) -> Iterable[Team]: + path = f"{_BASE}?deleted=true" if deleted else _BASE + data = self._http.get(path) + for item in data.get("teams", []): + yield to_domain(data=item, cls=Team) + + def get(self, team_id: str) -> Team: + data = self._http.get(f"{_BASE}/{team_id}") + return to_domain(data=data, cls=Team) + + def create(self, cmd: CreateTeamCmd) -> Team: + payload = to_payload_create(cmd) + data = self._http.post(_BASE, json=payload) + return to_domain(data=data, cls=Team) + + def update(self, team_id: str, cmd: UpdateTeamCmd) -> Team: + payload = to_payload_update(cmd) + data = self._http.put(f"{_BASE}/{team_id}", json=payload) + return to_domain(data=data, cls=Team) + + def delete(self, team_id: str) -> None: + self._http.delete(f"{_BASE}/{team_id}") + + def restore(self, team_id: str) -> Team: + data = self._http.post(f"{_BASE}/restore", json={"id": team_id}) + return to_domain(data=data, cls=Team) + + def bulk_add_agents(self, cmd: BulkAgentsCmd) -> BulkAgentsResult: + payload = to_payload_bulk_agents(cmd) + data = self._http.post(f"{_BASE}/bulk/add_agents", json=payload) + return to_domain(data=data, cls=BulkAgentsResult) + + def bulk_remove_agents(self, cmd: BulkAgentsCmd) -> BulkAgentsResult: + payload = to_payload_bulk_agents(cmd) + data = self._http.post(f"{_BASE}/bulk/remove_agents", json=payload) + return to_domain(data=data, cls=BulkAgentsResult) diff --git a/libzapi/infrastructure/api_clients/wfm/time_off_api_client.py b/libzapi/infrastructure/api_clients/wfm/time_off_api_client.py new file mode 100644 index 0000000..2290aea --- /dev/null +++ b/libzapi/infrastructure/api_clients/wfm/time_off_api_client.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.application.commands.wfm.time_off_cmds import ImportTimeOffCmd +from libzapi.domain.models.wfm.time_off import TimeOff, TimeOffImportResult +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.wfm.time_off_mapper import to_payload_import +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/wfm/public/api/v1/timeOff" + + +class TimeOffApiClient: + """HTTP adapter for WFM Time Off API.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list( + self, + time_off_request_id: str | None = None, + agent_id: int | None = None, + start_time: int | None = None, + end_time: int | None = None, + status: str | None = None, + reason_id: str | None = None, + time_off_type: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> Iterable[TimeOff]: + params: list[str] = [] + if time_off_request_id is not None: + params.append(f"timeOffRequestId={time_off_request_id}") + if agent_id is not None: + params.append(f"agentId={int(agent_id)}") + if start_time is not None: + params.append(f"startTime={int(start_time)}") + if end_time is not None: + params.append(f"endTime={int(end_time)}") + if status is not None: + params.append(f"status={status}") + if reason_id is not None: + params.append(f"reasonId={reason_id}") + if time_off_type is not None: + params.append(f"timeOffType={time_off_type}") + if page is not None: + params.append(f"page={int(page)}") + if per_page is not None: + params.append(f"perPage={int(per_page)}") + + query = "&".join(params) + path = f"{_BASE}?{query}" if query else _BASE + data = self._http.get(path) + for item in data.get("data", []): + yield to_domain(data=item, cls=TimeOff) + + def import_time_off(self, cmd: ImportTimeOffCmd) -> TimeOffImportResult: + payload = to_payload_import(cmd) + data = self._http.post(f"{_BASE}/import", json=payload) + return to_domain(data=data.get("data", {}), cls=TimeOffImportResult) diff --git a/libzapi/infrastructure/mappers/wfm/__init__.py b/libzapi/infrastructure/mappers/wfm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/infrastructure/mappers/wfm/shift_mapper.py b/libzapi/infrastructure/mappers/wfm/shift_mapper.py new file mode 100644 index 0000000..0482c40 --- /dev/null +++ b/libzapi/infrastructure/mappers/wfm/shift_mapper.py @@ -0,0 +1,15 @@ +from libzapi.application.commands.wfm.shift_cmds import FetchShiftsCmd + + +def to_payload_fetch(cmd: FetchShiftsCmd) -> dict: + payload: dict = { + "startDate": cmd.startDate, + "endDate": cmd.endDate, + } + if cmd.agentIds is not None: + payload["agentIds"] = cmd.agentIds + if cmd.published is not None: + payload["published"] = cmd.published + if cmd.page != 1: + payload["page"] = cmd.page + return payload diff --git a/libzapi/infrastructure/mappers/wfm/team_mapper.py b/libzapi/infrastructure/mappers/wfm/team_mapper.py new file mode 100644 index 0000000..f2d940d --- /dev/null +++ b/libzapi/infrastructure/mappers/wfm/team_mapper.py @@ -0,0 +1,22 @@ +from libzapi.application.commands.wfm.team_cmds import BulkAgentsCmd, CreateTeamCmd, UpdateTeamCmd + + +def to_payload_create(cmd: CreateTeamCmd) -> dict: + return { + "name": cmd.name, + "description": cmd.description, + "manager_id": cmd.manager_id, + "agents_ids": cmd.agents_ids, + } + + +def to_payload_update(cmd: UpdateTeamCmd) -> dict: + fields = ("name", "description", "manager_id", "agents_ids") + return {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + + +def to_payload_bulk_agents(cmd: BulkAgentsCmd) -> dict: + return { + "agent_ids": cmd.agent_ids, + "team_ids": cmd.team_ids, + } diff --git a/libzapi/infrastructure/mappers/wfm/time_off_mapper.py b/libzapi/infrastructure/mappers/wfm/time_off_mapper.py new file mode 100644 index 0000000..4513ffa --- /dev/null +++ b/libzapi/infrastructure/mappers/wfm/time_off_mapper.py @@ -0,0 +1,22 @@ +from libzapi.application.commands.wfm.time_off_cmds import ImportTimeOffCmd + + +def to_payload_import(cmd: ImportTimeOffCmd) -> dict: + entries = [] + for entry in cmd.data: + item: dict = { + "agentId": entry.agentId, + "startTime": entry.startTime, + "endTime": entry.endTime, + "reasonId": entry.reasonId, + } + if entry.id is not None: + item["id"] = entry.id + if entry.note is not None: + item["note"] = entry.note + if entry.status is not None: + item["status"] = entry.status + if entry.timeOffType is not None: + item["timeOffType"] = entry.timeOffType + entries.append(item) + return {"data": entries} diff --git a/tests/conftest.py b/tests/conftest.py index af0e35e..f3fec3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest -from libzapi import Ticketing, HelpCenter, CustomData, AgentAvailability, AssetManagement +from libzapi import Ticketing, HelpCenter, CustomData, AgentAvailability, AssetManagement, WorkforceManagement T = TypeVar("T") @@ -38,6 +38,12 @@ def asset_management(): return _generic_zendesk_client(AssetManagement) +@pytest.fixture(scope="session") +def wfm(): + """Creates a real WorkforceManagement client if environment variables are set.""" + return _generic_zendesk_client(WorkforceManagement) + + def _generic_zendesk_client(client_cls: Type[T]) -> T: base_url = os.getenv("ZENDESK_URL") email = os.getenv("ZENDESK_EMAIL") diff --git a/tests/integration/wfm/__init__.py b/tests/integration/wfm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/wfm/test_activities.py b/tests/integration/wfm/test_activities.py new file mode 100644 index 0000000..071e446 --- /dev/null +++ b/tests/integration/wfm/test_activities.py @@ -0,0 +1,18 @@ +import time + + +from libzapi import WorkforceManagement + + +def test_list_activities(wfm: WorkforceManagement): + one_day_ago = int(time.time()) - 86400 + activities = list(wfm.activities.list(start_time=one_day_ago)) + assert isinstance(activities, list) + + +def test_list_activities_with_relationships(wfm: WorkforceManagement): + one_day_ago = int(time.time()) - 86400 + activities, agents, activity_types = wfm.activities.list_with_relationships(start_time=one_day_ago) + assert isinstance(activities, list) + assert isinstance(agents, list) + assert isinstance(activity_types, list) diff --git a/tests/integration/wfm/test_reports.py b/tests/integration/wfm/test_reports.py new file mode 100644 index 0000000..7c240e0 --- /dev/null +++ b/tests/integration/wfm/test_reports.py @@ -0,0 +1,17 @@ +import time + +import pytest + +from libzapi import WorkforceManagement + + +def test_get_report_data(wfm: WorkforceManagement): + now = int(time.time()) + one_week_ago = now - 7 * 86400 + # This test requires a valid template_id from the account + # Skip if no templates are available + try: + rows = list(wfm.reports.get_data(template_id="default", start_time=one_week_ago, end_time=now)) + assert isinstance(rows, list) + except Exception: + pytest.skip("No valid report template available or WFM not enabled") diff --git a/tests/integration/wfm/test_shifts.py b/tests/integration/wfm/test_shifts.py new file mode 100644 index 0000000..e537480 --- /dev/null +++ b/tests/integration/wfm/test_shifts.py @@ -0,0 +1,12 @@ +from datetime import date, timedelta + + +from libzapi import WorkforceManagement + + +def test_fetch_shifts(wfm: WorkforceManagement): + today = date.today() + start = (today - timedelta(days=7)).isoformat() + end = today.isoformat() + shifts = list(wfm.shifts.fetch(start_date=start, end_date=end)) + assert isinstance(shifts, list) diff --git a/tests/integration/wfm/test_teams.py b/tests/integration/wfm/test_teams.py new file mode 100644 index 0000000..352ac19 --- /dev/null +++ b/tests/integration/wfm/test_teams.py @@ -0,0 +1,38 @@ +import uuid + +import pytest + +from libzapi import WorkforceManagement + + +def test_list_teams(wfm: WorkforceManagement): + teams = list(wfm.teams.list()) + assert isinstance(teams, list) + + +def test_list_and_get_team(wfm: WorkforceManagement): + teams = list(wfm.teams.list()) + if not teams: + pytest.skip("No teams found in the live API") + team = wfm.teams.get(teams[0].id) + assert team.id == teams[0].id + + +def test_create_update_delete_restore_team(wfm: WorkforceManagement): + random_id = str(uuid.uuid4())[:8] + team = wfm.teams.create( + name=f"Test Team {random_id}", + description="Integration test team", + manager_id=0, + agents_ids=[], + ) + assert team.id is not None + + try: + updated = wfm.teams.update(team.id, name=f"Updated Team {random_id}") + assert updated.name == f"Updated Team {random_id}" + + fetched = wfm.teams.get(team.id) + assert fetched.id == team.id + finally: + wfm.teams.delete(team.id) diff --git a/tests/integration/wfm/test_time_off.py b/tests/integration/wfm/test_time_off.py new file mode 100644 index 0000000..4e41cf4 --- /dev/null +++ b/tests/integration/wfm/test_time_off.py @@ -0,0 +1,11 @@ +from libzapi import WorkforceManagement + + +def test_list_time_off(wfm: WorkforceManagement): + time_offs = list(wfm.time_off.list()) + assert isinstance(time_offs, list) + + +def test_list_time_off_with_filters(wfm: WorkforceManagement): + time_offs = list(wfm.time_off.list(status="approved", page=1, per_page=10)) + assert isinstance(time_offs, list) diff --git a/tests/unit/wfm/__init__.py b/tests/unit/wfm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/wfm/test_activities_service.py b/tests/unit/wfm/test_activities_service.py new file mode 100644 index 0000000..8fd0fb9 --- /dev/null +++ b/tests/unit/wfm/test_activities_service.py @@ -0,0 +1,35 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.services.wfm.activities_service import ActivitiesService +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity + + +def _make_service(client=None): + client = client or Mock() + return ActivitiesService(client), client + + +class TestList: + def test_delegates_to_client(self): + service, client = _make_service() + client.list.return_value = [sentinel.activity] + result = service.list(start_time=100) + client.list.assert_called_once_with(start_time=100) + assert result == [sentinel.activity] + + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited]) + def test_propagates_error(self, error_cls): + service, client = _make_service() + client.list.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.list(start_time=100) + + +class TestListWithRelationships: + def test_delegates_to_client(self): + service, client = _make_service() + client.list_with_relationships.return_value = ([], [], []) + result = service.list_with_relationships(start_time=100) + client.list_with_relationships.assert_called_once_with(start_time=100) + assert result == ([], [], []) diff --git a/tests/unit/wfm/test_activity.py b/tests/unit/wfm/test_activity.py new file mode 100644 index 0000000..07d6746 --- /dev/null +++ b/tests/unit/wfm/test_activity.py @@ -0,0 +1,110 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.wfm.activity import Activity +from libzapi.infrastructure.api_clients.wfm import ActivityApiClient + +MODULE = "libzapi.infrastructure.api_clients.wfm.activity_api_client" + +strategy = builds(Activity, id=just("abc-123"), agentId=just(1), startTime=just(100), type=just("ticket")) + + +@given(strategy) +def test_logical_key(model: Activity): + assert model.logical_key.as_str() == "wfm_activity:abc-123" + + +def test_list_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"data": [{}], "metadata": {}} + client = ActivityApiClient(https) + list(client.list(start_time=1706820299)) + https.get.assert_called_with("/wfm/public/api/v1/activities?startTime=1706820299") + + +def test_list_follows_pagination(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = [ + {"data": [{}], "metadata": {"next": "/wfm/public/api/v1/activities?startTime=999"}}, + {"data": [{}], "metadata": {}}, + ] + client = ActivityApiClient(https) + results = list(client.list(start_time=100)) + assert len(results) == 2 + assert https.get.call_count == 2 + + +def test_list_handles_absolute_next_url(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = [ + {"data": [{}], "metadata": {"next": "https://example.zendesk.com/wfm/public/api/v1/activities?startTime=999"}}, + {"data": [], "metadata": {}}, + ] + client = ActivityApiClient(https) + list(client.list(start_time=100)) + https.get.assert_any_call("/wfm/public/api/v1/activities?startTime=999") + + +def test_list_with_relationships(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = { + "data": [{}], + "relationships": {"agent": [{}], "activityType": [{}]}, + } + client = ActivityApiClient(https) + activities, agents, types = client.list_with_relationships(start_time=100) + assert len(activities) == 1 + assert len(agents) == 1 + assert len(types) == 1 + + +def test_list_empty_data(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"data": [], "metadata": {}} + client = ActivityApiClient(https) + assert list(client.list(start_time=100)) == [] + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = ActivityApiClient(https) + with pytest.raises(error_cls): + list(client.list(start_time=100)) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_with_relationships_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = ActivityApiClient(https) + with pytest.raises(error_cls): + client.list_with_relationships(start_time=100) diff --git a/tests/unit/wfm/test_report.py b/tests/unit/wfm/test_report.py new file mode 100644 index 0000000..9f8fcba --- /dev/null +++ b/tests/unit/wfm/test_report.py @@ -0,0 +1,64 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.wfm.report import Grouping, ReportRow +from libzapi.infrastructure.api_clients.wfm import ReportApiClient + +MODULE = "libzapi.infrastructure.api_clients.wfm.report_api_client" + +strategy = builds( + ReportRow, + groupings=just([Grouping(key="agent", value="john")]), + metrics=just([]), +) + + +@given(strategy) +def test_logical_key(model: ReportRow): + assert model.logical_key.as_str() == "wfm_report_row:john" + + +def test_get_data_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"data": [{}]} + client = ReportApiClient(https) + list(client.get_data(template_id="tmpl-1", start_time=100, end_time=200)) + https.get.assert_called_with("/wfm/public/api/v1/reports/tmpl-1/data?startTime=100&endTime=200") + + +def test_get_data_empty(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"data": []} + client = ReportApiClient(https) + assert list(client.get_data(template_id="t", start_time=1, end_time=2)) == [] + + +def test_get_data_with_relationships(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"success": True, "data": [], "relationships": {}} + client = ReportApiClient(https) + result = client.get_data_with_relationships(template_id="t", start_time=1, end_time=2) + assert result["success"] is True + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_data_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = ReportApiClient(https) + with pytest.raises(error_cls): + list(client.get_data(template_id="t", start_time=1, end_time=2)) diff --git a/tests/unit/wfm/test_reports_service.py b/tests/unit/wfm/test_reports_service.py new file mode 100644 index 0000000..3a4f074 --- /dev/null +++ b/tests/unit/wfm/test_reports_service.py @@ -0,0 +1,34 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.services.wfm.reports_service import ReportsService +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity + + +def _make_service(client=None): + client = client or Mock() + return ReportsService(client), client + + +class TestGetData: + def test_delegates_to_client(self): + service, client = _make_service() + client.get_data.return_value = [sentinel.row] + result = service.get_data(template_id="t1", start_time=100, end_time=200) + client.get_data.assert_called_once_with(template_id="t1", start_time=100, end_time=200) + assert result == [sentinel.row] + + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited]) + def test_propagates_error(self, error_cls): + service, client = _make_service() + client.get_data.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.get_data(template_id="t1", start_time=100, end_time=200) + + +class TestGetDataWithRelationships: + def test_delegates_to_client(self): + service, client = _make_service() + client.get_data_with_relationships.return_value = {"data": []} + result = service.get_data_with_relationships(template_id="t1", start_time=100, end_time=200) + assert result == {"data": []} diff --git a/tests/unit/wfm/test_shift.py b/tests/unit/wfm/test_shift.py new file mode 100644 index 0000000..9baed16 --- /dev/null +++ b/tests/unit/wfm/test_shift.py @@ -0,0 +1,63 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.application.commands.wfm.shift_cmds import FetchShiftsCmd +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.wfm.shift import Shift +from libzapi.infrastructure.api_clients.wfm import ShiftApiClient + +MODULE = "libzapi.infrastructure.api_clients.wfm.shift_api_client" + +strategy = builds(Shift, id=just("shift-1"), agentId=just(1), startTime=just(100), endTime=just(200)) + + +@given(strategy) +def test_logical_key(model: Shift): + assert model.logical_key.as_str() == "wfm_shift:shift-1" + + +def test_fetch_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch( + f"{MODULE}.to_payload_fetch", + return_value={"startDate": "2024-01-01", "endDate": "2024-01-31"}, + ) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"data": [{}]} + client = ShiftApiClient(https) + cmd = FetchShiftsCmd(startDate="2024-01-01", endDate="2024-01-31") + list(client.fetch(cmd=cmd)) + https.post.assert_called_with( + "/wfm/public/api/v1/shifts/fetch", + json={"startDate": "2024-01-01", "endDate": "2024-01-31"}, + ) + + +def test_fetch_empty(mocker): + mocker.patch(f"{MODULE}.to_payload_fetch", return_value={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"data": []} + client = ShiftApiClient(https) + cmd = FetchShiftsCmd(startDate="2024-01-01", endDate="2024-01-31") + assert list(client.fetch(cmd=cmd)) == [] + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_fetch_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.post.side_effect = error_cls("error") + client = ShiftApiClient(https) + cmd = FetchShiftsCmd(startDate="2024-01-01", endDate="2024-01-31") + with pytest.raises(error_cls): + list(client.fetch(cmd=cmd)) diff --git a/tests/unit/wfm/test_shifts_service.py b/tests/unit/wfm/test_shifts_service.py new file mode 100644 index 0000000..90f9c41 --- /dev/null +++ b/tests/unit/wfm/test_shifts_service.py @@ -0,0 +1,40 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.commands.wfm.shift_cmds import FetchShiftsCmd +from libzapi.application.services.wfm.shifts_service import ShiftsService +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity + + +def _make_service(client=None): + client = client or Mock() + return ShiftsService(client), client + + +class TestFetch: + def test_delegates_to_client_with_cmd(self): + service, client = _make_service() + client.fetch.return_value = [sentinel.shift] + result = service.fetch(start_date="2024-01-01", end_date="2024-01-31") + client.fetch.assert_called_once() + cmd = client.fetch.call_args.kwargs["cmd"] + assert isinstance(cmd, FetchShiftsCmd) + assert cmd.startDate == "2024-01-01" + assert cmd.endDate == "2024-01-31" + assert result == [sentinel.shift] + + def test_passes_optional_fields(self): + service, client = _make_service() + client.fetch.return_value = [] + service.fetch(start_date="2024-01-01", end_date="2024-01-31", agent_ids=[1, 2], published=1, page=2) + cmd = client.fetch.call_args.kwargs["cmd"] + assert cmd.agentIds == [1, 2] + assert cmd.published == 1 + assert cmd.page == 2 + + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited]) + def test_propagates_error(self, error_cls): + service, client = _make_service() + client.fetch.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.fetch(start_date="2024-01-01", end_date="2024-01-31") diff --git a/tests/unit/wfm/test_team.py b/tests/unit/wfm/test_team.py new file mode 100644 index 0000000..b561694 --- /dev/null +++ b/tests/unit/wfm/test_team.py @@ -0,0 +1,200 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.application.commands.wfm.team_cmds import BulkAgentsCmd, CreateTeamCmd, UpdateTeamCmd +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.wfm.team import Team +from libzapi.infrastructure.api_clients.wfm import TeamApiClient + +MODULE = "libzapi.infrastructure.api_clients.wfm.team_api_client" + +strategy = builds(Team, id=just("team-1"), name=just("Support")) + + +@given(strategy) +def test_logical_key(model: Team): + assert model.logical_key.as_str() == "wfm_team:support" + + +def test_list_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"teams": [{}]} + client = TeamApiClient(https) + list(client.list()) + https.get.assert_called_with("/wfm/l5/api/v2/teams") + + +def test_list_with_deleted(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"teams": [{}]} + client = TeamApiClient(https) + list(client.list(deleted=True)) + https.get.assert_called_with("/wfm/l5/api/v2/teams?deleted=true") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {} + client = TeamApiClient(https) + client.get("team-1") + https.get.assert_called_with("/wfm/l5/api/v2/teams/team-1") + + +def test_create(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch( + f"{MODULE}.to_payload_create", + return_value={"name": "T", "description": "D", "manager_id": 1, "agents_ids": []}, + ) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {} + client = TeamApiClient(https) + cmd = CreateTeamCmd(name="T", description="D", manager_id=1, agents_ids=[]) + client.create(cmd=cmd) + https.post.assert_called_with( + "/wfm/l5/api/v2/teams", + json={"name": "T", "description": "D", "manager_id": 1, "agents_ids": []}, + ) + + +def test_update(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"name": "New"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {} + client = TeamApiClient(https) + cmd = UpdateTeamCmd(name="New") + client.update("team-1", cmd=cmd) + https.put.assert_called_with("/wfm/l5/api/v2/teams/team-1", json={"name": "New"}) + + +def test_delete(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = TeamApiClient(https) + client.delete("team-1") + https.delete.assert_called_with("/wfm/l5/api/v2/teams/team-1") + + +def test_restore(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {} + client = TeamApiClient(https) + client.restore("team-1") + https.post.assert_called_with("/wfm/l5/api/v2/teams/restore", json={"id": "team-1"}) + + +def test_bulk_add_agents(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch( + f"{MODULE}.to_payload_bulk_agents", + return_value={"agent_ids": ["a1"], "team_ids": ["t1"]}, + ) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {} + client = TeamApiClient(https) + cmd = BulkAgentsCmd(agent_ids=["a1"], team_ids=["t1"]) + client.bulk_add_agents(cmd=cmd) + https.post.assert_called_with( + "/wfm/l5/api/v2/teams/bulk/add_agents", + json={"agent_ids": ["a1"], "team_ids": ["t1"]}, + ) + + +def test_bulk_remove_agents(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch( + f"{MODULE}.to_payload_bulk_agents", + return_value={"agent_ids": ["a1"], "team_ids": ["t1"]}, + ) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {} + client = TeamApiClient(https) + cmd = BulkAgentsCmd(agent_ids=["a1"], team_ids=["t1"]) + client.bulk_remove_agents(cmd=cmd) + https.post.assert_called_with( + "/wfm/l5/api/v2/teams/bulk/remove_agents", + json={"agent_ids": ["a1"], "team_ids": ["t1"]}, + ) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = TeamApiClient(https) + with pytest.raises(error_cls): + list(client.list()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = TeamApiClient(https) + with pytest.raises(error_cls): + client.get("team-1") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.post.side_effect = error_cls("error") + client = TeamApiClient(https) + cmd = CreateTeamCmd(name="T", description="D", manager_id=1, agents_ids=[]) + with pytest.raises(error_cls): + client.create(cmd=cmd) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.delete.side_effect = error_cls("error") + client = TeamApiClient(https) + with pytest.raises(error_cls): + client.delete("team-1") diff --git a/tests/unit/wfm/test_teams_service.py b/tests/unit/wfm/test_teams_service.py new file mode 100644 index 0000000..651e684 --- /dev/null +++ b/tests/unit/wfm/test_teams_service.py @@ -0,0 +1,100 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.commands.wfm.team_cmds import CreateTeamCmd, UpdateTeamCmd +from libzapi.application.services.wfm.teams_service import TeamsService +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity + + +def _make_service(client=None): + client = client or Mock() + return TeamsService(client), client + + +class TestList: + def test_delegates_to_client(self): + service, client = _make_service() + client.list.return_value = [sentinel.team] + result = service.list() + client.list.assert_called_once_with(deleted=False) + assert result == [sentinel.team] + + def test_with_deleted(self): + service, client = _make_service() + client.list.return_value = [] + service.list(deleted=True) + client.list.assert_called_once_with(deleted=True) + + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited]) + def test_propagates_error(self, error_cls): + service, client = _make_service() + client.list.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.list() + + +class TestGet: + def test_delegates_to_client(self): + service, client = _make_service() + client.get.return_value = sentinel.team + result = service.get("team-1") + client.get.assert_called_once_with(team_id="team-1") + assert result is sentinel.team + + +class TestCreate: + def test_delegates_with_cmd(self): + service, client = _make_service() + client.create.return_value = sentinel.team + result = service.create(name="T", description="D", manager_id=1, agents_ids=["a1"]) + client.create.assert_called_once() + cmd = client.create.call_args.kwargs["cmd"] + assert isinstance(cmd, CreateTeamCmd) + assert cmd.name == "T" + assert result is sentinel.team + + +class TestUpdate: + def test_delegates_with_cmd(self): + service, client = _make_service() + client.update.return_value = sentinel.team + result = service.update("team-1", name="New") + client.update.assert_called_once() + cmd = client.update.call_args.kwargs["cmd"] + assert isinstance(cmd, UpdateTeamCmd) + assert cmd.name == "New" + assert result is sentinel.team + + +class TestDelete: + def test_delegates_to_client(self): + service, client = _make_service() + service.delete("team-1") + client.delete.assert_called_once_with(team_id="team-1") + + +class TestRestore: + def test_delegates_to_client(self): + service, client = _make_service() + client.restore.return_value = sentinel.team + result = service.restore("team-1") + client.restore.assert_called_once_with(team_id="team-1") + assert result is sentinel.team + + +class TestBulkAddAgents: + def test_delegates_with_cmd(self): + service, client = _make_service() + client.bulk_add_agents.return_value = sentinel.result + result = service.bulk_add_agents(agent_ids=["a1"], team_ids=["t1"]) + client.bulk_add_agents.assert_called_once() + assert result is sentinel.result + + +class TestBulkRemoveAgents: + def test_delegates_with_cmd(self): + service, client = _make_service() + client.bulk_remove_agents.return_value = sentinel.result + result = service.bulk_remove_agents(agent_ids=["a1"], team_ids=["t1"]) + client.bulk_remove_agents.assert_called_once() + assert result is sentinel.result diff --git a/tests/unit/wfm/test_time_off.py b/tests/unit/wfm/test_time_off.py new file mode 100644 index 0000000..c4d0749 --- /dev/null +++ b/tests/unit/wfm/test_time_off.py @@ -0,0 +1,104 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.application.commands.wfm.time_off_cmds import ImportTimeOffCmd, ImportTimeOffEntry +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.wfm.time_off import TimeOff +from libzapi.infrastructure.api_clients.wfm import TimeOffApiClient + +MODULE = "libzapi.infrastructure.api_clients.wfm.time_off_api_client" + +strategy = builds( + TimeOff, + timeOffRequestId=just("req-1"), + agentId=just(1), + startTime=just(100), + endTime=just(200), +) + + +@given(strategy) +def test_logical_key(model: TimeOff): + assert model.logical_key.as_str() == "wfm_time_off:req-1" + + +def test_list_calls_correct_path_no_filters(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"data": [{}]} + client = TimeOffApiClient(https) + list(client.list()) + https.get.assert_called_with("/wfm/public/api/v1/timeOff") + + +def test_list_with_filters(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"data": [{}]} + client = TimeOffApiClient(https) + list(client.list(agent_id=42, status="approved", page=1, per_page=25)) + https.get.assert_called_with("/wfm/public/api/v1/timeOff?agentId=42&status=approved&page=1&perPage=25") + + +def test_list_empty(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"data": []} + client = TimeOffApiClient(https) + assert list(client.list()) == [] + + +def test_import_time_off(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch( + f"{MODULE}.to_payload_import", + return_value={"data": [{"agentId": 1, "startTime": 100, "endTime": 200, "reasonId": "r1"}]}, + ) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"success": True, "data": {"entities": [], "inserted": [], "updated": []}} + client = TimeOffApiClient(https) + cmd = ImportTimeOffCmd(data=[ImportTimeOffEntry(agentId=1, startTime=100, endTime=200, reasonId="r1")]) + client.import_time_off(cmd=cmd) + https.post.assert_called_with( + "/wfm/public/api/v1/timeOff/import", + json={"data": [{"agentId": 1, "startTime": 100, "endTime": 200, "reasonId": "r1"}]}, + ) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = TimeOffApiClient(https) + with pytest.raises(error_cls): + list(client.list()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_import_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.post.side_effect = error_cls("error") + client = TimeOffApiClient(https) + cmd = ImportTimeOffCmd(data=[ImportTimeOffEntry(agentId=1, startTime=100, endTime=200, reasonId="r1")]) + with pytest.raises(error_cls): + client.import_time_off(cmd=cmd) diff --git a/tests/unit/wfm/test_time_off_service.py b/tests/unit/wfm/test_time_off_service.py new file mode 100644 index 0000000..1e5e876 --- /dev/null +++ b/tests/unit/wfm/test_time_off_service.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.commands.wfm.time_off_cmds import ImportTimeOffEntry +from libzapi.application.services.wfm.time_off_service import TimeOffService +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity + + +def _make_service(client=None): + client = client or Mock() + return TimeOffService(client), client + + +class TestList: + def test_delegates_to_client(self): + service, client = _make_service() + client.list.return_value = [sentinel.time_off] + result = service.list(agent_id=42, status="approved") + client.list.assert_called_once_with( + time_off_request_id=None, + agent_id=42, + start_time=None, + end_time=None, + status="approved", + reason_id=None, + time_off_type=None, + page=None, + per_page=None, + ) + assert result == [sentinel.time_off] + + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited]) + def test_propagates_error(self, error_cls): + service, client = _make_service() + client.list.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.list() + + +class TestImport: + def test_delegates_to_client(self): + service, client = _make_service() + client.import_time_off.return_value = sentinel.result + entry = ImportTimeOffEntry(agentId=1, startTime=100, endTime=200, reasonId="r1") + result = service.import_time_off(entries=[entry]) + client.import_time_off.assert_called_once() + cmd = client.import_time_off.call_args.kwargs["cmd"] + assert len(cmd.data) == 1 + assert cmd.data[0].agentId == 1 + assert result is sentinel.result + + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited]) + def test_propagates_error(self, error_cls): + service, client = _make_service() + client.import_time_off.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.import_time_off(entries=[]) diff --git a/uv.lock b/uv.lock index fabd18e..38a070d 100644 --- a/uv.lock +++ b/uv.lock @@ -586,7 +586,7 @@ wheels = [ [[package]] name = "libzapi" -version = "0.6.4" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "cattrs" },