From d5e4f6d577a4437258f03dcf90737cedae6a861d Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 21 Apr 2026 13:40:29 +0300 Subject: [PATCH] Add LogStorm attendance client --- .env.example | 5 + backend/common/logstorm_client.py | 77 ++++++++++++ backend/eusrr_backend/settings.py | 5 + .../tests/integration/test_logstorm_client.py | 118 ++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 backend/common/logstorm_client.py create mode 100644 backend/tests/integration/test_logstorm_client.py diff --git a/.env.example b/.env.example index 7c23ead0..dd80d406 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,8 @@ NODE_ENV=production # Other Settings # SITE_URL=http://localhost:8000 + +# LogStorm attendance analyzer +LOGSTORM_API_URL=http://localhost:8000 +LOGSTORM_API_TOKEN=change-me +LOGSTORM_TIMEOUT_SECONDS=30 diff --git a/backend/common/logstorm_client.py b/backend/common/logstorm_client.py new file mode 100644 index 00000000..6637e86f --- /dev/null +++ b/backend/common/logstorm_client.py @@ -0,0 +1,77 @@ +"""Client for the external LogStorm attendance analyzer API.""" + +from dataclasses import dataclass +from typing import Any, Optional + +import requests +from django.conf import settings + + +class LogStormClientError(RuntimeError): + """Raised when LogStorm cannot process an attendance request.""" + + +@dataclass(frozen=True) +class LogStormConfig: + base_url: str + token: str = "" + timeout: int = 30 + + +def load_logstorm_config() -> LogStormConfig: + return LogStormConfig( + base_url=getattr( + settings, "LOGSTORM_API_URL", "http://127.0.0.1:8000" + ).rstrip("/"), + token=getattr(settings, "LOGSTORM_API_TOKEN", ""), + timeout=getattr(settings, "LOGSTORM_TIMEOUT_SECONDS", 30), + ) + + +class LogStormClient: + """Small synchronous client for EUSRR -> LogStorm calls.""" + + def __init__( + self, + config: Optional[LogStormConfig] = None, + session: Optional[requests.Session] = None, + ): + self.config = config or load_logstorm_config() + self.session = session or requests.Session() + + def analyze_attendance(self, payload: dict[str, Any]) -> dict[str, Any]: + response = self.session.post( + self._url("/attendance/analyze"), + json=payload, + headers=self._headers(), + timeout=self.config.timeout, + ) + if not response.ok: + raise LogStormClientError( + f"LogStorm API returned HTTP {response.status_code}: " + f"{response.text}" + ) + try: + return response.json() + except ValueError as exc: + raise LogStormClientError("LogStorm API returned invalid JSON") from exc + + def health(self) -> bool: + try: + response = self.session.get( + self._url("/health"), + headers=self._headers(), + timeout=self.config.timeout, + ) + except requests.RequestException: + return False + return response.ok + + def _url(self, path: str) -> str: + return f"{self.config.base_url}/{path.lstrip('/')}" + + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if self.config.token: + headers["Authorization"] = f"Bearer {self.config.token}" + return headers diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index efdb968c..2ed36029 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -203,6 +203,11 @@ LOGIN_REDIRECT_URL = "/admin/" # Используем локальный адрес для избежания проблем с SSL API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api") +# LogStorm attendance analyzer integration. +LOGSTORM_API_URL = os.getenv("LOGSTORM_API_URL", "http://127.0.0.1:8000") +LOGSTORM_API_TOKEN = os.getenv("LOGSTORM_API_TOKEN", "") +LOGSTORM_TIMEOUT_SECONDS = int(os.getenv("LOGSTORM_TIMEOUT_SECONDS", "30")) + PHONE_DEFAULT_REGION = os.getenv("PHONE_DEFAULT_REGION", "RU") PHONENUMBER_DEFAULT_REGION = "RU" diff --git a/backend/tests/integration/test_logstorm_client.py b/backend/tests/integration/test_logstorm_client.py new file mode 100644 index 00000000..c15e972f --- /dev/null +++ b/backend/tests/integration/test_logstorm_client.py @@ -0,0 +1,118 @@ +import pytest + +from common.logstorm_client import ( + LogStormClient, + LogStormClientError, + LogStormConfig, + load_logstorm_config, +) + + +class FakeResponse: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self._payload = payload + self.text = text + self.ok = 200 <= status_code < 400 + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class FakeSession: + def __init__(self, response): + self.response = response + self.requests = [] + + def post(self, url, **kwargs): + self.requests.append(("POST", url, kwargs)) + return self.response + + def get(self, url, **kwargs): + self.requests.append(("GET", url, kwargs)) + return self.response + + +def _payload(): + return { + "employee_id": "100", + "period_start": "2026-04-20", + "period_end": "2026-04-20", + "schedule": { + "start_time": "09:00", + "end_time": "18:00", + "expected_hours": 9, + "workdays": ["Monday"], + "date_overrides": [], + }, + } + + +def test_load_logstorm_config_reads_settings(settings): + settings.LOGSTORM_API_URL = "http://logstorm:8000/" + settings.LOGSTORM_API_TOKEN = "secret" + settings.LOGSTORM_TIMEOUT_SECONDS = 12 + + config = load_logstorm_config() + + assert config.base_url == "http://logstorm:8000" + assert config.token == "secret" + assert config.timeout == 12 + + +def test_analyze_attendance_posts_payload_with_bearer_token(): + session = FakeSession(FakeResponse(payload={"records": []})) + client = LogStormClient( + LogStormConfig( + base_url="http://logstorm:8000", + token="secret", + timeout=7, + ), + session=session, + ) + + result = client.analyze_attendance(_payload()) + + assert result == {"records": []} + method, url, kwargs = session.requests[0] + assert method == "POST" + assert url == "http://logstorm:8000/attendance/analyze" + assert kwargs["json"]["employee_id"] == "100" + assert kwargs["headers"]["Authorization"] == "Bearer secret" + assert kwargs["timeout"] == 7 + + +def test_analyze_attendance_raises_on_http_error(): + client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(status_code=500, text="boom")), + ) + + with pytest.raises(LogStormClientError, match="HTTP 500"): + client.analyze_attendance(_payload()) + + +def test_analyze_attendance_raises_on_invalid_json(): + client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(payload=ValueError("bad json"))), + ) + + with pytest.raises(LogStormClientError, match="invalid JSON"): + client.analyze_attendance(_payload()) + + +def test_health_returns_boolean(): + ok_client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(status_code=200, payload={})), + ) + bad_client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(status_code=503, payload={})), + ) + + assert ok_client.health() is True + assert bad_client.health() is False