gitmap-core 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.
- gitmap_core/README.md +46 -0
- gitmap_core/__init__.py +100 -0
- gitmap_core/communication.py +346 -0
- gitmap_core/compat.py +408 -0
- gitmap_core/connection.py +232 -0
- gitmap_core/context.py +709 -0
- gitmap_core/diff.py +283 -0
- gitmap_core/maps.py +385 -0
- gitmap_core/merge.py +449 -0
- gitmap_core/models.py +332 -0
- gitmap_core/py.typed +0 -0
- gitmap_core/pyproject.toml +48 -0
- gitmap_core/remote.py +728 -0
- gitmap_core/repository.py +1632 -0
- gitmap_core/tests/__init__.py +1 -0
- gitmap_core/tests/test_communication.py +695 -0
- gitmap_core/tests/test_compat.py +310 -0
- gitmap_core/tests/test_connection.py +314 -0
- gitmap_core/tests/test_context.py +814 -0
- gitmap_core/tests/test_diff.py +567 -0
- gitmap_core/tests/test_init.py +153 -0
- gitmap_core/tests/test_maps.py +642 -0
- gitmap_core/tests/test_merge.py +694 -0
- gitmap_core/tests/test_models.py +410 -0
- gitmap_core/tests/test_remote.py +3014 -0
- gitmap_core/tests/test_repository.py +1639 -0
- gitmap_core/tests/test_visualize.py +902 -0
- gitmap_core/visualize.py +1217 -0
- gitmap_core-0.1.0.dist-info/METADATA +961 -0
- gitmap_core-0.1.0.dist-info/RECORD +32 -0
- gitmap_core-0.1.0.dist-info/WHEEL +4 -0
- gitmap_core-0.1.0.dist-info/licenses/LICENSE +21 -0
gitmap_core/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# GitMap Core
|
|
2
|
+
|
|
3
|
+
Core library for GitMap - Version control for ArcGIS web maps.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Modules
|
|
12
|
+
|
|
13
|
+
- **models.py** - Data classes for Commit, Branch, Remote, RepoConfig
|
|
14
|
+
- **repository.py** - Local `.gitmap` repository management
|
|
15
|
+
- **connection.py** - Portal/AGOL authentication
|
|
16
|
+
- **maps.py** - Web map JSON operations
|
|
17
|
+
- **diff.py** - JSON comparison and layer diffing
|
|
18
|
+
- **merge.py** - Layer-level merge logic
|
|
19
|
+
- **remote.py** - Push/pull operations to Portal
|
|
20
|
+
- **communication.py** - Portal/AGOL user notifications and group helpers
|
|
21
|
+
- **compat.py** - ArcGIS API version compatibility layer (2.2.x-2.4.x)
|
|
22
|
+
- **context.py** - SQLite-backed context/event store for IDE agents
|
|
23
|
+
- **visualize.py** - Mermaid, ASCII, and HTML visualization of context graphs
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from gitmap_core import Commit, Branch, RepoConfig
|
|
29
|
+
from gitmap_core.repository import Repository, init_repository
|
|
30
|
+
|
|
31
|
+
# Initialize a new repository
|
|
32
|
+
repo = init_repository("/path/to/project")
|
|
33
|
+
|
|
34
|
+
# Create a commit
|
|
35
|
+
commit = repo.create_commit(message="Initial commit", author="John")
|
|
36
|
+
|
|
37
|
+
# List branches
|
|
38
|
+
branches = repo.list_branches()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Dependencies
|
|
42
|
+
|
|
43
|
+
- `arcgis>=2.2.0,<3.0.0` - ArcGIS API for Python
|
|
44
|
+
- `deepdiff>=6.0.0` - JSON comparison
|
|
45
|
+
|
|
46
|
+
|
gitmap_core/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""GitMap Core Library.
|
|
2
|
+
|
|
3
|
+
Provides version control functionality for ArcGIS web maps, including
|
|
4
|
+
local repository management, Portal authentication, and map operations.
|
|
5
|
+
|
|
6
|
+
Execution Context:
|
|
7
|
+
Library package - imported by CLI and other applications
|
|
8
|
+
|
|
9
|
+
Dependencies:
|
|
10
|
+
- arcgis: Portal/AGOL interaction
|
|
11
|
+
- deepdiff: JSON comparison
|
|
12
|
+
|
|
13
|
+
Metadata:
|
|
14
|
+
Version: 0.5.0
|
|
15
|
+
Author: GitMap Team
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
# Core data models - loaded eagerly for common use cases
|
|
20
|
+
from gitmap_core.context import Annotation
|
|
21
|
+
from gitmap_core.context import ContextStore
|
|
22
|
+
from gitmap_core.context import Edge
|
|
23
|
+
from gitmap_core.context import Event
|
|
24
|
+
from gitmap_core.models import Branch
|
|
25
|
+
from gitmap_core.models import Commit
|
|
26
|
+
from gitmap_core.models import Remote
|
|
27
|
+
from gitmap_core.models import RepoConfig
|
|
28
|
+
from gitmap_core.visualize import GraphData
|
|
29
|
+
|
|
30
|
+
__version__ = "0.5.0"
|
|
31
|
+
|
|
32
|
+
# Public API - core data models loaded eagerly
|
|
33
|
+
__all__ = [
|
|
34
|
+
# Core data models (eager load for common access patterns)
|
|
35
|
+
"Annotation",
|
|
36
|
+
"Branch",
|
|
37
|
+
"Commit",
|
|
38
|
+
"ContextStore",
|
|
39
|
+
"Edge",
|
|
40
|
+
"Event",
|
|
41
|
+
"GraphData",
|
|
42
|
+
"Remote",
|
|
43
|
+
"RepoConfig",
|
|
44
|
+
"__version__",
|
|
45
|
+
# Visualization functions (lazy loaded - optional utilities)
|
|
46
|
+
"generate_ascii_graph",
|
|
47
|
+
"generate_ascii_timeline",
|
|
48
|
+
"generate_html_visualization",
|
|
49
|
+
"generate_mermaid_flowchart",
|
|
50
|
+
"generate_mermaid_git_graph",
|
|
51
|
+
"generate_mermaid_timeline",
|
|
52
|
+
"visualize_context",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---- Lazy Import Functions ----------------------------------------------------------------------------------
|
|
57
|
+
# These are loaded on-demand to reduce initial import time and memory footprint.
|
|
58
|
+
# Only imported when actually called, avoiding unnecessary dependencies.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def generate_ascii_graph(*args, **kwargs):
|
|
62
|
+
"""Generate ASCII representation of commit graph (lazy import)."""
|
|
63
|
+
from gitmap_core.visualize import generate_ascii_graph as _func
|
|
64
|
+
return _func(*args, **kwargs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def generate_ascii_timeline(*args, **kwargs):
|
|
68
|
+
"""Generate ASCII timeline visualization (lazy import)."""
|
|
69
|
+
from gitmap_core.visualize import generate_ascii_timeline as _func
|
|
70
|
+
return _func(*args, **kwargs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generate_html_visualization(*args, **kwargs):
|
|
74
|
+
"""Generate HTML visualization (lazy import)."""
|
|
75
|
+
from gitmap_core.visualize import generate_html_visualization as _func
|
|
76
|
+
return _func(*args, **kwargs)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generate_mermaid_flowchart(*args, **kwargs):
|
|
80
|
+
"""Generate Mermaid flowchart (lazy import)."""
|
|
81
|
+
from gitmap_core.visualize import generate_mermaid_flowchart as _func
|
|
82
|
+
return _func(*args, **kwargs)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generate_mermaid_git_graph(*args, **kwargs):
|
|
86
|
+
"""Generate Mermaid git graph (lazy import)."""
|
|
87
|
+
from gitmap_core.visualize import generate_mermaid_git_graph as _func
|
|
88
|
+
return _func(*args, **kwargs)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def generate_mermaid_timeline(*args, **kwargs):
|
|
92
|
+
"""Generate Mermaid timeline (lazy import)."""
|
|
93
|
+
from gitmap_core.visualize import generate_mermaid_timeline as _func
|
|
94
|
+
return _func(*args, **kwargs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def visualize_context(*args, **kwargs):
|
|
98
|
+
"""Visualize context graph (lazy import)."""
|
|
99
|
+
from gitmap_core.visualize import visualize_context as _func
|
|
100
|
+
return _func(*args, **kwargs)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Communication helpers for Portal/AGOL users and groups.
|
|
2
|
+
|
|
3
|
+
Provides utilities to gather group members and send notifications using the
|
|
4
|
+
ArcGIS API for Python `Group.notify` method (no SMTP wiring required).
|
|
5
|
+
|
|
6
|
+
Execution Context:
|
|
7
|
+
Library module - imported by CLI notification commands
|
|
8
|
+
|
|
9
|
+
Dependencies:
|
|
10
|
+
- arcgis: Portal/AGOL interaction
|
|
11
|
+
|
|
12
|
+
Metadata:
|
|
13
|
+
Version: 0.2.0
|
|
14
|
+
Author: GitMap Team
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Sequence
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from arcgis.gis import GIS # type: ignore
|
|
22
|
+
except Exception: # pragma: no cover - handled at runtime
|
|
23
|
+
GIS = None # type: ignore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ensure_gis(gis: GIS) -> None:
|
|
27
|
+
"""Ensure a GIS connection object is available.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
gis: GIS connection object.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
RuntimeError: If GIS is not available.
|
|
34
|
+
"""
|
|
35
|
+
if GIS is None:
|
|
36
|
+
msg = "ArcGIS API for Python is not installed."
|
|
37
|
+
raise RuntimeError(msg)
|
|
38
|
+
if gis is None:
|
|
39
|
+
msg = "A valid GIS connection is required."
|
|
40
|
+
raise RuntimeError(msg)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_group(gis: GIS, group_id_or_title: str):
|
|
44
|
+
"""Resolve a group by ID or title.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
gis: Authenticated GIS connection.
|
|
48
|
+
group_id_or_title: Group ID or title to resolve.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Group object if found, otherwise None.
|
|
52
|
+
"""
|
|
53
|
+
_ensure_gis(gis)
|
|
54
|
+
group = gis.groups.get(group_id_or_title)
|
|
55
|
+
if group:
|
|
56
|
+
return group
|
|
57
|
+
|
|
58
|
+
matches = gis.groups.search(f'title:"{group_id_or_title}"')
|
|
59
|
+
return matches[0] if matches else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_group_member_usernames(
|
|
63
|
+
gis: GIS,
|
|
64
|
+
group_id_or_title: str,
|
|
65
|
+
) -> list[str]:
|
|
66
|
+
"""Collect usernames for all members of a group.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
gis: Authenticated GIS connection.
|
|
70
|
+
group_id_or_title: Group ID or title.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of unique usernames (owner, admins, users, and invited users).
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
RuntimeError: If group cannot be found or no members are available.
|
|
77
|
+
"""
|
|
78
|
+
_ensure_gis(gis)
|
|
79
|
+
group = _resolve_group(gis, group_id_or_title)
|
|
80
|
+
if group is None:
|
|
81
|
+
msg = f"Group '{group_id_or_title}' not found."
|
|
82
|
+
raise RuntimeError(msg)
|
|
83
|
+
|
|
84
|
+
members = group.get_members()
|
|
85
|
+
usernames: set[str] = set()
|
|
86
|
+
|
|
87
|
+
owner = members.get("owner")
|
|
88
|
+
if owner:
|
|
89
|
+
usernames.add(owner)
|
|
90
|
+
|
|
91
|
+
for key in ("admins", "users", "admins_invited", "users_invited"):
|
|
92
|
+
for username in members.get(key, []) or []:
|
|
93
|
+
if username:
|
|
94
|
+
usernames.add(username)
|
|
95
|
+
|
|
96
|
+
if not usernames:
|
|
97
|
+
msg = f"No members found for group '{group_id_or_title}'."
|
|
98
|
+
raise RuntimeError(msg)
|
|
99
|
+
|
|
100
|
+
return sorted(usernames)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def list_groups(
|
|
104
|
+
gis: GIS,
|
|
105
|
+
query: str = "",
|
|
106
|
+
max_results: int = 100,
|
|
107
|
+
) -> list[dict[str, str]]:
|
|
108
|
+
"""List available groups from the Portal.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
gis: Authenticated GIS connection.
|
|
112
|
+
query: Optional search query string to filter groups (e.g., "title:MyGroup").
|
|
113
|
+
Defaults to empty string to list all groups.
|
|
114
|
+
max_results: Maximum number of groups to return (default: 100).
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
List of dictionaries containing group information (id, title, owner).
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
RuntimeError: If group search fails.
|
|
121
|
+
"""
|
|
122
|
+
_ensure_gis(gis)
|
|
123
|
+
try:
|
|
124
|
+
# groups.search() only accepts query string as positional argument
|
|
125
|
+
groups = gis.groups.search(query)
|
|
126
|
+
result = []
|
|
127
|
+
for group in groups[:max_results]:
|
|
128
|
+
result.append({
|
|
129
|
+
"id": getattr(group, "id", ""),
|
|
130
|
+
"title": getattr(group, "title", ""),
|
|
131
|
+
"owner": getattr(group, "owner", ""),
|
|
132
|
+
})
|
|
133
|
+
return result
|
|
134
|
+
except Exception as search_error:
|
|
135
|
+
msg = f"Failed to search groups: {search_error}"
|
|
136
|
+
raise RuntimeError(msg) from search_error
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def send_group_notification(
|
|
140
|
+
gis: GIS,
|
|
141
|
+
group_id_or_title: str,
|
|
142
|
+
subject: str,
|
|
143
|
+
body: str,
|
|
144
|
+
users: Sequence[str] | None = None,
|
|
145
|
+
) -> Sequence[str]:
|
|
146
|
+
"""Send a notification to group members using ArcGIS `Group.notify`.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
gis: Authenticated GIS connection.
|
|
150
|
+
group_id_or_title: Group ID or title to resolve.
|
|
151
|
+
subject: Notification subject.
|
|
152
|
+
body: Notification message body (plain text or HTML supported by ArcGIS).
|
|
153
|
+
users: Optional iterable of usernames; when omitted, the entire group is notified.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Sequence of usernames passed to the notification call.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
RuntimeError: If notification fails or no users can be determined.
|
|
160
|
+
"""
|
|
161
|
+
_ensure_gis(gis)
|
|
162
|
+
group = _resolve_group(gis, group_id_or_title)
|
|
163
|
+
if group is None:
|
|
164
|
+
msg = f"Group '{group_id_or_title}' not found."
|
|
165
|
+
raise RuntimeError(msg)
|
|
166
|
+
|
|
167
|
+
target_users = list(users) if users else get_group_member_usernames(gis, group_id_or_title)
|
|
168
|
+
if not target_users:
|
|
169
|
+
msg = f"No target users resolved for group '{group_id_or_title}'."
|
|
170
|
+
raise RuntimeError(msg)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
group.notify(users=target_users, subject=subject, message=body)
|
|
174
|
+
except Exception as notify_error: # pragma: no cover - depends on ArcGIS service
|
|
175
|
+
msg = f"Failed to send notification: {notify_error}"
|
|
176
|
+
raise RuntimeError(msg) from notify_error
|
|
177
|
+
|
|
178
|
+
return target_users
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_item_group_users(
|
|
182
|
+
gis: GIS,
|
|
183
|
+
item,
|
|
184
|
+
) -> list[str]:
|
|
185
|
+
"""Collect all users from groups that have access to a Portal item.
|
|
186
|
+
|
|
187
|
+
Gets all groups that the item is shared with and collects unique usernames
|
|
188
|
+
from all those groups. De-duplicates users across groups.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
gis: Authenticated GIS connection.
|
|
192
|
+
item: Portal Item object.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
List of unique usernames from all groups sharing the item.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
RuntimeError: If item sharing information cannot be retrieved.
|
|
199
|
+
"""
|
|
200
|
+
_ensure_gis(gis)
|
|
201
|
+
try:
|
|
202
|
+
# Get item sharing information
|
|
203
|
+
# item.sharing is a SharingManager object, not a dict
|
|
204
|
+
# Check if item is private (not shared)
|
|
205
|
+
if item.access == "private":
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
# Get groups the item is shared with
|
|
209
|
+
# Try to get from item properties or by querying user's groups
|
|
210
|
+
groups = []
|
|
211
|
+
try:
|
|
212
|
+
# Method 1: Try to get from item's properties
|
|
213
|
+
if hasattr(item, "properties") and item.properties:
|
|
214
|
+
sharing_data = item.properties.get("sharing", {})
|
|
215
|
+
if isinstance(sharing_data, dict):
|
|
216
|
+
groups = sharing_data.get("groups", [])
|
|
217
|
+
|
|
218
|
+
# Method 2: If not found, query user's groups to find which have access
|
|
219
|
+
if not groups:
|
|
220
|
+
user = gis.users.me
|
|
221
|
+
if user:
|
|
222
|
+
user_groups = user.groups
|
|
223
|
+
for group in user_groups:
|
|
224
|
+
try:
|
|
225
|
+
# Check if this group has access to the item
|
|
226
|
+
# by checking if the item appears in the group's content
|
|
227
|
+
group_items = group.content()
|
|
228
|
+
if any(g_item.id == item.id for g_item in group_items):
|
|
229
|
+
groups.append(group.id)
|
|
230
|
+
except Exception:
|
|
231
|
+
continue
|
|
232
|
+
except Exception:
|
|
233
|
+
groups = []
|
|
234
|
+
|
|
235
|
+
if not groups:
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
# Collect all unique usernames from all groups
|
|
239
|
+
all_users: set[str] = set()
|
|
240
|
+
for group_id in groups:
|
|
241
|
+
try:
|
|
242
|
+
group_users = get_group_member_usernames(gis, group_id)
|
|
243
|
+
all_users.update(group_users)
|
|
244
|
+
except Exception:
|
|
245
|
+
# Skip groups that can't be accessed
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
return sorted(all_users)
|
|
249
|
+
|
|
250
|
+
except Exception as sharing_error:
|
|
251
|
+
msg = f"Failed to get item group users: {sharing_error}"
|
|
252
|
+
raise RuntimeError(msg) from sharing_error
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def notify_item_group_users(
|
|
256
|
+
gis: GIS,
|
|
257
|
+
item,
|
|
258
|
+
subject: str,
|
|
259
|
+
body: str,
|
|
260
|
+
) -> list[str]:
|
|
261
|
+
"""Send notifications to all users from groups that have access to an item.
|
|
262
|
+
|
|
263
|
+
Gets all groups that the item is shared with, collects unique usernames
|
|
264
|
+
from all those groups, and sends notifications. The ArcGIS API handles
|
|
265
|
+
deduplication of notifications.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
gis: Authenticated GIS connection.
|
|
269
|
+
item: Portal Item object.
|
|
270
|
+
subject: Notification subject.
|
|
271
|
+
body: Notification message body.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
List of unique usernames from all groups (may receive notifications
|
|
275
|
+
through multiple groups, but ArcGIS handles deduplication).
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
RuntimeError: If notification fails.
|
|
279
|
+
"""
|
|
280
|
+
_ensure_gis(gis)
|
|
281
|
+
try:
|
|
282
|
+
# Get item sharing information
|
|
283
|
+
# item.sharing is a SharingManager object, not a dict
|
|
284
|
+
# Check if item is private (not shared)
|
|
285
|
+
if item.access == "private":
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
# Get groups the item is shared with
|
|
289
|
+
# Try to get from item properties or by querying user's groups
|
|
290
|
+
groups = []
|
|
291
|
+
try:
|
|
292
|
+
# Method 1: Try to get from item's properties
|
|
293
|
+
if hasattr(item, "properties") and item.properties:
|
|
294
|
+
sharing_data = item.properties.get("sharing", {})
|
|
295
|
+
if isinstance(sharing_data, dict):
|
|
296
|
+
groups = sharing_data.get("groups", [])
|
|
297
|
+
|
|
298
|
+
# Method 2: If not found, query user's groups to find which have access
|
|
299
|
+
if not groups:
|
|
300
|
+
user = gis.users.me
|
|
301
|
+
if user:
|
|
302
|
+
user_groups = user.groups
|
|
303
|
+
for group in user_groups:
|
|
304
|
+
try:
|
|
305
|
+
# Check if this group has access to the item
|
|
306
|
+
# by checking if the item appears in the group's content
|
|
307
|
+
group_items = group.content()
|
|
308
|
+
if any(g_item.id == item.id for g_item in group_items):
|
|
309
|
+
groups.append(group.id)
|
|
310
|
+
except Exception:
|
|
311
|
+
continue
|
|
312
|
+
except Exception:
|
|
313
|
+
groups = []
|
|
314
|
+
|
|
315
|
+
if not groups:
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
# Collect all unique usernames from all groups
|
|
319
|
+
all_users: set[str] = set()
|
|
320
|
+
|
|
321
|
+
for group_id in groups:
|
|
322
|
+
try:
|
|
323
|
+
group_users = get_group_member_usernames(gis, group_id)
|
|
324
|
+
all_users.update(group_users)
|
|
325
|
+
|
|
326
|
+
# Send notification to each group
|
|
327
|
+
# ArcGIS API handles deduplication of notifications
|
|
328
|
+
try:
|
|
329
|
+
group = _resolve_group(gis, group_id)
|
|
330
|
+
if group:
|
|
331
|
+
group.notify(users=group_users, subject=subject, message=body)
|
|
332
|
+
except Exception:
|
|
333
|
+
# Skip groups that fail to notify, but keep their users in the list
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
except Exception:
|
|
337
|
+
# Skip groups that can't be accessed
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
return sorted(all_users)
|
|
341
|
+
|
|
342
|
+
except Exception as notify_error:
|
|
343
|
+
msg = f"Failed to notify item group users: {notify_error}"
|
|
344
|
+
raise RuntimeError(msg) from notify_error
|
|
345
|
+
|
|
346
|
+
|