google-analytics-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ga_cli/__init__.py +8 -0
- ga_cli/api/__init__.py +0 -0
- ga_cli/api/client.py +142 -0
- ga_cli/auth/__init__.py +35 -0
- ga_cli/auth/credentials.py +126 -0
- ga_cli/auth/oauth.py +322 -0
- ga_cli/auth/service_account.py +155 -0
- ga_cli/commands/__init__.py +0 -0
- ga_cli/commands/access_bindings.py +254 -0
- ga_cli/commands/access_reports.py +201 -0
- ga_cli/commands/account_summaries.py +68 -0
- ga_cli/commands/accounts.py +297 -0
- ga_cli/commands/agent_cmd.py +776 -0
- ga_cli/commands/annotations.py +264 -0
- ga_cli/commands/audiences.py +223 -0
- ga_cli/commands/auth_cmd.py +205 -0
- ga_cli/commands/bigquery_links.py +309 -0
- ga_cli/commands/calculated_metrics.py +312 -0
- ga_cli/commands/channel_groups.py +223 -0
- ga_cli/commands/completions_cmd.py +55 -0
- ga_cli/commands/config_cmd.py +113 -0
- ga_cli/commands/custom_dimensions.py +272 -0
- ga_cli/commands/custom_metrics.py +305 -0
- ga_cli/commands/data_retention.py +153 -0
- ga_cli/commands/data_streams.py +277 -0
- ga_cli/commands/event_create_rules.py +250 -0
- ga_cli/commands/event_edit_rules.py +292 -0
- ga_cli/commands/firebase_links.py +142 -0
- ga_cli/commands/google_ads_links.py +225 -0
- ga_cli/commands/key_events.py +269 -0
- ga_cli/commands/mp_secrets.py +265 -0
- ga_cli/commands/properties.py +330 -0
- ga_cli/commands/property_settings.py +287 -0
- ga_cli/commands/reports.py +726 -0
- ga_cli/commands/upgrade_cmd.py +148 -0
- ga_cli/config/__init__.py +0 -0
- ga_cli/config/constants.py +61 -0
- ga_cli/config/store.py +115 -0
- ga_cli/main.py +110 -0
- ga_cli/utils/__init__.py +20 -0
- ga_cli/utils/describe.py +129 -0
- ga_cli/utils/dry_run.py +40 -0
- ga_cli/utils/errors.py +150 -0
- ga_cli/utils/output.py +209 -0
- ga_cli/utils/pagination.py +93 -0
- google_analytics_cli-0.1.0.dist-info/METADATA +321 -0
- google_analytics_cli-0.1.0.dist-info/RECORD +49 -0
- google_analytics_cli-0.1.0.dist-info/WHEEL +4 -0
- google_analytics_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Service account authentication.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
1. GA_CLI_SERVICE_ACCOUNT env var (path to key file)
|
|
5
|
+
2. GOOGLE_APPLICATION_CREDENTIALS env var (standard Google convention)
|
|
6
|
+
3. Saved auth method from a previous ``ga auth login --service-account`` call
|
|
7
|
+
|
|
8
|
+
Equivalent to GTM CLI's auth/service-account.ts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from google.auth.transport.requests import Request
|
|
20
|
+
from google.oauth2 import service_account
|
|
21
|
+
|
|
22
|
+
from ..config.constants import OAUTH_SCOPES, get_auth_method_path, get_config_dir
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_service_account_key(key_path: str) -> dict:
|
|
28
|
+
"""Validate a service account key file.
|
|
29
|
+
|
|
30
|
+
Checks that the JSON is valid and contains required fields.
|
|
31
|
+
Returns the parsed key data.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
FileNotFoundError: If the key file does not exist.
|
|
35
|
+
ValueError: If the file is not valid service account JSON.
|
|
36
|
+
"""
|
|
37
|
+
path = Path(key_path)
|
|
38
|
+
|
|
39
|
+
if not path.exists():
|
|
40
|
+
raise FileNotFoundError(f"Service account key file not found: {key_path}")
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(path.read_text())
|
|
44
|
+
except json.JSONDecodeError as exc:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Invalid JSON in service account key file: {key_path}"
|
|
47
|
+
) from exc
|
|
48
|
+
|
|
49
|
+
if data.get("type") != "service_account":
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Invalid key file: expected type 'service_account', "
|
|
52
|
+
f"got '{data.get('type', '<missing>')}'"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
missing = [f for f in ("private_key", "client_email") if not data.get(f)]
|
|
56
|
+
if missing:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Invalid key file: missing required fields: {', '.join(missing)}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def login_with_service_account(key_path: str) -> str:
|
|
65
|
+
"""Login with a service account key file.
|
|
66
|
+
|
|
67
|
+
Validates the key, tests authentication, and saves the auth method.
|
|
68
|
+
Returns the service account email.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
FileNotFoundError: If the key file does not exist.
|
|
72
|
+
ValueError: If the key file is invalid.
|
|
73
|
+
google.auth.exceptions.RefreshError: If authentication fails.
|
|
74
|
+
"""
|
|
75
|
+
key_data = validate_service_account_key(key_path)
|
|
76
|
+
|
|
77
|
+
# Test that we can actually get a token
|
|
78
|
+
creds = service_account.Credentials.from_service_account_file(
|
|
79
|
+
key_path,
|
|
80
|
+
scopes=OAUTH_SCOPES,
|
|
81
|
+
)
|
|
82
|
+
creds.refresh(Request())
|
|
83
|
+
|
|
84
|
+
# Save auth method config
|
|
85
|
+
_save_auth_method(
|
|
86
|
+
{
|
|
87
|
+
"method": "service-account",
|
|
88
|
+
"service_account_path": str(Path(key_path).resolve()),
|
|
89
|
+
"service_account_email": key_data["client_email"],
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return key_data["client_email"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_service_account_credentials() -> Optional[service_account.Credentials]:
|
|
97
|
+
"""Get service account credentials if configured.
|
|
98
|
+
|
|
99
|
+
Check order:
|
|
100
|
+
1. GA_CLI_SERVICE_ACCOUNT env var
|
|
101
|
+
2. GOOGLE_APPLICATION_CREDENTIALS env var
|
|
102
|
+
3. Saved auth method from previous login
|
|
103
|
+
|
|
104
|
+
Returns None if no service account is configured (OAuth should be used).
|
|
105
|
+
"""
|
|
106
|
+
# Check env vars first
|
|
107
|
+
for env_var in ("GA_CLI_SERVICE_ACCOUNT", "GOOGLE_APPLICATION_CREDENTIALS"):
|
|
108
|
+
key_path = os.environ.get(env_var)
|
|
109
|
+
if key_path:
|
|
110
|
+
validate_service_account_key(key_path)
|
|
111
|
+
return service_account.Credentials.from_service_account_file(
|
|
112
|
+
key_path,
|
|
113
|
+
scopes=OAUTH_SCOPES,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Check saved auth method
|
|
117
|
+
auth_method = _load_auth_method()
|
|
118
|
+
if auth_method and auth_method.get("method") == "service-account":
|
|
119
|
+
key_path = auth_method.get("service_account_path")
|
|
120
|
+
if key_path:
|
|
121
|
+
validate_service_account_key(key_path)
|
|
122
|
+
return service_account.Credentials.from_service_account_file(
|
|
123
|
+
key_path,
|
|
124
|
+
scopes=OAUTH_SCOPES,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _save_auth_method(config: dict) -> None:
|
|
131
|
+
"""Save auth method configuration."""
|
|
132
|
+
config_dir = get_config_dir()
|
|
133
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
get_auth_method_path().write_text(json.dumps(config, indent=2))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _load_auth_method() -> Optional[dict]:
|
|
138
|
+
"""Load auth method configuration."""
|
|
139
|
+
try:
|
|
140
|
+
return json.loads(get_auth_method_path().read_text())
|
|
141
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def clear_auth_method() -> None:
|
|
146
|
+
"""Delete auth method configuration."""
|
|
147
|
+
try:
|
|
148
|
+
get_auth_method_path().unlink()
|
|
149
|
+
except FileNotFoundError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_auth_method() -> Optional[dict]:
|
|
154
|
+
"""Public access to load auth method (used by auth status command)."""
|
|
155
|
+
return _load_auth_method()
|
|
File without changes
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Access binding management commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..api.client import get_admin_alpha_client
|
|
9
|
+
from ..config.store import get_effective_value
|
|
10
|
+
from ..utils import handle_error, info, output, resolve_output_format, success
|
|
11
|
+
from ..utils.pagination import paginate_all
|
|
12
|
+
|
|
13
|
+
access_bindings_app = typer.Typer(
|
|
14
|
+
name="access-bindings",
|
|
15
|
+
help="Manage access bindings (user-role assignments)",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_parent(
|
|
21
|
+
account_id: Optional[str], property_id: Optional[str]
|
|
22
|
+
) -> tuple[str, str]:
|
|
23
|
+
"""Resolve the parent resource for access binding commands.
|
|
24
|
+
|
|
25
|
+
Exactly one of account_id or property_id must be provided.
|
|
26
|
+
property_id falls back to config default if not explicitly set.
|
|
27
|
+
|
|
28
|
+
Returns (parent_string, parent_type) where parent_type is
|
|
29
|
+
"accounts" or "properties".
|
|
30
|
+
"""
|
|
31
|
+
if account_id and property_id:
|
|
32
|
+
raise typer.BadParameter(
|
|
33
|
+
"Provide either --account-id or --property-id, not both."
|
|
34
|
+
)
|
|
35
|
+
if account_id:
|
|
36
|
+
return f"accounts/{account_id}", "accounts"
|
|
37
|
+
|
|
38
|
+
# Only fall back to config default when --account-id is not provided
|
|
39
|
+
effective_property = get_effective_value(property_id, "default_property_id")
|
|
40
|
+
if effective_property:
|
|
41
|
+
return f"properties/{effective_property}", "properties"
|
|
42
|
+
raise typer.BadParameter(
|
|
43
|
+
"Either --account-id or --property-id is required."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_access_bindings_resource(admin, parent_type: str):
|
|
48
|
+
"""Get the correct accessBindings API resource for the parent type."""
|
|
49
|
+
if parent_type == "accounts":
|
|
50
|
+
return admin.accounts().accessBindings()
|
|
51
|
+
return admin.properties().accessBindings()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_roles(roles: list[str]) -> str:
|
|
55
|
+
"""Strip predefinedRoles/ prefix for display."""
|
|
56
|
+
return ", ".join(r.removeprefix("predefinedRoles/") for r in roles)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@access_bindings_app.command("list")
|
|
60
|
+
def list_cmd(
|
|
61
|
+
account_id: Optional[str] = typer.Option(
|
|
62
|
+
None, "--account-id", "-a", help="Account ID"
|
|
63
|
+
),
|
|
64
|
+
property_id: Optional[str] = typer.Option(
|
|
65
|
+
None, "--property-id", "-p", help="Property ID (numeric)"
|
|
66
|
+
),
|
|
67
|
+
output_format: Optional[str] = typer.Option(
|
|
68
|
+
None, "--output", "-o", help="Output format (json, table, compact)"
|
|
69
|
+
),
|
|
70
|
+
):
|
|
71
|
+
"""List access bindings for an account or property."""
|
|
72
|
+
try:
|
|
73
|
+
parent, parent_type = _resolve_parent(account_id, property_id)
|
|
74
|
+
effective_format = resolve_output_format(output_format)
|
|
75
|
+
|
|
76
|
+
admin = get_admin_alpha_client()
|
|
77
|
+
resource = _get_access_bindings_resource(admin, parent_type)
|
|
78
|
+
bindings = paginate_all(
|
|
79
|
+
lambda **kw: resource.list(parent=parent, **kw).execute(),
|
|
80
|
+
"accessBindings",
|
|
81
|
+
pageSize=500,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if effective_format != "json":
|
|
85
|
+
for b in bindings:
|
|
86
|
+
if "roles" in b:
|
|
87
|
+
b["_roles_display"] = _format_roles(b["roles"])
|
|
88
|
+
|
|
89
|
+
output(
|
|
90
|
+
bindings,
|
|
91
|
+
effective_format,
|
|
92
|
+
columns=["name", "user", "_roles_display"],
|
|
93
|
+
headers=["Resource Name", "User", "Roles"],
|
|
94
|
+
)
|
|
95
|
+
except typer.BadParameter:
|
|
96
|
+
raise
|
|
97
|
+
except Exception as e:
|
|
98
|
+
handle_error(e)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@access_bindings_app.command("get")
|
|
102
|
+
def get_cmd(
|
|
103
|
+
account_id: Optional[str] = typer.Option(
|
|
104
|
+
None, "--account-id", "-a", help="Account ID"
|
|
105
|
+
),
|
|
106
|
+
property_id: Optional[str] = typer.Option(
|
|
107
|
+
None, "--property-id", "-p", help="Property ID (numeric)"
|
|
108
|
+
),
|
|
109
|
+
binding_id: str = typer.Option(
|
|
110
|
+
..., "--binding-id", "-b", help="Access binding ID"
|
|
111
|
+
),
|
|
112
|
+
output_format: Optional[str] = typer.Option(
|
|
113
|
+
None, "--output", "-o", help="Output format (json, table, compact)"
|
|
114
|
+
),
|
|
115
|
+
):
|
|
116
|
+
"""Get details for an access binding."""
|
|
117
|
+
try:
|
|
118
|
+
parent, parent_type = _resolve_parent(account_id, property_id)
|
|
119
|
+
effective_format = resolve_output_format(output_format)
|
|
120
|
+
|
|
121
|
+
admin = get_admin_alpha_client()
|
|
122
|
+
resource = _get_access_bindings_resource(admin, parent_type)
|
|
123
|
+
resource_name = f"{parent}/accessBindings/{binding_id}"
|
|
124
|
+
binding = resource.get(name=resource_name).execute()
|
|
125
|
+
output(binding, effective_format)
|
|
126
|
+
except typer.BadParameter:
|
|
127
|
+
raise
|
|
128
|
+
except Exception as e:
|
|
129
|
+
handle_error(e)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@access_bindings_app.command("create")
|
|
133
|
+
def create_cmd(
|
|
134
|
+
account_id: Optional[str] = typer.Option(
|
|
135
|
+
None, "--account-id", "-a", help="Account ID"
|
|
136
|
+
),
|
|
137
|
+
property_id: Optional[str] = typer.Option(
|
|
138
|
+
None, "--property-id", "-p", help="Property ID (numeric)"
|
|
139
|
+
),
|
|
140
|
+
user: str = typer.Option(
|
|
141
|
+
..., "--user", "-u", help="Email address of the user"
|
|
142
|
+
),
|
|
143
|
+
roles: str = typer.Option(
|
|
144
|
+
..., "--roles", "-r", help="Comma-separated roles (e.g. viewer,editor)"
|
|
145
|
+
),
|
|
146
|
+
output_format: Optional[str] = typer.Option(
|
|
147
|
+
None, "--output", "-o", help="Output format (json, table, compact)"
|
|
148
|
+
),
|
|
149
|
+
):
|
|
150
|
+
"""Create an access binding for a user."""
|
|
151
|
+
try:
|
|
152
|
+
parent, parent_type = _resolve_parent(account_id, property_id)
|
|
153
|
+
effective_format = resolve_output_format(output_format)
|
|
154
|
+
|
|
155
|
+
role_list = [
|
|
156
|
+
r.strip() if "/" in r.strip() else f"predefinedRoles/{r.strip()}"
|
|
157
|
+
for r in roles.split(",")
|
|
158
|
+
if r.strip()
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
if not role_list:
|
|
162
|
+
raise typer.BadParameter("--roles must contain at least one role.")
|
|
163
|
+
|
|
164
|
+
body = {"user": user, "roles": role_list}
|
|
165
|
+
|
|
166
|
+
admin = get_admin_alpha_client()
|
|
167
|
+
resource = _get_access_bindings_resource(admin, parent_type)
|
|
168
|
+
binding = resource.create(parent=parent, body=body).execute()
|
|
169
|
+
output(binding, effective_format)
|
|
170
|
+
except typer.BadParameter:
|
|
171
|
+
raise
|
|
172
|
+
except Exception as e:
|
|
173
|
+
handle_error(e)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@access_bindings_app.command("update")
|
|
177
|
+
def update_cmd(
|
|
178
|
+
account_id: Optional[str] = typer.Option(
|
|
179
|
+
None, "--account-id", "-a", help="Account ID"
|
|
180
|
+
),
|
|
181
|
+
property_id: Optional[str] = typer.Option(
|
|
182
|
+
None, "--property-id", "-p", help="Property ID (numeric)"
|
|
183
|
+
),
|
|
184
|
+
binding_id: str = typer.Option(
|
|
185
|
+
..., "--binding-id", "-b", help="Access binding ID"
|
|
186
|
+
),
|
|
187
|
+
roles: str = typer.Option(
|
|
188
|
+
..., "--roles", "-r", help="Comma-separated roles (e.g. viewer,editor)"
|
|
189
|
+
),
|
|
190
|
+
output_format: Optional[str] = typer.Option(
|
|
191
|
+
None, "--output", "-o", help="Output format (json, table, compact)"
|
|
192
|
+
),
|
|
193
|
+
):
|
|
194
|
+
"""Update roles for an access binding."""
|
|
195
|
+
try:
|
|
196
|
+
parent, parent_type = _resolve_parent(account_id, property_id)
|
|
197
|
+
effective_format = resolve_output_format(output_format)
|
|
198
|
+
|
|
199
|
+
role_list = [
|
|
200
|
+
r.strip() if "/" in r.strip() else f"predefinedRoles/{r.strip()}"
|
|
201
|
+
for r in roles.split(",")
|
|
202
|
+
if r.strip()
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
if not role_list:
|
|
206
|
+
raise typer.BadParameter("--roles must contain at least one role.")
|
|
207
|
+
|
|
208
|
+
resource_name = f"{parent}/accessBindings/{binding_id}"
|
|
209
|
+
body = {"name": resource_name, "roles": role_list}
|
|
210
|
+
|
|
211
|
+
admin = get_admin_alpha_client()
|
|
212
|
+
resource = _get_access_bindings_resource(admin, parent_type)
|
|
213
|
+
binding = resource.patch(name=resource_name, body=body).execute()
|
|
214
|
+
output(binding, effective_format)
|
|
215
|
+
except typer.BadParameter:
|
|
216
|
+
raise
|
|
217
|
+
except Exception as e:
|
|
218
|
+
handle_error(e)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@access_bindings_app.command("delete")
|
|
222
|
+
def delete_cmd(
|
|
223
|
+
account_id: Optional[str] = typer.Option(
|
|
224
|
+
None, "--account-id", "-a", help="Account ID"
|
|
225
|
+
),
|
|
226
|
+
property_id: Optional[str] = typer.Option(
|
|
227
|
+
None, "--property-id", "-p", help="Property ID (numeric)"
|
|
228
|
+
),
|
|
229
|
+
binding_id: str = typer.Option(
|
|
230
|
+
..., "--binding-id", "-b", help="Access binding ID"
|
|
231
|
+
),
|
|
232
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
233
|
+
):
|
|
234
|
+
"""Delete an access binding."""
|
|
235
|
+
try:
|
|
236
|
+
parent, parent_type = _resolve_parent(account_id, property_id)
|
|
237
|
+
|
|
238
|
+
if not yes:
|
|
239
|
+
confirmed = questionary.confirm(
|
|
240
|
+
f"Delete access binding {binding_id}? This cannot be undone."
|
|
241
|
+
).ask()
|
|
242
|
+
if not confirmed:
|
|
243
|
+
info("Cancelled.")
|
|
244
|
+
raise typer.Exit()
|
|
245
|
+
|
|
246
|
+
resource_name = f"{parent}/accessBindings/{binding_id}"
|
|
247
|
+
admin = get_admin_alpha_client()
|
|
248
|
+
resource = _get_access_bindings_resource(admin, parent_type)
|
|
249
|
+
resource.delete(name=resource_name).execute()
|
|
250
|
+
success(f"Access binding {binding_id} deleted.")
|
|
251
|
+
except (typer.BadParameter, typer.Exit):
|
|
252
|
+
raise
|
|
253
|
+
except Exception as e:
|
|
254
|
+
handle_error(e)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Access report commands: who accessed what data and when.
|
|
2
|
+
|
|
3
|
+
Uses the Analytics Admin API v1beta runAccessReport endpoint.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ..api.client import get_admin_client
|
|
11
|
+
from ..config.store import get_effective_value
|
|
12
|
+
from ..utils import console, handle_error, info, output, require_options, resolve_output_format
|
|
13
|
+
|
|
14
|
+
access_reports_app = typer.Typer(
|
|
15
|
+
name="access-reports",
|
|
16
|
+
help="Run data-access reports (who accessed what)",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_DEFAULT_DIMENSIONS = "userEmail,epochTimeMicros"
|
|
21
|
+
_DEFAULT_METRICS = "accessCount"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_access_report_body(
|
|
25
|
+
dimensions: str,
|
|
26
|
+
metrics: str,
|
|
27
|
+
start_date: str,
|
|
28
|
+
end_date: str,
|
|
29
|
+
limit: int,
|
|
30
|
+
offset: int,
|
|
31
|
+
include_all_users: bool,
|
|
32
|
+
expand_groups: bool,
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Build the request body for runAccessReport."""
|
|
35
|
+
body: dict = {
|
|
36
|
+
"dimensions": [{"dimensionName": d.strip()} for d in dimensions.split(",")],
|
|
37
|
+
"metrics": [{"metricName": m.strip()} for m in metrics.split(",")],
|
|
38
|
+
"dateRanges": [{"startDate": start_date, "endDate": end_date}],
|
|
39
|
+
"limit": limit,
|
|
40
|
+
}
|
|
41
|
+
if offset > 0:
|
|
42
|
+
body["offset"] = offset
|
|
43
|
+
if include_all_users:
|
|
44
|
+
body["includeAllUsers"] = True
|
|
45
|
+
if expand_groups:
|
|
46
|
+
body["expandGroups"] = True
|
|
47
|
+
return body
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _transform_access_rows(result: dict) -> tuple[list[dict], list[str], list[str]]:
|
|
51
|
+
"""Transform an access report response into a list of dicts."""
|
|
52
|
+
dim_headers = [h.get("dimensionName", "") for h in result.get("dimensionHeaders", [])]
|
|
53
|
+
met_headers = [h.get("metricName", "") for h in result.get("metricHeaders", [])]
|
|
54
|
+
|
|
55
|
+
all_keys = dim_headers + met_headers
|
|
56
|
+
rows = []
|
|
57
|
+
for row in result.get("rows", []):
|
|
58
|
+
entry = {}
|
|
59
|
+
for i, name in enumerate(dim_headers):
|
|
60
|
+
entry[name] = row["dimensionValues"][i]["value"]
|
|
61
|
+
for i, name in enumerate(met_headers):
|
|
62
|
+
entry[name] = row["metricValues"][i]["value"]
|
|
63
|
+
rows.append(entry)
|
|
64
|
+
|
|
65
|
+
return rows, all_keys, all_keys
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@access_reports_app.command("run-account")
|
|
69
|
+
def run_account_cmd(
|
|
70
|
+
account_id: Optional[str] = typer.Option(
|
|
71
|
+
None, "--account-id", "-a", help="Account ID (numeric)"
|
|
72
|
+
),
|
|
73
|
+
dimensions: str = typer.Option(
|
|
74
|
+
_DEFAULT_DIMENSIONS, "--dimensions", "-d", help="Comma-separated dimension names"
|
|
75
|
+
),
|
|
76
|
+
metrics: str = typer.Option(
|
|
77
|
+
_DEFAULT_METRICS, "--metrics", "-m", help="Comma-separated metric names"
|
|
78
|
+
),
|
|
79
|
+
start_date: str = typer.Option("7daysAgo", "--start-date", help="Start date"),
|
|
80
|
+
end_date: str = typer.Option("today", "--end-date", help="End date"),
|
|
81
|
+
limit: int = typer.Option(10000, "--limit", "-l", help="Max rows to return"),
|
|
82
|
+
offset: int = typer.Option(0, "--offset", help="Row offset for pagination"),
|
|
83
|
+
include_all_users: bool = typer.Option(
|
|
84
|
+
False, "--include-all-users", help="Include users who never made an API call"
|
|
85
|
+
),
|
|
86
|
+
expand_groups: bool = typer.Option(
|
|
87
|
+
False, "--expand-groups", help="Expand user group members (requires --include-all-users)"
|
|
88
|
+
),
|
|
89
|
+
output_format: Optional[str] = typer.Option(
|
|
90
|
+
None, "--output", "-o", help="Output format (json, table, compact)"
|
|
91
|
+
),
|
|
92
|
+
):
|
|
93
|
+
"""Run a data-access report for an account."""
|
|
94
|
+
try:
|
|
95
|
+
effective_account = get_effective_value(account_id, "default_account_id")
|
|
96
|
+
require_options({"account_id": effective_account}, ["account_id"])
|
|
97
|
+
effective_format = resolve_output_format(output_format)
|
|
98
|
+
|
|
99
|
+
body = _build_access_report_body(
|
|
100
|
+
dimensions,
|
|
101
|
+
metrics,
|
|
102
|
+
start_date,
|
|
103
|
+
end_date,
|
|
104
|
+
limit,
|
|
105
|
+
offset,
|
|
106
|
+
include_all_users,
|
|
107
|
+
expand_groups,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
admin = get_admin_client()
|
|
111
|
+
result = (
|
|
112
|
+
admin.accounts()
|
|
113
|
+
.runAccessReport(
|
|
114
|
+
entity=f"accounts/{effective_account}",
|
|
115
|
+
body=body,
|
|
116
|
+
)
|
|
117
|
+
.execute()
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
rows, columns, headers = _transform_access_rows(result)
|
|
121
|
+
row_count = result.get("rowCount", len(rows))
|
|
122
|
+
|
|
123
|
+
if not rows:
|
|
124
|
+
info("No access data found.")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
output(rows, effective_format, columns=columns, headers=headers)
|
|
128
|
+
|
|
129
|
+
if effective_format == "table" and row_count > 0:
|
|
130
|
+
console.print(f"\n[dim]{row_count} total rows[/dim]")
|
|
131
|
+
|
|
132
|
+
except Exception as e:
|
|
133
|
+
handle_error(e)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@access_reports_app.command("run-property")
|
|
137
|
+
def run_property_cmd(
|
|
138
|
+
property_id: Optional[str] = typer.Option(
|
|
139
|
+
None, "--property-id", "-p", help="Property ID (numeric)"
|
|
140
|
+
),
|
|
141
|
+
dimensions: str = typer.Option(
|
|
142
|
+
_DEFAULT_DIMENSIONS, "--dimensions", "-d", help="Comma-separated dimension names"
|
|
143
|
+
),
|
|
144
|
+
metrics: str = typer.Option(
|
|
145
|
+
_DEFAULT_METRICS, "--metrics", "-m", help="Comma-separated metric names"
|
|
146
|
+
),
|
|
147
|
+
start_date: str = typer.Option("7daysAgo", "--start-date", help="Start date"),
|
|
148
|
+
end_date: str = typer.Option("today", "--end-date", help="End date"),
|
|
149
|
+
limit: int = typer.Option(10000, "--limit", "-l", help="Max rows to return"),
|
|
150
|
+
offset: int = typer.Option(0, "--offset", help="Row offset for pagination"),
|
|
151
|
+
include_all_users: bool = typer.Option(
|
|
152
|
+
False, "--include-all-users", help="Include users who never made an API call"
|
|
153
|
+
),
|
|
154
|
+
expand_groups: bool = typer.Option(
|
|
155
|
+
False, "--expand-groups", help="Expand user group members (requires --include-all-users)"
|
|
156
|
+
),
|
|
157
|
+
output_format: Optional[str] = typer.Option(
|
|
158
|
+
None, "--output", "-o", help="Output format (json, table, compact)"
|
|
159
|
+
),
|
|
160
|
+
):
|
|
161
|
+
"""Run a data-access report for a property."""
|
|
162
|
+
try:
|
|
163
|
+
effective_property = get_effective_value(property_id, "default_property_id")
|
|
164
|
+
require_options({"property_id": effective_property}, ["property_id"])
|
|
165
|
+
effective_format = resolve_output_format(output_format)
|
|
166
|
+
|
|
167
|
+
body = _build_access_report_body(
|
|
168
|
+
dimensions,
|
|
169
|
+
metrics,
|
|
170
|
+
start_date,
|
|
171
|
+
end_date,
|
|
172
|
+
limit,
|
|
173
|
+
offset,
|
|
174
|
+
include_all_users,
|
|
175
|
+
expand_groups,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
admin = get_admin_client()
|
|
179
|
+
result = (
|
|
180
|
+
admin.properties()
|
|
181
|
+
.runAccessReport(
|
|
182
|
+
entity=f"properties/{effective_property}",
|
|
183
|
+
body=body,
|
|
184
|
+
)
|
|
185
|
+
.execute()
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
rows, columns, headers = _transform_access_rows(result)
|
|
189
|
+
row_count = result.get("rowCount", len(rows))
|
|
190
|
+
|
|
191
|
+
if not rows:
|
|
192
|
+
info("No access data found.")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
output(rows, effective_format, columns=columns, headers=headers)
|
|
196
|
+
|
|
197
|
+
if effective_format == "table" and row_count > 0:
|
|
198
|
+
console.print(f"\n[dim]{row_count} total rows[/dim]")
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
handle_error(e)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Account summaries command."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..api.client import get_admin_client
|
|
8
|
+
from ..utils import handle_error, output, resolve_output_format
|
|
9
|
+
from ..utils.pagination import paginate_all
|
|
10
|
+
|
|
11
|
+
account_summaries_app = typer.Typer(
|
|
12
|
+
name="account-summaries",
|
|
13
|
+
help="View account summaries",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@account_summaries_app.command("list")
|
|
19
|
+
def list_cmd(
|
|
20
|
+
output_format: Optional[str] = typer.Option(
|
|
21
|
+
None, "--output", "-o", help="Output format (json, table, compact)"
|
|
22
|
+
),
|
|
23
|
+
):
|
|
24
|
+
"""List all accounts with their property summaries."""
|
|
25
|
+
try:
|
|
26
|
+
effective_format = resolve_output_format(output_format)
|
|
27
|
+
|
|
28
|
+
admin = get_admin_client()
|
|
29
|
+
summaries = paginate_all(
|
|
30
|
+
lambda **kw: admin.accountSummaries().list(**kw).execute(),
|
|
31
|
+
"accountSummaries",
|
|
32
|
+
pageSize=200,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if effective_format != "table":
|
|
36
|
+
output(summaries, effective_format)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
# Flatten into rows: one row per property
|
|
40
|
+
rows = []
|
|
41
|
+
for acct in summaries:
|
|
42
|
+
acct_name = acct.get("displayName", "")
|
|
43
|
+
acct_id = acct.get("account", "").replace("accounts/", "")
|
|
44
|
+
for prop in acct.get("propertySummaries", []):
|
|
45
|
+
rows.append({
|
|
46
|
+
"accountName": acct_name,
|
|
47
|
+
"accountId": acct_id,
|
|
48
|
+
"propertyName": prop.get("displayName", ""),
|
|
49
|
+
"propertyId": prop.get("property", "").replace("properties/", ""),
|
|
50
|
+
"propertyType": prop.get("propertyType", ""),
|
|
51
|
+
})
|
|
52
|
+
if not acct.get("propertySummaries"):
|
|
53
|
+
rows.append({
|
|
54
|
+
"accountName": acct_name,
|
|
55
|
+
"accountId": acct_id,
|
|
56
|
+
"propertyName": "",
|
|
57
|
+
"propertyId": "",
|
|
58
|
+
"propertyType": "",
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
output(
|
|
62
|
+
rows,
|
|
63
|
+
effective_format,
|
|
64
|
+
columns=["accountName", "accountId", "propertyName", "propertyId", "propertyType"],
|
|
65
|
+
headers=["Account Name", "Account ID", "Property Name", "Property ID", "Property Type"],
|
|
66
|
+
)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
handle_error(e)
|