7 Module implementing a dialog to send bug reports. |
7 Module implementing a dialog to send bug reports. |
8 """ |
8 """ |
9 |
9 |
10 import os |
10 import os |
11 import base64 |
11 import base64 |
12 import json |
12 |
13 import datetime |
13 from googleapiclient import discovery, errors |
14 import contextlib |
14 from google.oauth2.credentials import Credentials, UserAccessTokenCredentials |
15 |
15 from google.auth.transport.requests import Request |
16 from googleapiclient import discovery |
16 from google.auth.exceptions import RefreshError |
17 from google.oauth2.credentials import Credentials |
17 from google_auth_oauthlib.flow import InstalledAppFlow |
18 from requests_oauthlib import OAuth2Session |
18 |
19 |
19 |
20 from PyQt6.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl, QUrlQuery |
20 from PyQt6.QtCore import pyqtSignal, QObject |
21 from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout |
|
22 |
|
23 from eric7.EricWidgets import EricMessageBox |
|
24 from eric7.EricWidgets.EricTextInputDialog import EricTextInputDialog |
|
25 |
21 |
26 from eric7 import Globals |
22 from eric7 import Globals |
27 |
23 |
28 from .EricGoogleMailHelpers import ( |
24 from .EricGoogleMailHelpers import ( |
29 CLIENT_SECRET_FILE, |
25 CLIENT_SECRET_FILE, |
30 SCOPES, |
26 SCOPES, |
31 TOKEN_FILE, |
27 TOKEN_FILE, |
32 APPLICATION_NAME, |
|
33 ) |
28 ) |
34 |
|
35 |
|
36 class EricGoogleMailAuthBrowser(QDialog): |
|
37 """ |
|
38 Class implementing a simple web browser to perform the OAuth2 |
|
39 authentication process. |
|
40 |
|
41 @signal approvalCodeReceived(str) emitted to indicate the receipt of the |
|
42 approval code |
|
43 """ |
|
44 |
|
45 approvalCodeReceived = pyqtSignal(str) |
|
46 |
|
47 def __init__(self, parent=None): |
|
48 """ |
|
49 Constructor |
|
50 |
|
51 @param parent reference to the parent widget |
|
52 @type QWidget |
|
53 """ |
|
54 super().__init__(parent) |
|
55 |
|
56 self.__layout = QVBoxLayout(self) |
|
57 |
|
58 from PyQt6.QtWebEngineWidgets import QWebEngineView |
|
59 |
|
60 self.__browser = QWebEngineView(self) |
|
61 self.__browser.titleChanged.connect(self.__titleChanged) |
|
62 self.__browser.loadFinished.connect(self.__pageLoadFinished) |
|
63 self.__layout.addWidget(self.__browser) |
|
64 |
|
65 self.__buttonBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) |
|
66 self.__buttonBox.rejected.connect(self.reject) |
|
67 self.__layout.addWidget(self.__buttonBox) |
|
68 |
|
69 self.resize(600, 700) |
|
70 |
|
71 @pyqtSlot(str) |
|
72 def __titleChanged(self, title): |
|
73 """ |
|
74 Private slot handling changes of the web page title. |
|
75 |
|
76 @param title web page title |
|
77 @type str |
|
78 """ |
|
79 self.setWindowTitle(title) |
|
80 |
|
81 @pyqtSlot() |
|
82 def __pageLoadFinished(self): |
|
83 """ |
|
84 Private slot handling the loadFinished signal. |
|
85 """ |
|
86 url = self.__browser.url() |
|
87 if url.toString().startswith( |
|
88 "https://accounts.google.com/o/oauth2/approval/v2" |
|
89 ): |
|
90 urlQuery = QUrlQuery(url) |
|
91 approvalCode = urlQuery.queryItemValue( |
|
92 "approvalCode", QUrl.ComponentFormattingOption.FullyDecoded |
|
93 ) |
|
94 elif url.toString().startswith(("http://localhost", "https://localhost")): |
|
95 urlQuery = QUrlQuery(url) |
|
96 approvalCode = urlQuery.queryItemValue( |
|
97 "code", QUrl.ComponentFormattingOption.FullyDecoded |
|
98 ) |
|
99 else: |
|
100 approvalCode = None |
|
101 if approvalCode: |
|
102 self.approvalCodeReceived.emit(approvalCode) |
|
103 self.close() |
|
104 |
|
105 def load(self, url): |
|
106 """ |
|
107 Public method to start the authorization flow by loading the given URL. |
|
108 |
|
109 @param url URL to be laoded |
|
110 @type str or QUrl |
|
111 """ |
|
112 self.__browser.setUrl(QUrl(url)) |
|
113 |
29 |
114 |
30 |
115 class EricGoogleMail(QObject): |
31 class EricGoogleMail(QObject): |
116 """ |
32 """ |
117 Class implementing the logic to send emails via Google Mail. |
33 Class implementing the logic to send emails via Google Mail. |
179 " API been enabled?" |
92 " API been enabled?" |
180 ), |
93 ), |
181 ) |
94 ) |
182 return |
95 return |
183 |
96 |
184 with open( |
97 credentials = self.__loadCredentials() |
185 os.path.join(Globals.getConfigDir(), CLIENT_SECRET_FILE), "r" |
98 credentials = UserAccessTokenCredentials() |
186 ) as clientSecret: |
99 # If there are no (valid) credentials available, let the user log in. |
187 clientData = json.load(clientSecret) |
100 if not credentials or not credentials.valid: |
188 self.__clientConfig = clientData["installed"] |
101 if credentials and credentials.expired and credentials.refresh_token: |
189 token = self.__loadToken() |
102 try: |
190 if token is None: |
103 credentials.refresh(Request()) |
191 # no valid OAuth2 token available |
104 except RefreshError: |
192 self.__session = OAuth2Session( |
105 credentials = None |
193 self.__clientConfig["client_id"], |
106 if not credentials or not credentials.valid: |
194 scope=SCOPES, |
107 flow = InstalledAppFlow.from_client_secrets_file( |
195 redirect_uri=self.__clientConfig["redirect_uris"][0], |
108 os.path.join(Globals.getConfigDir(), CLIENT_SECRET_FILE), SCOPES |
196 ) |
|
197 authorizationUrl, _ = self.__session.authorization_url( |
|
198 self.__clientConfig["auth_uri"], |
|
199 access_type="offline", |
|
200 prompt="select_account", |
|
201 ) |
|
202 if self.__browser is None: |
|
203 with contextlib.suppress(ImportError): |
|
204 self.__browser = EricGoogleMailAuthBrowser() |
|
205 self.__browser.approvalCodeReceived.connect( |
|
206 self.__processAuthorization |
|
207 ) |
|
208 if self.__browser: |
|
209 self.__browser.show() |
|
210 self.__browser.load(QUrl(authorizationUrl)) |
|
211 else: |
|
212 from PyQt6.QtGui import QDesktopServices |
|
213 |
|
214 QDesktopServices.openUrl(QUrl(authorizationUrl)) |
|
215 ok, authCode = EricTextInputDialog.getText( |
|
216 None, |
|
217 self.tr("OAuth2 Authorization Code"), |
|
218 self.tr("Enter the OAuth2 authorization code:"), |
|
219 ) |
109 ) |
220 if ok and authCode: |
110 credentials = flow.run_local_server(port=0) |
221 self.__processAuthorization(authCode) |
111 # Save the credentials for the next run |
222 else: |
112 self.__saveCredentials(credentials) |
223 self.__session = None |
113 |
224 else: |
114 self.__credentials = credentials |
225 self.__session = OAuth2Session( |
|
226 self.__clientConfig["client_id"], |
|
227 scope=SCOPES, |
|
228 redirect_uri=self.__clientConfig["redirect_uris"][0], |
|
229 token=token, |
|
230 auto_refresh_kwargs={ |
|
231 "client_id": self.__clientConfig["client_id"], |
|
232 "client_secret": self.__clientConfig["client_secret"], |
|
233 }, |
|
234 auto_refresh_url=self.__clientConfig["token_uri"], |
|
235 token_updater=self.__saveToken, |
|
236 ) |
|
237 self.__doSendMessages() |
|
238 |
|
239 @pyqtSlot(str) |
|
240 def __processAuthorization(self, authCode): |
|
241 """ |
|
242 Private slot to process the received authorization code. |
|
243 |
|
244 @param authCode received authorization code |
|
245 @type str |
|
246 """ |
|
247 try: |
|
248 self.__session.fetch_token( |
|
249 self.__clientConfig["token_uri"], |
|
250 client_secret=self.__clientConfig["client_secret"], |
|
251 code=authCode, |
|
252 ) |
|
253 self.__saveToken(self.__session.token) |
|
254 |
|
255 # authorization completed; now send all queued messages |
|
256 self.__doSendMessages() |
|
257 except Exception as err: |
|
258 EricMessageBox.critical( |
|
259 self, |
|
260 self.tr("Google Authorization"), |
|
261 self.tr( |
|
262 """<p>The authorization via Google failed.</p><p>Reason: {0}</p>""" |
|
263 ).format(str(err)), |
|
264 ) |
|
265 |
115 |
266 def __doSendMessages(self): |
116 def __doSendMessages(self): |
267 """ |
117 """ |
268 Private method to send all queued messages. |
118 Private method to send all queued messages. |
269 """ |
119 """ |
270 if not self.__session: |
120 if not self.__credentials or not self.__credentials.valid: |
271 self.sendResult.emit(False, self.tr("No authorized session available.")) |
121 self.sendResult.emit(False, self.tr("No valid credentials available.")) |
272 return |
122 return |
273 |
123 |
274 try: |
124 try: |
275 results = [] |
125 results = [] |
276 credentials = self.__credentialsFromSession() |
|
277 service = discovery.build( |
126 service = discovery.build( |
278 "gmail", "v1", credentials=credentials, cache_discovery=False |
127 "gmail", "v1", credentials=self.__credentials, cache_discovery=False |
279 ) |
128 ) |
280 count = 0 |
129 count = 0 |
281 while self.__messages: |
130 while self.__messages: |
282 count += 1 |
131 count += 1 |
283 message = self.__messages.pop(0) |
132 message = self.__messages.pop(0) |
284 message1 = self.__prepareMessage(message) |
133 message1 = self.__prepareMessage(message) |
285 service.users().messages().send(userId="me", body=message1).execute() |
134 service.users().messages().send(userId="me", body=message1).execute() |
286 results.append(self.tr("Message #{0} sent.").format(count)) |
135 results.append(self.tr("Message #{0} sent.").format(count)) |
287 |
136 |
288 self.sendResult.emit(True, "\n\n".join(results)) |
137 self.sendResult.emit(True, "\n\n".join(results)) |
289 except Exception as error: |
138 except errors.HttpError as err: |
290 self.sendResult.emit(False, str(error)) |
139 self.sendResult.emit(False, str(err)) |
291 |
140 |
292 def __loadToken(self): |
141 def __loadCredentials(self): |
293 """ |
142 """ |
294 Private method to load a token from the token file. |
143 Private method to load credentials from the token file. |
295 |
144 |
296 @return loaded token |
145 @return created credentials object |
297 @rtype dict or None |
146 @rtype Credentials |
298 """ |
147 """ |
299 homeDir = os.path.expanduser("~") |
148 homeDir = os.path.expanduser("~") |
300 credentialsDir = os.path.join(homeDir, ".credentials") |
149 credentialsDir = os.path.join(homeDir, ".credentials") |
301 if not os.path.exists(credentialsDir): |
150 if not os.path.exists(credentialsDir): |
302 os.makedirs(credentialsDir) |
151 os.makedirs(credentialsDir) |
303 tokenPath = os.path.join(credentialsDir, TOKEN_FILE) |
152 tokenPath = os.path.join(credentialsDir, TOKEN_FILE) |
304 |
153 |
305 if os.path.exists(tokenPath): |
154 if os.path.exists(tokenPath): |
306 with open(tokenPath, "r") as tokenFile: |
155 return Credentials.from_authorized_user_file(tokenPath, SCOPES) |
307 return json.load(tokenFile) |
|
308 else: |
156 else: |
309 return None |
157 return None |
310 |
158 |
311 def __saveToken(self, token): |
159 def __saveCredentials(self, credentials): |
312 """ |
160 """ |
313 Private method to save a token to the token file. |
161 Private method to save credentials to the token file. |
314 |
162 |
315 @param token token to be saved |
163 @param credentials credentials to be saved |
316 @type dict |
164 @type Credentials |
317 """ |
165 """ |
318 homeDir = os.path.expanduser("~") |
166 homeDir = os.path.expanduser("~") |
319 credentialsDir = os.path.join(homeDir, ".credentials") |
167 credentialsDir = os.path.join(homeDir, ".credentials") |
320 if not os.path.exists(credentialsDir): |
168 if not os.path.exists(credentialsDir): |
321 os.makedirs(credentialsDir) |
169 os.makedirs(credentialsDir) |
322 tokenPath = os.path.join(credentialsDir, TOKEN_FILE) |
170 tokenPath = os.path.join(credentialsDir, TOKEN_FILE) |
323 |
171 |
324 with open(tokenPath, "w") as tokenFile: |
172 with open(tokenPath, "w") as tokenFile: |
325 json.dump(token, tokenFile) |
173 tokenFile.write(credentials.to_json()) |
326 |
|
327 def __credentialsFromSession(self): |
|
328 """ |
|
329 Private method to create a credentials object. |
|
330 |
|
331 @return created credentials object |
|
332 @rtype google.oauth2.credentials.Credentials |
|
333 """ |
|
334 credentials = None |
|
335 |
|
336 if self.__clientConfig and self.__session: |
|
337 token = self.__session.token |
|
338 if token: |
|
339 credentials = Credentials( |
|
340 token["access_token"], |
|
341 refresh_token=token.get("refresh_token"), |
|
342 id_token=token.get("id_token"), |
|
343 token_uri=self.__clientConfig["token_uri"], |
|
344 client_id=self.__clientConfig["client_id"], |
|
345 client_secret=self.__clientConfig["client_secret"], |
|
346 scopes=SCOPES, |
|
347 ) |
|
348 credentials.expiry = datetime.datetime.fromtimestamp( |
|
349 token["expires_at"] |
|
350 ) |
|
351 |
|
352 return credentials |
|
353 |
174 |
354 |
175 |
355 def GoogleMailHelp(): |
176 def GoogleMailHelp(): |
356 """ |
177 """ |
357 Module function to get some help about how to enable the Google Mail |
178 Module function to get some help about how to enable the Google Mail |
363 return ( |
184 return ( |
364 "<h2>Steps to turn on the Gmail API</h2>" |
185 "<h2>Steps to turn on the Gmail API</h2>" |
365 "<ol>" |
186 "<ol>" |
366 "<li>Use <a href='{0}'>this wizard</a> to create or select a project" |
187 "<li>Use <a href='{0}'>this wizard</a> to create or select a project" |
367 " in the Google Developers Console and automatically turn on the API." |
188 " in the Google Developers Console and automatically turn on the API." |
368 " Click <b>Continue</b>, then <b>Go to credentials</b>.</li>" |
189 " Click <b>Continue</b>.</li>" |
369 "<li>At the top of the page, select the <b>OAuth consent screen</b>" |
190 "<li>Select <b>Create Project</b>, fill out the form and select <b>Create</b>." |
370 " tab. Select an <b>Email address</b>, enter a <b>Product name</b> if" |
191 " Select <b>Continue</b> and <b>Activate</b>.</li>" |
371 " not already set, and click the <b>Save</b> button.</li>" |
192 "<li>In the left side pane (menu) select <b>APIs and Services</b></li>" |
372 "<li>Select the <b>Credentials</b> tab, click the <b>Add credentials" |
193 "<li>Select <b>OAuth consent screen</b>, press <b>Create</b> and" |
373 "</b> button and select <b>OAuth 2.0 client ID</b>.</li>" |
194 " fill out the form.</li>" |
374 "<li>Select the application type <b>Other</b>, enter the name "" |
195 "<li>In the menu select <b>Credentials</b> and create OAuth credentials.</li>" |
375 "{1}", and click the <b>Create</b>" |
196 "<li>At the end of the process select the <b>Download</b> button below the" |
376 " button.</li>" |
197 "Client-ID. Save the credentials file to the eric configuration directory" |
377 "<li>Click <b>OK</b> to dismiss the resulting dialog.</li>" |
198 " <code>{1}</code> with the name <code>{2}</code>.</li>" |
378 "<li>Click the (Download JSON) button to the right of the client ID." |
|
379 "</li>" |
|
380 "<li>Move this file to the eric configuration directory" |
|
381 " <code>{2}</code> and rename it <code>{3}</code>.</li>" |
|
382 "</ol>".format( |
199 "</ol>".format( |
383 "https://console.developers.google.com/start/api?id=gmail", |
200 "https://console.developers.google.com/start/api?id=gmail", |
384 APPLICATION_NAME, |
|
385 Globals.getConfigDir(), |
201 Globals.getConfigDir(), |
386 CLIENT_SECRET_FILE, |
202 CLIENT_SECRET_FILE, |
387 ) |
203 ) |
388 ) |
204 ) |