|
1 | 1 | """ |
2 | | -Archive lifecycle: find inactive teams, notify owners, and archive |
3 | | -(or later unarchive on demand). |
| 2 | +Find inactive teams and report archiving candidates. |
4 | 3 |
|
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. |
20 | 6 | """ |
21 | 7 |
|
22 | | -import sys |
23 | 8 | from datetime import datetime, timedelta, timezone |
24 | 9 |
|
25 | 10 | from office365.graph_client import GraphClient |
26 | 11 | from tests import test_client_id, test_client_secret, test_tenant |
27 | 12 |
|
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 |
73 | 14 |
|
74 | 15 |
|
75 | 16 | def main(): |
76 | | - mode = sys.argv[1] if len(sys.argv) > 1 else "audit" |
77 | | - |
78 | 17 | client = GraphClient(tenant=test_tenant).with_client_secret(test_client_id, test_client_secret) |
79 | 18 | cutoff = datetime.now(timezone.utc) - timedelta(days=INACTIVITY_DAYS) |
80 | 19 |
|
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") |
100 | 22 |
|
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 |
111 | 26 |
|
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(): |
117 | 29 | 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 |
126 | 37 |
|
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}") |
133 | 42 |
|
134 | 43 |
|
135 | 44 | if __name__ == "__main__": |
|
0 commit comments