319 lines
12 KiB
Python
319 lines
12 KiB
Python
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
|
||
|