first
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""Google Analytics 4 - Admin API (read: accounts, properties, data streams).
|
||||
|
||||
https://developers.google.com/analytics/devguides/config/admin/v1
|
||||
|
||||
Credentials: X-GA-Access-Token (preferred) or X-GA-Credentials.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from ..clients.ga_client import GoogleAnalyticsClient
|
||||
from ..credentials import GaCredentials, get_ga_credentials
|
||||
|
||||
router = APIRouter(prefix="/ga/admin", tags=["google-analytics: admin"])
|
||||
|
||||
|
||||
def _property(property_id: str) -> str:
|
||||
pid = property_id.strip()
|
||||
return pid if pid.startswith("properties/") else f"properties/{pid}"
|
||||
|
||||
|
||||
@router.get("/accounts", summary="List accessible GA4 accounts")
|
||||
async def list_accounts(
|
||||
page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
params = _paging(page_size, page_token)
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get("/accounts", params=params)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/accountSummaries",
|
||||
summary="List account summaries (accounts + their properties)",
|
||||
)
|
||||
async def list_account_summaries(
|
||||
page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
params = _paging(page_size, page_token)
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get("/accountSummaries", params=params)
|
||||
|
||||
|
||||
@router.get("/properties", summary="List properties under an account")
|
||||
async def list_properties(
|
||||
account_id: str = Query(
|
||||
...,
|
||||
alias="accountId",
|
||||
description="Numeric account id; the filter parent:accounts/{id} is built for you.",
|
||||
),
|
||||
page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
show_deleted: bool | None = Query(None, alias="showDeleted"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
params: dict[str, Any] = {"filter": f"parent:accounts/{account_id.strip()}"}
|
||||
params.update(_paging(page_size, page_token))
|
||||
if show_deleted is not None:
|
||||
params["showDeleted"] = show_deleted
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get("/properties", params=params)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/properties/{property_id}",
|
||||
summary="Get a single GA4 property",
|
||||
)
|
||||
async def get_property(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get(f"/{_property(property_id)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/properties/{property_id}/dataStreams",
|
||||
summary="List data streams of a GA4 property",
|
||||
)
|
||||
async def list_data_streams(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
page_size: int | None = Query(None, ge=1, le=200, alias="pageSize"),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
params = _paging(page_size, page_token)
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.admin_get(
|
||||
f"/{_property(property_id)}/dataStreams", params=params
|
||||
)
|
||||
|
||||
|
||||
def _paging(page_size: int | None, page_token: str | None) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if page_size is not None:
|
||||
params["pageSize"] = page_size
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
return params
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Google Analytics 4 - Data API (reporting).
|
||||
|
||||
Thin passthrough: request bodies are the GA4 Data API request objects and
|
||||
responses are returned as-is. See
|
||||
https://developers.google.com/analytics/devguides/reporting/data/v1/rest
|
||||
|
||||
Credentials: X-GA-Access-Token (preferred) or X-GA-Credentials. See
|
||||
``app.credentials.get_ga_credentials``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
|
||||
from ..clients.ga_client import GoogleAnalyticsClient
|
||||
from ..credentials import GaCredentials, get_ga_credentials
|
||||
|
||||
router = APIRouter(prefix="/ga/data", tags=["google-analytics: data"])
|
||||
|
||||
# Reused OpenAPI example for report request bodies.
|
||||
_RUN_REPORT_EXAMPLE = {
|
||||
"dateRanges": [{"startDate": "7daysAgo", "endDate": "today"}],
|
||||
"dimensions": [{"name": "country"}],
|
||||
"metrics": [{"name": "activeUsers"}],
|
||||
}
|
||||
|
||||
|
||||
def _property(property_id: str) -> str:
|
||||
# Accept both "123456789" and "properties/123456789".
|
||||
pid = property_id.strip()
|
||||
return pid if pid.startswith("properties/") else f"properties/{pid}"
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/runReport",
|
||||
summary="Run a GA4 report",
|
||||
)
|
||||
async def run_report(
|
||||
property_id: str = Path(..., description="GA4 property id, e.g. 123456789"),
|
||||
body: dict[str, Any] = Body(..., examples=[_RUN_REPORT_EXAMPLE]),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(f"/{_property(property_id)}:runReport", body)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/runPivotReport",
|
||||
summary="Run a GA4 pivot report",
|
||||
)
|
||||
async def run_pivot_report(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:runPivotReport", body
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/batchRunReports",
|
||||
summary="Run up to 5 GA4 reports in one call",
|
||||
)
|
||||
async def batch_run_reports(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:batchRunReports", body
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/batchRunPivotReports",
|
||||
summary="Run up to 5 GA4 pivot reports in one call",
|
||||
)
|
||||
async def batch_run_pivot_reports(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:batchRunPivotReports", body
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/runRealtimeReport",
|
||||
summary="Run a GA4 realtime report",
|
||||
)
|
||||
async def run_realtime_report(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:runRealtimeReport", body
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/properties/{property_id}/checkCompatibility",
|
||||
summary="Check dimension/metric compatibility for a GA4 report",
|
||||
)
|
||||
async def check_compatibility(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
body: dict[str, Any] = Body(...),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_post(
|
||||
f"/{_property(property_id)}:checkCompatibility", body
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/properties/{property_id}/metadata",
|
||||
summary="List available GA4 dimensions and metrics for a property",
|
||||
)
|
||||
async def get_metadata(
|
||||
property_id: str = Path(..., description="GA4 property id"),
|
||||
creds: GaCredentials = Depends(get_ga_credentials),
|
||||
) -> Any:
|
||||
client = GoogleAnalyticsClient(creds)
|
||||
return await client.data_get(f"/{_property(property_id)}/metadata")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Infrastructure endpoints required by AppFactory. No credentials needed."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .. import config
|
||||
|
||||
router = APIRouter(tags=["meta"])
|
||||
|
||||
|
||||
@router.get("/health", summary="Liveness/readiness probe")
|
||||
def health() -> dict:
|
||||
"""Return 200 while the app can serve traffic. Used by AppFactory monitoring."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/version", summary="Service version and build info")
|
||||
def version() -> dict:
|
||||
return {
|
||||
"app": config.APP_NAME,
|
||||
"version": config.APP_VERSION,
|
||||
"language": "python",
|
||||
"root_path": config.ROOT_PATH,
|
||||
"integrations": ["google-analytics", "sklik"],
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Sklik (Seznam) - JSON-RPC proxy.
|
||||
|
||||
The proxy logs in with X-Sklik-Token per request (client.loginByToken) and then
|
||||
performs the requested call, injecting the session for you. See
|
||||
``app.clients.sklik_client`` and https://api.sklik.cz/drak/ for methods.
|
||||
|
||||
Credentials: X-Sklik-Token (required).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, Path
|
||||
|
||||
from ..clients.sklik_client import SklikClient
|
||||
from ..credentials import get_sklik_token
|
||||
from ..errors import UpstreamError
|
||||
|
||||
router = APIRouter(prefix="/sklik", tags=["sklik"])
|
||||
|
||||
# Entities that expose createReport/readReport for the report helper.
|
||||
_REPORT_ENTITIES = {
|
||||
"campaigns",
|
||||
"groups",
|
||||
"ads",
|
||||
"keywords",
|
||||
"queries",
|
||||
"sitelinks",
|
||||
"productSets",
|
||||
"banners",
|
||||
}
|
||||
|
||||
_REPORT_EXAMPLE = [
|
||||
{"dateFrom": "2026-06-01", "dateTo": "2026-06-18", "statGranularity": "daily"},
|
||||
{"statGranularity": "daily"},
|
||||
]
|
||||
|
||||
|
||||
def _optional_user_id(
|
||||
x_sklik_user_id: str | None = Header(
|
||||
default=None,
|
||||
alias="X-Sklik-User-Id",
|
||||
description="Optional managed account id (userId) to act on behalf of, "
|
||||
"for agency/MCC access.",
|
||||
),
|
||||
) -> int | None:
|
||||
raw = (x_sklik_user_id or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError as exc:
|
||||
raise UpstreamError(
|
||||
"X-Sklik-User-Id must be an integer.", status=400
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/login", summary="Verify the Sklik token (client.loginByToken)")
|
||||
async def login(
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> dict:
|
||||
"""Check the token works. The session itself is internal and not returned."""
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
payload = await client.login()
|
||||
return {
|
||||
"valid": True,
|
||||
"status": payload.get("status"),
|
||||
"statusMessage": payload.get("statusMessage"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/limits", summary="API limits and quota (api.limits)")
|
||||
async def limits(
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.call("api.limits")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/report/{entity}",
|
||||
summary="Create and read a Sklik stats report (createReport + readReport)",
|
||||
)
|
||||
async def report(
|
||||
entity: str = Path(
|
||||
...,
|
||||
description="Entity to report on: "
|
||||
+ ", ".join(sorted(_REPORT_ENTITIES)),
|
||||
),
|
||||
body: list[Any] = Body(
|
||||
...,
|
||||
examples=[_REPORT_EXAMPLE],
|
||||
description="Arguments for {entity}.createReport (restriction filter and "
|
||||
"optional display options). The session is injected automatically.",
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
if entity not in _REPORT_ENTITIES:
|
||||
raise UpstreamError(
|
||||
f"Unsupported report entity '{entity}'. Allowed: "
|
||||
+ ", ".join(sorted(_REPORT_ENTITIES)),
|
||||
status=400,
|
||||
)
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.fetch_report(entity, body)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rpc/{method}",
|
||||
summary="Generic authenticated Sklik call (any method)",
|
||||
)
|
||||
async def rpc(
|
||||
method: str = Path(
|
||||
...,
|
||||
description="Sklik method name, e.g. campaigns.list, groups.list, "
|
||||
"ads.list, api.limits. (client.loginByToken is managed by the proxy.)",
|
||||
),
|
||||
args: list[Any] = Body(
|
||||
default=[],
|
||||
description="Positional arguments AFTER the session struct (which the "
|
||||
"proxy injects as the first argument). Example for campaigns.list: "
|
||||
'[{"statuses": ["active"]}, {"displayColumns": ["id","name"]}]',
|
||||
),
|
||||
token: str = Depends(get_sklik_token),
|
||||
user_id: int | None = Depends(_optional_user_id),
|
||||
) -> Any:
|
||||
async with SklikClient(token, user_id=user_id) as client:
|
||||
return await client.call(method, args)
|
||||
Reference in New Issue
Block a user