from __future__ import annotations import json import os import ssl import time import uuid import urllib.error import urllib.request import urllib.parse from typing import Any, Optional from worklib.auth_as_employer import get_access_token DEFAULT_COMPANY_ID = "65437401ae3af6f8ffcdbaf8" DEFAULT_GRAPHQL_URL = "https://admin.dev.dipal.ru/graphql" _PASSREQUESTS_MOCK_STATE: dict[str, Any] = {} def reset_passrequests_mock_state() -> None: _PASSREQUESTS_MOCK_STATE.clear() def _build_ssl_context(url: str) -> ssl.SSLContext | None: """ На dev-стендах сертификат иногда не проходит системную валидацию на Windows. По умолчанию для *.dev.dipal.ru отключаем verify, но это можно переопределить env SSL_VERIFY=1. """ try: parsed = urllib.parse.urlparse(url) except Exception: return None if (os.getenv("SSL_VERIFY") or "").lower() in {"1", "true", "yes"}: return None if (parsed.scheme or "").lower() != "https": return None host = (parsed.hostname or "").lower() if host.endswith(".dev.dipal.ru") or host == "dev.dipal.ru": return ssl._create_unverified_context() # noqa: SLF001 return None def _mock_passrequests_execute_graphql(*, query: str, variables: Optional[dict[str, Any]], company_id: str, access_token: str) -> dict[str, Any]: state = _PASSREQUESTS_MOCK_STATE.setdefault( company_id, {"places": {}, "users": {}, "passes": {}, "pass_requests_by_pass_id": {}, "members_by_place": {}}, ) q = query or "" vars_ = variables or {} def _new_id(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex[:12]}" # createPlaceMultiple -> [{id}] if "createPlaceMultiple" in q: place_id = _new_id("place") parent_id = None if isinstance(vars_, dict): parent_id = vars_.get("parent_id") state["places"][place_id] = {"id": place_id, "parent_id": parent_id} return {"data": {"createPlaceMultiple": [{"id": place_id, "__typename": "Place"}]}} # createEntrance -> JSONObject (mocked as object with id) if "createEntrance" in q: entrance_id = _new_id("entrance") return {"data": {"createEntrance": {"id": entrance_id}}} # createUser -> id if "createUser" in q: account_id = _new_id("user") state["users"][account_id] = {"id": account_id} return {"data": {"createUser": account_id}} # createService -> {id,title,type} if "createService" in q: service_id = _new_id("service") title = (vars_.get("title") or "mock-service") if isinstance(vars_, dict) else "mock-service" stype = (vars_.get("type") or "access") if isinstance(vars_, dict) else "access" return {"data": {"createService": {"id": service_id, "title": title, "type": stype}}} # addPlaceToService/removePlaceFromService -> {id} if "addPlaceToService" in q or "removePlaceFromService" in q: return {"data": {"addPlaceToService": {"id": "ok"}, "removePlaceFromService": {"id": "ok"}}} # addUserToPlace(dto: $input) -> {place_id, member_id} if "addUserToPlace" in q: inp = vars_.get("input") or {} place_id = inp.get("place_id") account_id = inp.get("account_id") if not place_id or not account_id: return {"errors": [{"message": "Bad input", "status": 400}]} member_id = _new_id("member") members = state["members_by_place"].setdefault(str(place_id), []) raw_privs = inp.get("privileges") or inp.get("privilege") or [] if isinstance(raw_privs, str): privs = [raw_privs] elif isinstance(raw_privs, list): privs = raw_privs else: privs = [] members.append( { "id": member_id, "status": "accepted", "privileges": privs, "user": {"id": str(account_id)}, } ) return {"data": {"addUserToPlace": {"place_id": place_id, "member_id": member_id}}} # createPass -> {id} if "createPass" in q: pass_id = _new_id("pass") place_id = None if isinstance(vars_, dict): place_id = vars_.get("place_id") or (vars_.get("dto") or {}).get("place_id") state["passes"][pass_id] = {"id": pass_id, "place_id": place_id} pr_id = _new_id("passreq") parent_place_id = None if place_id and isinstance(state.get("places"), dict): parent_place_id = (state["places"].get(str(place_id)) or {}).get("parent_id") state["pass_requests_by_pass_id"][pass_id] = { "id": pr_id, "status": "pending", "pass_id": pass_id, "confirmer_ids": [], "created_at": str(int(time.time())), "updated_at": str(int(time.time())), "place_id": parent_place_id or place_id or "mock_parent_place", } return {"data": {"createPass": {"id": pass_id}}} # passRequests(filters:{pass_id}) -> results[{...}] if "passRequests" in q: pass_id = vars_.get("pass_id") pr = state["pass_requests_by_pass_id"].get(pass_id) if not pr: return {"data": {"passRequests": {"results": []}}} return { "data": { "passRequests": { "results": [ { "id": pr["id"], "status": pr["status"], "pass_id": pr["pass_id"], "place_id": pr.get("place_id") or "mock_parent_place", "created_at": pr["created_at"], "updated_at": pr["updated_at"], "place": {"id": pr.get("place_id") or "mock_parent_place"}, "confirmer_ids": pr["confirmer_ids"], } ] } } } # members(filters:{place_id}) -> results[{id,status,privileges,user{id}}] # Важно: place(...) query тоже выбирает поле members, поэтому матчим только конкретные members-запросы. if ("membersByPlace" in q or "query members" in q) and "members(" in q: place_id = None if isinstance(vars_, dict): place_id = vars_.get("place_id") results = state["members_by_place"].get(str(place_id), []) if place_id else [] return {"data": {"members": {"results": results}}} # setUserPlaces -> {id} if "setUserPlaces" in q: dto = None if isinstance(vars_, dict): dto = vars_.get("dto") or vars_ account_id = (dto or {}).get("account_id") place_ids = (dto or {}).get("place_ids") or [] extra_privileges = (dto or {}).get("extra_privileges") or [] for pid in place_ids: members = state["members_by_place"].setdefault(str(pid), []) members.append( { "id": _new_id("member"), "status": "accepted", "privileges": extra_privileges, "user": {"id": str(account_id)}, } ) return {"data": {"setUserPlaces": {"id": "ok"}}} # place(filters:{member_ids/member_id/user_ids}) -> results[{id,members{...}}] if "place(" in q and "filters" in q: member_ids = [] if isinstance(vars_, dict): member_ids = vars_.get("member_ids") or vars_.get("user_ids") or [] mid = vars_.get("member_id") if mid: member_ids = [mid] rows = [] for pid, members in (state.get("members_by_place") or {}).items(): if member_ids: found = False for m in members: u = (m or {}).get("user") if isinstance(m, dict) else None if isinstance(u, dict) and u.get("id") in member_ids: found = True break if not found: continue rows.append({"id": pid, "members": members}) return {"data": {"place": {"results": rows}}} # approve/confirm -> if already rejected then no-op; else require 2 confirmations to become active if "approvePassRequest" in q or "confirmPassRequest" in q: pr_id = vars_.get("pass_request_id") or vars_.get("id") pr = next((v for v in state["pass_requests_by_pass_id"].values() if v["id"] == pr_id), None) if pr and pr["status"] != "rejected": conf_id = access_token or "mock_employee" if conf_id not in pr["confirmer_ids"]: pr["confirmer_ids"].append(conf_id) pr["status"] = "active" if len(pr["confirmer_ids"]) >= 2 else "pending" return {"data": {"approvePassRequest": True}} # rejectPassRequest(pass_request_id) -> sets rejected permanently if "rejectPassRequest" in q: pr_id = vars_.get("pass_request_id") or vars_.get("id") pr = next((v for v in state["pass_requests_by_pass_id"].values() if v["id"] == pr_id), None) if pr: pr["status"] = "rejected" return {"data": {"rejectPassRequest": True}} # deleteUser/deletePlace/deletePass/deleteTicket etc -> True if "deleteTicket" in q: return {"data": {"deleteTicket": True}} if "deleteUser" in q or "deletePlace" in q or "deletePass" in q: return {"data": {"ok": True}} return {"data": {}} def build_headers(access_token: str, *, company_id: str = DEFAULT_COMPANY_ID) -> dict[str, str]: return { "Authorization": f"Bearer {access_token}", "x-company-id": company_id, } def execute_graphql( *, query: str, variables: Optional[dict[str, Any]] = None, graphql_url: Optional[str] = None, company_id: str = DEFAULT_COMPANY_ID, access_token: Optional[str] = None, timeout_s: int = 30, ) -> dict[str, Any]: graphql_url = graphql_url or os.getenv("GRAPHQL_URL") or DEFAULT_GRAPHQL_URL if not graphql_url: raise ValueError("Не задан GRAPHQL_URL (передайте graphql_url или установите env GRAPHQL_URL)") token = access_token or get_access_token() if os.getenv("SUBSCRIBE_BUNDLE_MOCKS") in {"1", "true", "True"}: from worklib.subscribe_bundle_graphql_mock import execute_subscribe_bundle_mock_graphql data = execute_subscribe_bundle_mock_graphql( query=query, variables=variables, company_id=company_id, access_token=token, ) if isinstance(data, dict) and data.get("errors"): raise RuntimeError(f"GraphQL errors: {data['errors']}") return data if os.getenv("PASSREQUESTS_MOCKS") in {"1", "true", "True"}: data = _mock_passrequests_execute_graphql(query=query, variables=variables, company_id=company_id, access_token=token) if isinstance(data, dict) and data.get("errors"): raise RuntimeError(f"GraphQL errors: {data['errors']}") return data headers = build_headers(token, company_id=company_id) payload: dict[str, Any] = {"query": query} if variables is not None: payload["variables"] = variables req = urllib.request.Request( graphql_url, data=json.dumps(payload).encode("utf-8"), headers={"content-type": "application/json", **headers}, method="POST", ) try: ctx = _build_ssl_context(graphql_url) with urllib.request.urlopen(req, timeout=timeout_s, context=ctx) as resp: raw = resp.read().decode("utf-8") except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") raise RuntimeError(f"GraphQL HTTP {e.code}: {body}") from e data = json.loads(raw) if raw else {} if isinstance(data, dict) and data.get("errors"): errors = data["errors"] # На стенде часть операций возвращает Forbidden как GraphQL error (не HTTP 403). if isinstance(errors, list) and any(getattr(err, "get", lambda *_: None)("status") == 403 for err in errors): raise PermissionError( "Forbidden (403) для GraphQL операции. " "Проверьте креды/права. Можно задать env: AUTH_USERNAME/AUTH_PASSWORD/AUTH_GRANT_TYPE." ) raise RuntimeError(f"GraphQL errors: {errors}") if not isinstance(data, dict): raise RuntimeError(f"GraphQL response is not an object: {data!r}") return data