Skip to content

Commit e2bbb10

Browse files
committed
feat: add missing TeamsAsyncOperation properties and stubs for OperationError, TeamsAsyncOperationType
1 parent 610e83c commit e2bbb10

9 files changed

Lines changed: 120 additions & 194 deletions

File tree

examples/teams/archive_lifecycle.py

Lines changed: 22 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,44 @@
11
"""
2-
Archive lifecycle: find inactive teams, notify owners, and archive
3-
(or later unarchive on demand).
2+
Find inactive teams and report archiving candidates.
43
5-
Archived teams are read-only — no new messages, edits, or reactions.
6-
They remain in the directory and can be restored at any time.
7-
This is a common compliance workflow for reducing team sprawl.
8-
9-
The script operates in two modes:
10-
- **audit** (default): scan teams, report candidates for archiving.
11-
- **archive**: archive teams inactive for the given threshold.
12-
13-
Requires application permissions:
14-
Team.ReadBasic.All, TeamSettings.Read.All, TeamMember.Read.All
15-
Directory.Read.All List all teams
16-
TeamSettings.ReadWrite.All Archive / unarchive
17-
18-
https://learn.microsoft.com/en-us/graph/api/team-archive
19-
https://learn.microsoft.com/en-us/graph/api/team-unarchive
4+
Archived teams are read-only. Requires application permissions
5+
Team.ReadBasic.All, TeamMember.Read.All, Directory.Read.All.
206
"""
217

22-
import sys
238
from datetime import datetime, timedelta, timezone
249

2510
from office365.graph_client import GraphClient
2611
from tests import test_client_id, test_client_secret, test_tenant
2712

28-
INACTIVITY_DAYS = 180 # Archive teams with no recent channel activity
29-
30-
31-
def get_all_teams(client: GraphClient) -> list[dict]:
32-
"""List every team in the tenant with basic metadata."""
33-
groups = (
34-
client.groups.get()
35-
.filter("resourceProvisioningOptions/Any(x:x eq 'Team')")
36-
.select(["id", "displayName", "description", "visibility"])
37-
.top(999)
38-
.execute_query()
39-
)
40-
teams = []
41-
for g in groups:
42-
team = client.teams[g.id].get().select(["isArchived", "createdDateTime"]).execute_query()
43-
teams.append(
44-
{
45-
"id": g.id,
46-
"name": g.display_name,
47-
"description": getattr(g, "description", ""),
48-
"visibility": getattr(g, "visibility", "?"),
49-
"is_archived": team.is_archived,
50-
"created": team.created_date_time,
51-
}
52-
)
53-
return teams
54-
55-
56-
def last_activity_date(client: GraphClient, team_id: str) -> datetime | None:
57-
"""Find the most recent message across any channel in the team.
58-
59-
Returns None if the team has no messages at all.
60-
"""
61-
latest = None
62-
channels = client.teams[team_id].channels.get().execute_query()
63-
for channel in channels:
64-
try:
65-
messages = client.teams[team_id].channels[channel.id].messages.top(1).get().execute_query()
66-
if messages:
67-
msg_date = getattr(messages[0], "created_date_time", None)
68-
if msg_date and (latest is None or msg_date > latest):
69-
latest = msg_date
70-
except Exception:
71-
pass
72-
return latest
13+
INACTIVITY_DAYS = 180
7314

7415

7516
def main():
76-
mode = sys.argv[1] if len(sys.argv) > 1 else "audit"
77-
7817
client = GraphClient(tenant=test_tenant).with_client_secret(test_client_id, test_client_secret)
7918
cutoff = datetime.now(timezone.utc) - timedelta(days=INACTIVITY_DAYS)
8019

81-
teams = get_all_teams(client)
82-
print(f"Found {len(teams)} teams in the tenant\n")
83-
84-
candidates = []
85-
for t in teams:
86-
if t["is_archived"]:
87-
continue # already archived
88-
last_msg = last_activity_date(client, t["id"])
89-
if last_msg is None or last_msg < cutoff:
90-
days = (datetime.now(timezone.utc) - last_msg).days if last_msg else float("inf")
91-
candidates.append({**t, "last_message": last_msg, "days_since": days})
92-
93-
if not candidates:
94-
print("No inactive teams found (all have recent activity).")
95-
return
96-
97-
print(f"Inactive teams ({INACTIVITY_DAYS}+ days): {len(candidates)}\n")
98-
print(f"{'Team':35s} {'Visibility':12s} {'Created':15s} {'Days inactive':>14s} {'Archived?'}")
99-
print("-" * 95)
20+
teams = client.teams.get_all().execute_query()
21+
print(f"Found {len(teams)} teams\n")
10022

101-
for c in sorted(candidates, key=lambda x: x["days_since"], reverse=True):
102-
last_str = f"{c['days_since']:.0f}d" if c["last_message"] else "∞ (no messages)"
103-
created_str = c["created"].strftime("%Y-%m-%d") if c["created"] else "?"
104-
print(
105-
f"{c['name'][:33]:35s} "
106-
f"{c['visibility']:12s} "
107-
f"{created_str:15s} "
108-
f"{last_str:>14s} "
109-
f"{'→ will archive' if mode == 'archive' else 'candidate'}"
110-
)
23+
for team in teams:
24+
if team.is_archived:
25+
continue
11126

112-
# — Archive mode —
113-
if mode == "archive":
114-
print(f"\nArchiving {len(candidates)} teams (set-readonly)...")
115-
archived_count = 0
116-
for c in candidates:
27+
last_msg = None
28+
for channel in team.channels.get().execute_query():
11729
try:
118-
# Set "shouldSetSpoSiteReadOnlyForMembers=False" to let
119-
# members still access SharePoint even though Teams is archived.
120-
client.teams[c["id"]].archive().execute_query()
121-
archived_count += 1
122-
print(f" ✓ {c['name']}")
123-
except Exception as e:
124-
print(f" ⚠ {c['name']}: {e}")
125-
print(f"\nDone. {archived_count} teams archived.")
30+
msgs = channel.messages.top(1).order_by("createdDateTime desc").get().execute_query()
31+
if msgs:
32+
msg_date = msgs[0].created_date_time
33+
if msg_date and (last_msg is None or msg_date > last_msg):
34+
last_msg = msg_date
35+
except Exception:
36+
pass
12637

127-
# — Provide a ready-to-paste unarchive snippet —
128-
print()
129-
print("To unarchive a specific team later:")
130-
print(' team = client.teams["<teamId>"].get().execute_query()')
131-
print(" team.unarchive().execute_query()")
132-
print(' print(f"Unarchived: {team.display_name}")')
38+
if last_msg is None or last_msg < cutoff:
39+
days = (datetime.now(timezone.utc) - last_msg).days if last_msg else float("inf")
40+
status = f"{days:.0f}d" if last_msg else "no messages"
41+
print(f" {team.display_name} ({team.visibility}) inactive: {status}")
13342

13443

13544
if __name__ == "__main__":

examples/teams/call_records.py

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
"""
22
Call records — Teams call quality analytics across the tenant.
33
4-
Rich data model: call → sessions → segments → endpoints.
5-
Useful for:
6-
- Identifying poor-quality calls (jitter, packet loss, latency)
7-
- Auditing direct routing call failures
8-
- Reporting on call modalities (audio, video, screen share)
9-
10-
Requires application permission ``CallRecords.Read.All``.
11-
12-
https://learn.microsoft.com/en-us/graph/api/resources/callrecords-api-overview
4+
Requires application permission CallRecords.Read.All.
135
"""
146

157
from office365.graph_client import GraphClient
@@ -19,72 +11,27 @@
1911
def main():
2012
client = GraphClient(tenant=test_tenant).with_client_secret(test_client_id, test_client_secret)
2113

22-
# -- Step 1: list recent call records --
23-
records = client.communications.call_records.top(20).get().execute_query()
14+
records = client.communications.call_records.get().execute_query()
2415
print(f"Call records: {len(records)}\n")
2516

26-
if not records:
27-
print("(No call records — may need CallRecords.Read.All permission or recent calls)")
28-
return
29-
3017
for r in records:
31-
r_id = r.id or "?"
32-
call_type = r.properties.get("type", "?")
33-
modality = r.properties.get("modalities", [])
34-
start = r.start_date_time
35-
if hasattr(start, "strftime"):
36-
start = start.strftime("%Y-%m-%d %H:%M")
37-
end = r.end_date_time
38-
if hasattr(end, "strftime"):
39-
end = end.strftime("%Y-%m-%d %H:%M")
40-
41-
user_count = r.properties.get("participants_count", "?")
42-
organizer = r.properties.get("organizer", {})
43-
org_upn = "?"
44-
if isinstance(organizer, dict):
45-
u = organizer.get("user", {})
46-
org_upn = u.get("userPrincipalName", "?") if isinstance(u, dict) else "?"
47-
48-
failure = r.properties.get("failureInfo", {})
49-
failure_phase = failure.get("stage", "") if isinstance(failure, dict) else ""
18+
org = r.organizer.user.user_principal_name if r.organizer and r.organizer.user else "?"
19+
print(f" {r.start_date_time} {r.type_.name} organizer={org}")
5020

51-
print(
52-
f" {r_id[:20]:20s} type={call_type:15s} modality={str(modality[:3]):10s} "
53-
f"users={user_count} organizer={org_upn[:25]:25s}"
54-
)
55-
print(f" start={start} end={end} failure={failure_phase or 'none'}")
56-
57-
# -- Step 2: drill into sessions for the most recent call --
5821
if records:
5922
print()
6023
recent = records[0]
61-
print(f"Drilling into call {recent.id[:20]}...")
62-
6324
sessions = recent.sessions.get().execute_query()
64-
print(f" Sessions: {len(sessions)}")
65-
25+
print(f"Sessions: {len(sessions)}")
6626
for s in sessions:
67-
s_id = s.id[:15] if s.id else "?"
68-
callee = s.properties.get("callee", {}).get("user", {}).get("userPrincipalName", "?")
69-
caller = s.properties.get("caller", {}).get("user", {}).get("userPrincipalName", "?")
70-
modality = s.properties.get("modalities", [])
71-
print(
72-
f" session {s_id} caller={str(caller)[:25]:25s} "
73-
f"callee={str(callee)[:25]:25s} modality={str(modality[:3]):10s}"
74-
)
27+
caller = s.caller.user.user_principal_name if s.caller and s.caller.user else "?"
28+
callee = s.callee.user.user_principal_name if s.callee and s.callee.user else "?"
29+
modalities = [m.name for m in s.modalities]
30+
print(f" {caller} -> {callee} modalities={modalities}")
7531

76-
# Segments within this session
7732
segments = s.segments.get().execute_query()
7833
for seg in segments:
79-
seg_id = seg.properties.get("id", "?")[:10]
80-
print(f" segment {seg_id}")
81-
82-
# Endpoint quality data
83-
endpoints = seg.properties.get("endpoints", [])
84-
for ep in endpoints:
85-
name = ep.get("name", "?")
86-
ep_type = ep.get("@odata.type", "?")
87-
print(f" endpoint={name} type={str(ep_type).split('.')[-1][:20]}")
34+
print(f" segment {seg.id}")
8835

8936

9037
if __name__ == "__main__":

examples/teams/clone_team.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,7 @@ def main():
5959
f"{source_team.display_name}_cloned",
6060
ClonableTeamParts.channels,
6161
source_team.visibility,
62-
).execute_query()
63-
64-
# — Step 4: poll for completion via the source team's operations —
65-
succeeded = poll_clone_completion(source_team)
66-
67-
if succeeded:
68-
print(f"\n Clone succeeded — '{target_team.display_name}' is ready.")
69-
else:
70-
print("\n Clone failed or timed out.")
62+
).execute_query_and_wait()
7163

7264

7365
if __name__ == "__main__":

office365/communications/callrecords/session.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from office365.entity_collection import EntityCollection
99
from office365.runtime.client_value_collection import ClientValueCollection
1010
from office365.runtime.paths.resource_path import ResourcePath
11+
from office365.runtime.types.odata_property import odata
1112

1213

1314
class Session(Entity):
@@ -32,6 +33,7 @@ def caller(self) -> Endpoint:
3233
"""Gets the caller property"""
3334
return self.properties.get("caller", Endpoint())
3435

36+
@odata(name="endDateTime")
3537
@property
3638
def end_date_time(self) -> datetime:
3739
"""Gets the endDateTime property"""
@@ -47,6 +49,7 @@ def modalities(self) -> ClientValueCollection[Modality]:
4749
"""Gets the modalities property"""
4850
return self.properties.get("modalities", ClientValueCollection[Modality](Modality))
4951

52+
@odata(name="startDateTime")
5053
@property
5154
def start_date_time(self) -> datetime:
5255
"""Gets the startDateTime property"""

office365/teams/collection.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def _process_response(resp: requests.Response) -> None:
5555
operation_path = ODataPathBuilder.parse_url(loc)
5656
operation = TeamsAsyncOperation(self.context, operation_path)
5757
return_type.operations.add_child(operation)
58+
return_type._pending_operation = operation
5859

5960
payload = {
6061
"displayName": display_name,

office365/teams/operations/async_operation.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import time
2+
from datetime import datetime
23
from typing import Optional
34

45
from office365.entity import Entity
6+
from office365.runtime.types.odata_property import odata
7+
from office365.teams.operations.error import OperationError
8+
from office365.teams.operations.type import TeamsAsyncOperationType
59

610

711
class TeamsAsyncOperation(Entity):
@@ -54,6 +58,40 @@ def _verify_status(return_type):
5458
_poll_for_status(1)
5559
return self
5660

61+
@odata(name="attemptsCount")
62+
@property
63+
def attempts_count(self) -> Optional[int]:
64+
"""Number of times the operation was attempted before being marked as succeeded or failed."""
65+
return self.properties.get("attemptsCount", None)
66+
67+
@odata(name="createdDateTime")
68+
@property
69+
def created_date_time(self) -> Optional[datetime]:
70+
"""Date and time when the operation was created."""
71+
return self.properties.get("createdDateTime", None)
72+
73+
@property
74+
def error(self) -> Optional[OperationError]:
75+
"""Error information if the operation failed."""
76+
return self.properties.get("error", None)
77+
78+
@odata(name="lastActionDateTime")
79+
@property
80+
def last_action_date_time(self) -> Optional[datetime]:
81+
"""Date and time when the operation was last updated."""
82+
return self.properties.get("lastActionDateTime", None)
83+
84+
@odata(name="operationType")
85+
@property
86+
def operation_type(self) -> Optional[TeamsAsyncOperationType]:
87+
"""The type of the operation."""
88+
return self.properties.get("operationType", None)
89+
90+
@property
91+
def status(self) -> Optional[str]:
92+
"""Operation status."""
93+
return self.properties.get("status", None)
94+
5795
@property
5896
def target_resource_id(self) -> Optional[str]:
5997
"""The ID of the object that's created or modified as result of this async operation, typically a team."""
@@ -65,8 +103,3 @@ def target_resource_location(self) -> Optional[str]:
65103
This URL should be treated as an opaque value and not parsed into its component paths.
66104
"""
67105
return self.properties.get("targetResourceLocation", None)
68-
69-
@property
70-
def status(self) -> Optional[str]:
71-
"""Operation status."""
72-
return self.properties.get("status", None)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from office365.runtime.client_value import ClientValue
2+
3+
4+
class OperationError(ClientValue):
5+
"""Represents the error information for a failed Teams async operation."""

office365/teams/operations/type.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from enum import Enum
2+
3+
4+
class TeamsAsyncOperationType(Enum):
5+
"""The type of long-running operation for a team."""
6+
7+
invalid = "invalid"
8+
cloneTeam = "cloneTeam"
9+
archiveTeam = "archiveTeam"
10+
unarchiveTeam = "unarchiveTeam"
11+
createTeam = "createTeam"
12+
unknownFutureValue = "unknownFutureValue"
13+
14+
@property
15+
def entity_type_name(self):
16+
return "microsoft.graph.teamsAsyncOperationType"

0 commit comments

Comments
 (0)