1- from typing import Any , Iterable , Optional , override
1+ from collections import defaultdict
2+ from datetime import datetime , timedelta
3+ from typing import Any , Iterable , Literal , Optional , override
4+ from urllib .parse import urljoin
25
36from pardner .exceptions import UnsupportedRequestException , UnsupportedVerticalException
47from pardner .services import BaseTransferService
58from pardner .services .utils import scope_as_set , scope_as_string
6- from pardner .verticals import PhysicalActivityVertical , Vertical
9+ from pardner .verticals import PhysicalActivityVertical , SocialPostingVertical , Vertical
10+ from pardner .verticals .sub_verticals import AssociatedMediaSubVertical
711
812
913class StravaTransferService (BaseTransferService ):
@@ -64,9 +68,150 @@ def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]:
6468 sub_scopes .update (['activity:read' , 'profile:read_all' ])
6569 return sub_scopes
6670
71+ def _convert_to_datetime (self , raw_datetime : str | None ) -> datetime | None :
72+ if raw_datetime :
73+ return datetime .strptime (raw_datetime , '%Y-%m-%dT%H:%M:%SZ' )
74+ return None
75+
76+ def _parse_social_posting (self , raw_data : Any ) -> SocialPostingVertical | None :
77+ """
78+ Given the response from the API request, creates a
79+ :class:`SocialPostingVertical` model object, if possible.
80+
81+ :param raw_data: the JSON representation of the data returned by the request.
82+
83+ :returns: :class:`SocialPostingVertical` or ``None``, depending on whether it
84+ was possible to extract data from the response
85+ """
86+ if not isinstance (raw_data , dict ):
87+ return None
88+ raw_data_dict = defaultdict (dict , raw_data )
89+
90+ created_at = raw_data_dict .get ('start_date' )
91+ if created_at :
92+ created_at = self ._convert_to_datetime (created_at )
93+
94+ url_str = urljoin (
95+ 'https://www.strava.com/activities/' , str (raw_data_dict .get ('id' ))
96+ )
97+ interaction_count = raw_data_dict .get ('kudos_count' , 0 ) + raw_data_dict .get (
98+ 'comment_count' , 0
99+ )
100+
101+ status : Literal ['public' , 'private' , 'restricted' ] = 'public'
102+ if raw_data_dict .get ('private' ):
103+ status = 'private'
104+ elif raw_data_dict .get ('visibility' ) == 'followers_only' :
105+ status = 'restricted'
106+
107+ associated_media_list = []
108+ if raw_data_dict .get ('total_photo_count' , 0 ) > 0 :
109+ photo_urls = (
110+ raw_data_dict ['photos' ].get ('primary' , {}).get ('urls' , {}).values ()
111+ )
112+ associated_media_list = [
113+ AssociatedMediaSubVertical (image_url = photo_url )
114+ for photo_url in photo_urls
115+ ]
116+
117+ return SocialPostingVertical (
118+ creator_user_id = str (raw_data_dict ['athlete' ].get ('id' )),
119+ service = self ._service_name ,
120+ created_at = created_at ,
121+ url = url_str ,
122+ associated_media = associated_media_list ,
123+ interaction_count = interaction_count ,
124+ status = status ,
125+ text = raw_data_dict .get ('description' ),
126+ title = raw_data_dict .get ('name' ),
127+ )
128+
129+ def fetch_social_posting_vertical (
130+ self , request_params : dict [str , Any ] = {}, count : int = 30
131+ ) -> tuple [list [SocialPostingVertical | None ], Any ]:
132+ """
133+ Fetches and returns social postings created by the authorized user.
134+
135+ :param count: number of posts to request. At most 30 at a time.
136+ :param request_params: any other endpoint-specific parameters to be sent
137+ to the endpoint. Depending on the parameters passed, this could override
138+ the other arguments to this method.
139+
140+ :returns: two elements: the first, a list of :class:`SocialPostingVertical`s
141+ or ``None``, if unable to parse; the second, the raw response from making the
142+ request.
143+
144+ :raises: :class:`UnsupportedRequestException` if the request is unable to be
145+ made.
146+ """
147+ max_count = 30
148+ if count <= max_count :
149+ raw_social_postings = self ._get_resource_from_path (
150+ 'athlete/activities' , params = {'per_page' : count , ** request_params }
151+ ).json ()
152+ return [
153+ self ._parse_social_posting (raw_social_posting )
154+ for raw_social_posting in raw_social_postings
155+ ], raw_social_postings
156+ raise UnsupportedRequestException (
157+ self ._service_name ,
158+ f'can only make a request for at most { max_count } posts at a time.' ,
159+ )
160+
161+ def _parse_physical_activity (
162+ self , raw_data : Any
163+ ) -> PhysicalActivityVertical | None :
164+ """
165+ Given the response from the API request, creates a
166+ :class:`PhysicalActivityVertical` model object, if possible.
167+
168+ :param raw_data: the JSON representation of the data returned by the request.
169+
170+ :returns: :class:`PhysicalActivityVertical` or ``None``, depending on whether it
171+ was possible to extract data from the response
172+ """
173+ social_posting = self ._parse_social_posting (raw_data )
174+ if not social_posting :
175+ return None
176+
177+ social_posting_dict = social_posting .model_dump ()
178+ raw_data_dict = defaultdict (dict , raw_data )
179+
180+ start_datetime = self ._convert_to_datetime (raw_data_dict .get ('start_date' ))
181+ duration_s = raw_data_dict .get ('elapsed_time' )
182+ duration_timedelta = timedelta (seconds = duration_s ) if duration_s else None
183+ end_datetime = (
184+ start_datetime + duration_timedelta
185+ if start_datetime and duration_timedelta
186+ else None
187+ )
188+
189+ start_latlng = raw_data_dict .get ('start_latlng' )
190+ end_latlng = raw_data_dict .get ('end_latlng' )
191+
192+ social_posting_dict .update (
193+ {
194+ 'vertical_name' : 'physical_activity' ,
195+ 'activity_type' : raw_data_dict .get ('sport_type' ),
196+ 'distance' : raw_data_dict .get ('distance' ),
197+ 'elevation_high' : raw_data_dict .get ('elev_high' ),
198+ 'elevation_low' : raw_data_dict .get ('elev_low' ),
199+ 'kilocalories' : raw_data_dict .get ('calories' ),
200+ 'max_speed' : raw_data_dict .get ('max_speed' ),
201+ 'start_datetime' : start_datetime ,
202+ 'end_datetime' : end_datetime ,
203+ 'start_latitude' : start_latlng [0 ] if start_latlng else None ,
204+ 'start_longitude' : start_latlng [1 ] if start_latlng else None ,
205+ 'end_latitude' : end_latlng [0 ] if end_latlng else None ,
206+ 'end_longitude' : end_latlng [1 ] if end_latlng else None ,
207+ }
208+ )
209+
210+ return PhysicalActivityVertical .model_validate (social_posting_dict )
211+
67212 def fetch_physical_activity_vertical (
68213 self , request_params : dict [str , Any ] = {}, count : int = 30
69- ) -> list [Any ]:
214+ ) -> tuple [ list [PhysicalActivityVertical | None ], Any ]:
70215 """
71216 Fetches and returns activities completed by the authorized user.
72217
@@ -75,19 +220,22 @@ def fetch_physical_activity_vertical(
75220 to the endpoint. Depending on the parameters passed, this could override
76221 the other arguments to this method.
77222
78- :returns: a list of dictionary objects with information for the activities from
79- the authorized user.
223+ :returns: two elements: the first, a list of :class:`PhysicalActivityVertical`s
224+ or ``None``, if unable to parse; the second, the raw response from making the
225+ request.
80226
81227 :raises: :class:`UnsupportedRequestException` if the request is unable to be
82228 made.
83229 """
84230 max_count = 30
85231 if count <= max_count :
86- return list (
87- self ._get_resource_from_path (
88- 'athlete/activities' , params = {'per_page' : count , ** request_params }
89- ).json ()
90- )
232+ raw_activities = self ._get_resource_from_path (
233+ 'athlete/activities' , params = {'per_page' : count , ** request_params }
234+ ).json ()
235+ return [
236+ self ._parse_physical_activity (raw_activity )
237+ for raw_activity in raw_activities
238+ ], raw_activities
91239 raise UnsupportedRequestException (
92240 self ._service_name ,
93241 f'can only make a request for at most { max_count } activities at a time.' ,
0 commit comments