Skip to content

Commit fe18ed2

Browse files
authored
Git service: attach each service to an allauth provider (#11995)
- With #11942, there is the need to get an OAuth2 client from an account, so that method was moved outside the UserService class, and it just depends on the social account. - provider_id and provider name don't need to be an instance method, and can be extracted from the allauth provider attached to the service class. This is on top of #11983
1 parent 2c963f1 commit fe18ed2

File tree

11 files changed

+219
-256
lines changed

11 files changed

+219
-256
lines changed

readthedocs/api/v2/views/model_views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ def get_queryset(self):
420420
self.model.objects.api_v2(self.request.user)
421421
.filter(
422422
remote_organization_relations__account__provider__in=[
423-
service.adapter.provider_id for service in registry
423+
service.allauth_provider.id for service in registry
424424
]
425425
)
426426
.distinct()
@@ -466,7 +466,7 @@ def get_queryset(self):
466466

467467
query = query.filter(
468468
remote_repository_relations__account__provider__in=[
469-
service.adapter.provider_id for service in registry
469+
service.allauth_provider.id for service in registry
470470
],
471471
).distinct()
472472

readthedocs/builds/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ def send_build_status(build_pk, commit, status):
428428
message_id=MESSAGE_OAUTH_BUILD_STATUS_FAILURE,
429429
attached_to=build.project,
430430
format_values={
431-
"provider_name": service_class.provider_name,
431+
"provider_name": service_class.allauth_provider.name,
432432
"url_connect_account": reverse("socialaccount_connections"),
433433
},
434434
dismissable=True,

readthedocs/oauth/clients.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from datetime import datetime
2+
3+
import structlog
4+
from django.utils import timezone
5+
from requests_oauthlib import OAuth2Session
6+
7+
log = structlog.get_logger(__name__)
8+
9+
10+
def _get_token_updater(token):
11+
"""
12+
Update token given data from OAuth response.
13+
14+
Expect the following response into the closure::
15+
16+
{
17+
u'token_type': u'bearer',
18+
u'scopes': u'webhook repository team account',
19+
u'refresh_token': u'...',
20+
u'access_token': u'...',
21+
u'expires_in': 3600,
22+
u'expires_at': 1449218652.558185
23+
}
24+
"""
25+
26+
def _updater(data):
27+
token.token = data["access_token"]
28+
token.token_secret = data.get("refresh_token", "")
29+
token.expires_at = timezone.make_aware(
30+
datetime.fromtimestamp(data["expires_at"]),
31+
)
32+
token.save()
33+
log.info("Updated token.", token_id=token.pk)
34+
35+
return _updater
36+
37+
38+
def get_oauth2_client(account):
39+
"""Get an OAuth2 client for the given social account."""
40+
token = account.socialtoken_set.first()
41+
if token is None:
42+
return None
43+
44+
token_config = {
45+
"access_token": token.token,
46+
"token_type": "bearer",
47+
}
48+
if token.expires_at is not None:
49+
token_expires = (token.expires_at - timezone.now()).total_seconds()
50+
token_config.update(
51+
{
52+
"refresh_token": token.token_secret,
53+
"expires_in": token_expires,
54+
}
55+
)
56+
57+
provider = account.get_provider()
58+
social_app = provider.app
59+
oauth2_adapter = provider.get_oauth2_adapter(request=provider.request)
60+
61+
session = OAuth2Session(
62+
client_id=social_app.client_id,
63+
token=token_config,
64+
auto_refresh_kwargs={
65+
"client_id": social_app.client_id,
66+
"client_secret": social_app.secret,
67+
},
68+
auto_refresh_url=oauth2_adapter.access_token_url,
69+
token_updater=_get_token_updater(token),
70+
)
71+
return session

readthedocs/oauth/services/base.py

Lines changed: 13 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
"""OAuth utility functions."""
22
import re
3-
from datetime import datetime
3+
from functools import cached_property
44

55
import structlog
66
from allauth.socialaccount.models import SocialAccount
7-
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter
7+
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
88
from django.conf import settings
99
from django.urls import reverse
10-
from django.utils import timezone
1110
from django.utils.translation import gettext_lazy as _
1211
from oauthlib.oauth2.rfc6749.errors import InvalidClientIdError
1312
from requests.exceptions import RequestException
14-
from requests_oauthlib import OAuth2Session
1513

1614
from readthedocs.core.permissions import AdminPermission
15+
from readthedocs.oauth.clients import get_oauth2_client
1716

1817
log = structlog.get_logger(__name__)
1918

@@ -33,8 +32,9 @@ class Service:
3332
"""Base class for service that interacts with a VCS provider and a project."""
3433

3534
vcs_provider_slug: str
35+
allauth_provider = type[OAuth2Provider]
36+
3637
url_pattern: re.Pattern | None
37-
provider_name: str
3838
default_user_avatar_url = settings.OAUTH_AVATAR_USER_DEFAULT_URL
3939
default_org_avatar_url = settings.OAUTH_AVATAR_ORG_DEFAULT_URL
4040
supports_build_status = False
@@ -127,15 +127,12 @@ class UserService(Service):
127127
:param account: :py:class:`SocialAccount` instance for user
128128
"""
129129

130-
adapter = None
131-
132130
def __init__(self, user, account):
133-
self.session = None
134131
self.user = user
135132
self.account = account
136133
log.bind(
137134
user_username=self.user.username,
138-
social_provider=self.provider_id,
135+
social_provider=self.allauth_provider.id,
139136
social_account_id=self.account.pk,
140137
)
141138

@@ -149,96 +146,14 @@ def for_project(cls, project):
149146
def for_user(cls, user):
150147
accounts = SocialAccount.objects.filter(
151148
user=user,
152-
provider=cls.adapter.provider_id,
149+
provider=cls.allauth_provider.id,
153150
)
154151
for account in accounts:
155152
yield cls(user=user, account=account)
156153

157-
def get_adapter(self) -> type[OAuth2Adapter]:
158-
return self.adapter
159-
160-
@property
161-
def provider_id(self):
162-
return self.get_adapter().provider_id
163-
164-
def get_session(self):
165-
if self.session is None:
166-
self.create_session()
167-
return self.session
168-
169-
def get_access_token_url(self):
170-
# ``access_token_url`` is a property in some adapters,
171-
# so we need to instantiate it to get the actual value.
172-
# pylint doesn't recognize that get_adapter returns a class.
173-
# pylint: disable=not-callable
174-
adapter = self.get_adapter()(request=None)
175-
return adapter.access_token_url
176-
177-
def create_session(self):
178-
"""
179-
Create OAuth session for user.
180-
181-
This configures the OAuth session based on the :py:class:`SocialToken`
182-
attributes. If there is an ``expires_at``, treat the session as an auto
183-
renewing token. Some providers expire tokens after as little as 2 hours.
184-
"""
185-
token = self.account.socialtoken_set.first()
186-
if token is None:
187-
return None
188-
189-
token_config = {
190-
"access_token": token.token,
191-
"token_type": "bearer",
192-
}
193-
if token.expires_at is not None:
194-
token_expires = (token.expires_at - timezone.now()).total_seconds()
195-
token_config.update(
196-
{
197-
"refresh_token": token.token_secret,
198-
"expires_in": token_expires,
199-
}
200-
)
201-
202-
social_app = self.account.get_provider().app
203-
self.session = OAuth2Session(
204-
client_id=social_app.client_id,
205-
token=token_config,
206-
auto_refresh_kwargs={
207-
"client_id": social_app.client_id,
208-
"client_secret": social_app.secret,
209-
},
210-
auto_refresh_url=self.get_access_token_url(),
211-
token_updater=self.token_updater(token),
212-
)
213-
214-
return self.session or None
215-
216-
def token_updater(self, token):
217-
"""
218-
Update token given data from OAuth response.
219-
220-
Expect the following response into the closure::
221-
222-
{
223-
u'token_type': u'bearer',
224-
u'scopes': u'webhook repository team account',
225-
u'refresh_token': u'...',
226-
u'access_token': u'...',
227-
u'expires_in': 3600,
228-
u'expires_at': 1449218652.558185
229-
}
230-
"""
231-
232-
def _updater(data):
233-
token.token = data["access_token"]
234-
token.token_secret = data.get("refresh_token", "")
235-
token.expires_at = timezone.make_aware(
236-
datetime.fromtimestamp(data["expires_at"]),
237-
)
238-
token.save()
239-
log.info("Updated token.", token_id=token.pk)
240-
241-
return _updater
154+
@cached_property
155+
def session(self):
156+
return get_oauth2_client(self.account)
242157

243158
def paginate(self, url, **kwargs):
244159
"""
@@ -251,7 +166,7 @@ def paginate(self, url, **kwargs):
251166
"""
252167
resp = None
253168
try:
254-
resp = self.get_session().get(url, params=kwargs)
169+
resp = self.session.get(url, params=kwargs)
255170

256171
# TODO: this check of the status_code would be better in the
257172
# ``create_session`` method since it could be used from outside, but
@@ -263,7 +178,7 @@ def paginate(self, url, **kwargs):
263178
# needs to reconnect his account
264179
raise SyncServiceError(
265180
SyncServiceError.INVALID_OR_REVOKED_ACCESS_TOKEN.format(
266-
provider=self.provider_name
181+
provider=self.allauth_provider.name
267182
)
268183
)
269184

@@ -277,7 +192,7 @@ def paginate(self, url, **kwargs):
277192
log.warning("access_token or refresh_token failed.", url=url)
278193
raise SyncServiceError(
279194
SyncServiceError.INVALID_OR_REVOKED_ACCESS_TOKEN.format(
280-
provider=self.provider_name
195+
provider=self.allauth_provider.name
281196
)
282197
)
283198
# Catch exceptions with request or deserializing JSON

readthedocs/oauth/services/bitbucket.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
import re
55

66
import structlog
7-
from allauth.socialaccount.providers.bitbucket_oauth2.views import (
8-
BitbucketOAuth2Adapter,
7+
from allauth.socialaccount.providers.bitbucket_oauth2.provider import (
8+
BitbucketOAuth2Provider,
99
)
1010
from django.conf import settings
1111
from requests.exceptions import RequestException
@@ -24,12 +24,12 @@ class BitbucketService(UserService):
2424

2525
"""Provider service for Bitbucket."""
2626

27-
adapter = BitbucketOAuth2Adapter
27+
vcs_provider_slug = BITBUCKET
28+
allauth_provider = BitbucketOAuth2Provider
29+
base_api_url = "https://api.bitbucket.org"
2830
# TODO replace this with a less naive check
2931
url_pattern = re.compile(r"bitbucket.org")
3032
https_url_pattern = re.compile(r"^https:\/\/[^@][email protected]/")
31-
vcs_provider_slug = BITBUCKET
32-
provider_name = "Bitbucket"
3333

3434
def sync_repositories(self):
3535
"""Sync repositories from Bitbucket API."""
@@ -49,7 +49,7 @@ def sync_repositories(self):
4949
log.warning("Error syncing Bitbucket repositories")
5050
raise SyncServiceError(
5151
SyncServiceError.INVALID_OR_REVOKED_ACCESS_TOKEN.format(
52-
provider=self.vcs_provider_slug
52+
provider=self.allauth_provider.name
5353
)
5454
)
5555

@@ -82,7 +82,7 @@ def sync_organizations(self):
8282

8383
try:
8484
workspaces = self.paginate(
85-
"https://api.bitbucket.org/2.0/workspaces/",
85+
f"{self.base_api_url}/2.0/workspaces/",
8686
role="member",
8787
)
8888
for workspace in workspaces:
@@ -102,7 +102,7 @@ def sync_organizations(self):
102102
log.warning("Error syncing Bitbucket organizations")
103103
raise SyncServiceError(
104104
SyncServiceError.INVALID_OR_REVOKED_ACCESS_TOKEN.format(
105-
provider=self.vcs_provider_slug
105+
provider=self.allauth_provider.name
106106
)
107107
)
108108

@@ -235,9 +235,8 @@ def get_provider_data(self, project, integration):
235235
if integration.provider_data:
236236
return integration.provider_data
237237

238-
session = self.get_session()
239238
owner, repo = build_utils.get_bitbucket_username_repo(url=project.repo)
240-
url = f"https://api.bitbucket.org/2.0/repositories/{owner}/{repo}/hooks"
239+
url = f"{self.base_api_url}/2.0/repositories/{owner}/{repo}/hooks"
241240

242241
rtd_webhook_url = self.get_webhook_url(project, integration)
243242

@@ -247,7 +246,7 @@ def get_provider_data(self, project, integration):
247246
url=url,
248247
)
249248
try:
250-
resp = session.get(url)
249+
resp = self.session.get(url)
251250

252251
if resp.status_code == 200:
253252
recv_data = resp.json()
@@ -284,9 +283,8 @@ def setup_webhook(self, project, integration=None):
284283
:returns: boolean based on webhook set up success, and requests Response object
285284
:rtype: (Bool, Response)
286285
"""
287-
session = self.get_session()
288286
owner, repo = build_utils.get_bitbucket_username_repo(url=project.repo)
289-
url = f"https://api.bitbucket.org/2.0/repositories/{owner}/{repo}/hooks"
287+
url = f"{self.base_api_url}/2.0/repositories/{owner}/{repo}/hooks"
290288
if not integration:
291289
integration, _ = Integration.objects.get_or_create(
292290
project=project,
@@ -302,7 +300,7 @@ def setup_webhook(self, project, integration=None):
302300
)
303301

304302
try:
305-
resp = session.post(
303+
resp = self.session.post(
306304
url,
307305
data=data,
308306
headers={"content-type": "application/json"},
@@ -355,13 +353,12 @@ def update_webhook(self, project, integration):
355353
if not provider_data:
356354
return self.setup_webhook(project, integration)
357355

358-
session = self.get_session()
359356
data = self.get_webhook_data(project, integration)
360357
resp = None
361358
try:
362359
# Expect to throw KeyError here if provider_data is invalid
363360
url = provider_data["links"]["self"]["href"]
364-
resp = session.put(
361+
resp = self.session.put(
365362
url,
366363
data=data,
367364
headers={"content-type": "application/json"},

0 commit comments

Comments
 (0)