Skip to content

Commit 3d0f503

Browse files
authored
Merge pull request #1137 from sdelgadoc/master
Add generic notification subscriptions through webhooks
2 parents 32c13e5 + 4a30529 commit 3d0f503

File tree

8 files changed

+327
-12
lines changed

8 files changed

+327
-12
lines changed

O365/account.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
from typing import Callable, List, Optional, Tuple, Type
33

44
from .connection import Connection, MSGraphProtocol, Protocol
5+
from .subscriptions import Subscriptions
56
from .utils import ME_RESOURCE, consent_input_token
67

78

8-
class Account:
9-
connection_constructor: Type = Connection #: :meta private:
10-
11-
def __init__(self, credentials: Tuple[str, str], *,
12-
username: Optional[str] = None,
13-
protocol: Optional[Protocol] = None,
14-
main_resource: Optional[str] = None, **kwargs):
9+
class Account:
10+
connection_constructor: Type = Connection #: :meta private:
11+
subscriptions_constructor: Type = Subscriptions
12+
13+
def __init__(self, credentials: Tuple[str, str], *,
14+
username: Optional[str] = None,
15+
protocol: Optional[Protocol] = None,
16+
main_resource: Optional[str] = None, **kwargs):
1517
""" Creates an object which is used to access resources related to the specified credentials.
1618
1719
:param credentials: a tuple containing the client_id and client_secret
@@ -60,6 +62,7 @@ def __init__(self, credentials: Tuple[str, str], *,
6062
self.con = self.connection_constructor(credentials, **kwargs)
6163
#: The resource in use for the account. |br| **Type:** str
6264
self.main_resource: str = main_resource or self.protocol.default_resource
65+
self.subscriptions = self.subscriptions_constructor(parent=self)
6366

6467
def __repr__(self):
6568
if self.con.auth:

O365/mailbox.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,5 +1069,4 @@ def get_settings(self):
10691069

10701070
return self.mailbox_settings_constructor(
10711071
parent=self, **{self._cloud_data_key: data}
1072-
)
1073-
1072+
)

O365/subscriptions.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import datetime as dt
2+
from typing import Iterable, Mapping, Optional, Union
3+
4+
from .utils import ApiComponent
5+
6+
7+
class Subscriptions(ApiComponent):
8+
"""Subscription operations for Microsoft Graph webhooks."""
9+
10+
_endpoints = {
11+
"subscriptions": "/subscriptions",
12+
}
13+
14+
def __init__(self, *, parent=None, con=None, **kwargs):
15+
if parent and con:
16+
raise ValueError("Need a parent or a connection but not both")
17+
self.con = parent.con if parent else con
18+
19+
main_resource = kwargs.pop("main_resource", None) or (
20+
getattr(parent, "main_resource", None) if parent else None
21+
)
22+
23+
super().__init__(
24+
protocol=parent.protocol if parent else kwargs.get("protocol"),
25+
main_resource=main_resource,
26+
)
27+
28+
def _build_subscription_url(self, subscription_id: Optional[str] = None) -> str:
29+
"""Build the Microsoft Graph subscriptions endpoint."""
30+
endpoint = self._endpoints.get("subscriptions")
31+
if endpoint is None:
32+
raise ValueError("Subscriptions endpoint is not configured.")
33+
base_url = self.protocol.service_url.rstrip("/")
34+
if subscription_id:
35+
return f"{base_url}{endpoint}/{subscription_id}"
36+
return f"{base_url}{endpoint}"
37+
38+
@staticmethod
39+
def _format_subscription_expiration(
40+
expiration_datetime: Optional[dt.datetime] = None,
41+
expiration_minutes: Optional[int] = None,
42+
) -> str:
43+
"""Return an ISO 8601 UTC expiration string as required by Graph webhooks."""
44+
if expiration_datetime and expiration_minutes is not None:
45+
raise ValueError(
46+
"Provide either expiration_datetime or expiration_minutes, not both."
47+
)
48+
if expiration_datetime is None:
49+
minutes = expiration_minutes if expiration_minutes is not None else 60
50+
if minutes <= 0:
51+
raise ValueError("expiration_minutes must be a positive integer.")
52+
expiration_datetime = dt.datetime.now(dt.timezone.utc) + dt.timedelta(
53+
minutes=minutes
54+
)
55+
else:
56+
if expiration_datetime.tzinfo is None:
57+
expiration_datetime = expiration_datetime.replace(tzinfo=dt.timezone.utc)
58+
else:
59+
expiration_datetime = expiration_datetime.astimezone(dt.timezone.utc)
60+
return expiration_datetime.isoformat(timespec="microseconds").replace("+00:00", "Z")
61+
62+
@staticmethod
63+
def _stringify_change_type(change_type: Union[str, Iterable[str]]) -> str:
64+
"""Normalize changeType into the comma-separated string Graph expects."""
65+
if isinstance(change_type, str):
66+
value = change_type.strip()
67+
else:
68+
try:
69+
parts = [str(part).strip() for part in change_type]
70+
except TypeError as exc:
71+
raise ValueError(
72+
"change_type must be a string or an iterable of strings."
73+
) from exc
74+
value = ",".join(part for part in parts if part)
75+
if not value:
76+
raise ValueError("change_type must contain at least one value.")
77+
return value
78+
79+
def create_subscription(
80+
self,
81+
notification_url: str,
82+
resource: Optional[str] = None,
83+
change_type: Union[str, Iterable[str]] = "created",
84+
*,
85+
expiration_datetime: Optional[dt.datetime] = None,
86+
expiration_minutes: Optional[int] = None,
87+
client_state: Optional[str] = None,
88+
include_resource_data: Optional[bool] = None,
89+
encryption_certificate: Optional[str] = None,
90+
encryption_certificate_id: Optional[str] = None,
91+
lifecycle_notification_url: Optional[str] = None,
92+
latest_supported_tls_version: Optional[str] = None,
93+
additional_data: Optional[Mapping[str, object]] = None,
94+
**request_kwargs,
95+
) -> Optional[dict]:
96+
"""Create a Microsoft Graph webhook subscription.
97+
98+
See Documentation.md for webhook setup requirements.
99+
"""
100+
if not notification_url:
101+
raise ValueError("notification_url must be provided.")
102+
103+
resource = resource or self.main_resource
104+
if not resource:
105+
raise ValueError("resource must be provided.")
106+
if not resource.startswith("/"):
107+
resource = f"/{resource}"
108+
109+
expiration_value = self._format_subscription_expiration(
110+
expiration_datetime=expiration_datetime,
111+
expiration_minutes=expiration_minutes,
112+
)
113+
change_type_value = self._stringify_change_type(change_type)
114+
115+
payload = {
116+
self._cc("change_type"): change_type_value,
117+
self._cc("notification_url"): notification_url,
118+
self._cc("resource"): resource,
119+
self._cc("expiration_date_time"): expiration_value,
120+
}
121+
122+
if client_state is not None:
123+
payload[self._cc("client_state")] = client_state
124+
if include_resource_data is not None:
125+
payload[self._cc("include_resource_data")] = include_resource_data
126+
if encryption_certificate is not None:
127+
payload[self._cc("encryption_certificate")] = encryption_certificate
128+
if encryption_certificate_id is not None:
129+
payload[self._cc("encryption_certificate_id")] = encryption_certificate_id
130+
if lifecycle_notification_url is not None:
131+
payload[self._cc("lifecycle_notification_url")] = lifecycle_notification_url
132+
if latest_supported_tls_version is not None:
133+
payload[
134+
self._cc("latest_supported_tls_version")
135+
] = latest_supported_tls_version
136+
if additional_data:
137+
if not isinstance(additional_data, Mapping):
138+
raise ValueError("additional_data must be a mapping if provided.")
139+
payload.update({str(key): value for key, value in additional_data.items()})
140+
141+
url = self._build_subscription_url()
142+
response = self.con.post(url, data=payload, **request_kwargs)
143+
144+
if not response:
145+
return None
146+
147+
return response.json()
148+
149+
def renew_subscription(
150+
self,
151+
subscription_id: str,
152+
*,
153+
expiration_datetime: Optional[dt.datetime] = None,
154+
expiration_minutes: Optional[int] = None,
155+
**request_kwargs,
156+
) -> Optional[dict]:
157+
"""Renew an existing webhook subscription."""
158+
if not subscription_id:
159+
raise ValueError("subscription_id must be provided.")
160+
161+
expiration_value = self._format_subscription_expiration(
162+
expiration_datetime=expiration_datetime,
163+
expiration_minutes=expiration_minutes,
164+
)
165+
166+
payload = {
167+
self._cc("expiration_date_time"): expiration_value,
168+
}
169+
170+
url = self._build_subscription_url(subscription_id)
171+
response = self.con.patch(url, data=payload, **request_kwargs)
172+
173+
if not response:
174+
return None
175+
176+
return response.json()
177+
178+
def delete_subscription(
179+
self,
180+
subscription_id: str,
181+
**request_kwargs,
182+
) -> bool:
183+
"""Delete an existing webhook subscription."""
184+
if not subscription_id:
185+
raise ValueError("subscription_id must be provided.")
186+
187+
url = self._build_subscription_url(subscription_id)
188+
response = self.con.delete(url, **request_kwargs)
189+
190+
return bool(response)

docs/latest/api/account.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
<li class="toctree-l1 current"><a class="reference internal" href="../api.html">O365 API</a><ul class="current">
5353
<li class="toctree-l2 current"><a class="current reference internal" href="#">Account</a><ul>
5454
<li class="toctree-l3"><a class="reference internal" href="#O365.account.Account"><code class="docutils literal notranslate"><span class="pre">Account</span></code></a><ul>
55+
<li class="toctree-l4"><a class="reference internal" href="#O365.account.Account.subscriptions_constructor"><code class="docutils literal notranslate"><span class="pre">Account.subscriptions_constructor</span></code></a></li>
5556
<li class="toctree-l4"><a class="reference internal" href="#O365.account.Account.__init__"><code class="docutils literal notranslate"><span class="pre">Account.__init__()</span></code></a></li>
5657
<li class="toctree-l4"><a class="reference internal" href="#O365.account.Account.address_book"><code class="docutils literal notranslate"><span class="pre">Account.address_book()</span></code></a></li>
5758
<li class="toctree-l4"><a class="reference internal" href="#O365.account.Account.authenticate"><code class="docutils literal notranslate"><span class="pre">Account.authenticate()</span></code></a></li>
@@ -131,6 +132,12 @@ <h1>Account<a class="headerlink" href="#account" title="Link to this heading">
131132
<dt class="sig sig-object py" id="O365.account.Account">
132133
<em class="property"><span class="k"><span class="pre">class</span></span><span class="w"> </span></em><span class="sig-prename descclassname"><span class="pre">O365.account.</span></span><span class="sig-name descname"><span class="pre">Account</span></span><span class="sig-paren">(</span><em class="sig-param"><span class="n"><span class="pre">credentials</span></span><span class="p"><span class="pre">:</span></span><span class="w"> </span><span class="n"><span class="pre">Tuple</span><span class="p"><span class="pre">[</span></span><span class="pre">str</span><span class="p"><span class="pre">,</span></span><span class="w"> </span><span class="pre">str</span><span class="p"><span class="pre">]</span></span></span></em>, <em class="sig-param"><span class="keyword-only-separator o"><abbr title="Keyword-only parameters separator (PEP 3102)"><span class="pre">*</span></abbr></span></em>, <em class="sig-param"><span class="n"><span class="pre">username</span></span><span class="p"><span class="pre">:</span></span><span class="w"> </span><span class="n"><span class="pre">str</span><span class="w"> </span><span class="p"><span class="pre">|</span></span><span class="w"> </span><span class="pre">None</span></span><span class="w"> </span><span class="o"><span class="pre">=</span></span><span class="w"> </span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">protocol</span></span><span class="p"><span class="pre">:</span></span><span class="w"> </span><span class="n"><a class="reference internal" href="connection.html#O365.connection.Protocol" title="O365.connection.Protocol"><span class="pre">Protocol</span></a><span class="w"> </span><span class="p"><span class="pre">|</span></span><span class="w"> </span><span class="pre">None</span></span><span class="w"> </span><span class="o"><span class="pre">=</span></span><span class="w"> </span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">main_resource</span></span><span class="p"><span class="pre">:</span></span><span class="w"> </span><span class="n"><span class="pre">str</span><span class="w"> </span><span class="p"><span class="pre">|</span></span><span class="w"> </span><span class="pre">None</span></span><span class="w"> </span><span class="o"><span class="pre">=</span></span><span class="w"> </span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="o"><span class="pre">**</span></span><span class="n"><span class="pre">kwargs</span></span></em><span class="sig-paren">)</span><a class="reference internal" href="../_modules/O365/account.html#Account"><span class="viewcode-link"><span class="pre">[source]</span></span></a><a class="headerlink" href="#O365.account.Account" title="Link to this definition"></a></dt>
133134
<dd><p>Bases: <code class="xref py py-class docutils literal notranslate"><span class="pre">object</span></code></p>
135+
<dl class="py attribute">
136+
<dt class="sig sig-object py" id="O365.account.Account.subscriptions_constructor">
137+
<span class="sig-name descname"><span class="pre">subscriptions_constructor</span></span><a class="headerlink" href="#O365.account.Account.subscriptions_constructor" title="Link to this definition"></a></dt>
138+
<dd><p>alias of <code class="xref py py-class docutils literal notranslate"><span class="pre">Subscriptions</span></code></p>
139+
</dd></dl>
140+
134141
<dl class="py method">
135142
<dt class="sig sig-object py" id="O365.account.Account.__init__">
136143
<span class="sig-name descname"><span class="pre">__init__</span></span><span class="sig-paren">(</span><em class="sig-param"><span class="n"><span class="pre">credentials</span></span><span class="p"><span class="pre">:</span></span><span class="w"> </span><span class="n"><span class="pre">Tuple</span><span class="p"><span class="pre">[</span></span><span class="pre">str</span><span class="p"><span class="pre">,</span></span><span class="w"> </span><span class="pre">str</span><span class="p"><span class="pre">]</span></span></span></em>, <em class="sig-param"><span class="keyword-only-separator o"><abbr title="Keyword-only parameters separator (PEP 3102)"><span class="pre">*</span></abbr></span></em>, <em class="sig-param"><span class="n"><span class="pre">username</span></span><span class="p"><span class="pre">:</span></span><span class="w"> </span><span class="n"><span class="pre">str</span><span class="w"> </span><span class="p"><span class="pre">|</span></span><span class="w"> </span><span class="pre">None</span></span><span class="w"> </span><span class="o"><span class="pre">=</span></span><span class="w"> </span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">protocol</span></span><span class="p"><span class="pre">:</span></span><span class="w"> </span><span class="n"><a class="reference internal" href="connection.html#O365.connection.Protocol" title="O365.connection.Protocol"><span class="pre">Protocol</span></a><span class="w"> </span><span class="p"><span class="pre">|</span></span><span class="w"> </span><span class="pre">None</span></span><span class="w"> </span><span class="o"><span class="pre">=</span></span><span class="w"> </span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="n"><span class="pre">main_resource</span></span><span class="p"><span class="pre">:</span></span><span class="w"> </span><span class="n"><span class="pre">str</span><span class="w"> </span><span class="p"><span class="pre">|</span></span><span class="w"> </span><span class="pre">None</span></span><span class="w"> </span><span class="o"><span class="pre">=</span></span><span class="w"> </span><span class="default_value"><span class="pre">None</span></span></em>, <em class="sig-param"><span class="o"><span class="pre">**</span></span><span class="n"><span class="pre">kwargs</span></span></em><span class="sig-paren">)</span><a class="reference internal" href="../_modules/O365/account.html#Account.__init__"><span class="viewcode-link"><span class="pre">[source]</span></span></a><a class="headerlink" href="#O365.account.Account.__init__" title="Link to this definition"></a></dt>

docs/latest/genindex.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3288,10 +3288,10 @@ <h2 id="S">S</h2>
32883288
</li>
32893289
<li><a href="api/excel.html#O365.excel.RangeFormat.set_borders">set_borders() (O365.excel.RangeFormat method)</a>
32903290
</li>
3291-
</ul></td>
3292-
<td style="width: 33%; vertical-align: top;"><ul>
32933291
<li><a href="api/message.html#O365.message.MessageFlag.set_completed">set_completed() (O365.message.MessageFlag method)</a>
32943292
</li>
3293+
</ul></td>
3294+
<td style="width: 33%; vertical-align: top;"><ul>
32953295
<li><a href="api/calendar.html#O365.calendar.EventRecurrence.set_daily">set_daily() (O365.calendar.EventRecurrence method)</a>
32963296
</li>
32973297
<li><a href="api/mailbox.html#O365.mailbox.MailBox.set_disable_reply">set_disable_reply() (O365.mailbox.MailBox method)</a>
@@ -3438,6 +3438,8 @@ <h2 id="S">S</h2>
34383438
<li><a href="api/teams.html#O365.teams.ChatMessage.subject">(O365.teams.ChatMessage attribute)</a>
34393439
</li>
34403440
</ul></li>
3441+
<li><a href="api/account.html#O365.account.Account.subscriptions_constructor">subscriptions_constructor (O365.account.Account attribute)</a>
3442+
</li>
34413443
<li><a href="api/teams.html#O365.teams.ChatMessage.summary">summary (O365.teams.ChatMessage attribute)</a>
34423444
</li>
34433445
<li><a href="api/address_book.html#O365.address_book.Contact.surname">surname (O365.address_book.Contact property)</a>

docs/latest/objects.inv

18 Bytes
Binary file not shown.

docs/latest/searchindex.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)