source: OpenWorkouts-current/ow/views/workout.py @ b3374f6

currentfeature/docs
Last change on this file since b3374f6 was b3374f6, checked in by Borja Lopez <borja@…>, 5 years ago

(#52) - Make map screenshot generation async and non-blocker of the dashboard
and user profile pages:

  • workout.map_screenshot is a property again, returns a string with the static path (suitable for use with request.static_url()) for the map screenshot file if exists, none otherwise
  • added a couple of helpers to build the proper screenshot name and full path on the filesystem
  • added a view to generate the map if needed, returning the full static url to the screenshot file in a json-encoded stream
  • added a new js ow tool that looks for workouts that still don't have a map screenshot, calling the view to generate that screenshot and updating the map screenshot when the file is ready
  • modified the dashboard and user profile templates, so a dummy/placeholder image is shown when no map screenshot is yet ready, plus setup and calls for the js tool that generates the maps.
  • Property mode set to 100644
File size: 9.8 KB
Line 
1from decimal import Decimal
2from datetime import datetime, timedelta, time, timezone
3import json
4
5import gpxpy
6
7from pyramid.httpexceptions import HTTPFound, HTTPNotFound
8from pyramid.view import view_config
9from pyramid.response import Response
10from pyramid_simpleform import Form
11from pyramid_simpleform.renderers import FormRenderer
12
13from ..schemas.workout import (
14    UploadedWorkoutSchema,
15    ManualWorkoutSchema,
16    UpdateWorkoutSchema
17)
18from ..models.workout import Workout
19from ..models.user import User
20from ..utilities import slugify, save_map_screenshot
21from ..catalog import get_catalog, reindex_object, remove_from_catalog
22
23
24@view_config(
25    context=User,
26    permission='edit',
27    name='add-workout-manually',
28    renderer='ow:templates/add_manual_workout.pt')
29def add_workout_manually(context, request):
30    form = Form(request, schema=ManualWorkoutSchema(),
31                defaults={'duration_hours': '0',
32                          'duration_minutes': '0',
33                          'duration_seconds': '0'})
34
35    if 'submit' in request.POST and form.validate():
36        # exclude the three duration_* and start_* fields, so they won't be
37        # "bind" to the object, we do calculate both the full duration in
38        # seconds and the full datetime "start" and we save that
39        excluded = ['duration_hours', 'duration_minutes', 'duration_seconds',
40                    'start_date', 'start_time']
41        workout = form.bind(Workout(), exclude=excluded)
42        duration = timedelta(hours=form.data['duration_hours'],
43                             minutes=form.data['duration_minutes'],
44                             seconds=form.data['duration_seconds'])
45        workout.duration = duration
46        # create a time object first using the given hours and minutes
47        start_time = time(form.data['start_time'][0],
48                          form.data['start_time'][1])
49        # combine the given start date with the built time object
50        start = datetime.combine(form.data['start_date'], start_time,
51                                 tzinfo=timezone.utc)
52        workout.start = start
53        context.add_workout(workout)
54        return HTTPFound(location=request.resource_url(workout))
55
56    return {
57        'form': FormRenderer(form)
58    }
59
60
61@view_config(
62    context=User,
63    permission='edit',
64    name='add-workout',
65    renderer='ow:templates/add_workout.pt')
66def add_workout(context, request):
67    """
68    Add a workout uploading a tracking file
69    """
70    # if not given a file there is an empty byte in POST, which breaks
71    # our blob storage validator.
72    # dirty fix until formencode fixes its api.is_empty method
73    if isinstance(request.POST.get('tracking_file', None), bytes):
74        request.POST['tracking_file'] = ''
75
76    form = Form(request, schema=UploadedWorkoutSchema())
77
78    if 'submit' in request.POST and form.validate():
79        # Grab some information from the tracking file
80        trackfile_ext = request.POST['tracking_file'].filename.split('.')[-1]
81        # Create a Workout instance based on the input from the form
82        workout = form.bind(Workout())
83        # Add the type of tracking file
84        workout.tracking_filetype = trackfile_ext
85        # Add basic info gathered from the file
86        workout.load_from_file()
87        # Add the workout
88        context.add_workout(workout)
89        return HTTPFound(location=request.resource_url(workout))
90
91    return {
92        'form': FormRenderer(form)
93    }
94
95
96@view_config(
97    context=Workout,
98    permission='edit',
99    name='edit',
100    renderer='ow:templates/edit_manual_workout.pt')
101def edit_workout(context, request):
102    """
103    Edit manually an existing workout. This won't let users attach/update
104    tracking files, just manually edit of the values.
105    """
106    form = Form(request, schema=ManualWorkoutSchema(), obj=context)
107    if 'submit' in request.POST and form.validate():
108        # exclude the three duration_* and start_* fields, so they won't be
109        # "bind" to the object, we do calculate both the full duration in
110        # seconds and the full datetime "start" and we save that
111        excluded = ['duration_hours', 'duration_minutes', 'duration_seconds',
112                    'start_date', 'start_time']
113
114        form.bind(context, exclude=excluded)
115
116        duration = timedelta(hours=form.data['duration_hours'],
117                             minutes=form.data['duration_minutes'],
118                             seconds=form.data['duration_seconds'])
119        context.duration = duration
120
121        # create a time object first using the given hours and minutes
122        start_time = time(form.data['start_time'][0],
123                          form.data['start_time'][1])
124        # combine the given start date with the built time object
125        start = datetime.combine(form.data['start_date'], start_time,
126                                 tzinfo=timezone.utc)
127        context.start = start
128        # ensure distance is a decimal
129        context.distance = Decimal(context.distance)
130        catalog = get_catalog(context)
131        reindex_object(catalog, context)
132        return HTTPFound(location=request.resource_url(context))
133
134    # round some values before rendering
135    if form.data['distance']:
136        form.data['distance'] = round(form.data['distance'], 2)
137
138    return {
139        'form': FormRenderer(form)
140    }
141
142
143@view_config(
144    context=Workout,
145    permission='edit',
146    name='update-from-file',
147    renderer='ow:templates/update_workout_from_file.pt')
148def update_workout_from_file(context, request):
149    # if not given a file there is an empty byte in POST, which breaks
150    # our blob storage validator.
151    # dirty fix until formencode fixes its api.is_empty method
152    if isinstance(request.POST.get('tracking_file', None), bytes):
153        request.POST['tracking_file'] = ''
154
155    form = Form(request, schema=UpdateWorkoutSchema())
156    if 'submit' in request.POST and form.validate():
157        # Grab some information from the tracking file
158        trackfile_ext = request.POST['tracking_file'].filename.split('.')[-1]
159        # Update the type of tracking file
160        context.tracking_filetype = trackfile_ext
161        form.bind(context)
162        # Override basic info gathered from the file
163        context.load_from_file()
164        catalog = get_catalog(context)
165        reindex_object(catalog, context)
166        return HTTPFound(location=request.resource_url(context))
167    return {
168        'form': FormRenderer(form)
169    }
170
171
172@view_config(
173    context=Workout,
174    permission='delete',
175    name='delete',
176    renderer='ow:templates/delete_workout.pt')
177def delete_workout(context, request):
178    """
179    Delete a workout
180    """
181    if 'submit' in request.POST:
182        if request.POST.get('delete', None) == 'yes':
183            catalog = get_catalog(context)
184            remove_from_catalog(catalog, context)
185            del request.root[request.authenticated_userid][context.workout_id]
186            return HTTPFound(location=request.resource_url(request.root))
187    return {}
188
189
190@view_config(
191    context=Workout,
192    permission='view',
193    renderer='ow:templates/workout.pt')
194def workout(context, request):
195    """
196    Details page for a workout
197    """
198    start_point = {}
199    if context.has_gpx:
200        with context.tracking_file.open() as gpx_file:
201            gpx_contents = gpx_file.read()
202            gpx_contents = gpx_contents.decode('utf-8')
203            gpx = gpxpy.parse(gpx_contents)
204            if gpx.tracks:
205                track = gpx.tracks[0]
206                center_point = track.get_center()
207                start_point = {'latitude': center_point.latitude,
208                               'longitude': center_point.longitude,
209                               'elevation': center_point.elevation}
210    return {'start_point': start_point}
211
212
213@view_config(
214    context=Workout,
215    name='gpx')
216def workout_gpx(context, request):
217    """
218    Return a gpx file with the workout tracking information, if any.
219    For now, simply return the gpx file if it has been attached to the
220    workout.
221
222    This view requires no permission, as we access it from an non-authenticated
223    request in a separate job, to generate the static map screenshot.
224    """
225    if not context.has_gpx:
226        return HTTPNotFound()
227    # Generate a proper file name to suggest on the download
228    gpx_slug = slugify(context.title) + '.gpx'
229    return Response(
230        content_type='application/xml',
231        content_disposition='attachment; filename="%s"' % gpx_slug,
232        body_file=context.tracking_file.open())
233
234
235@view_config(
236    context=Workout,
237    name='map',
238    renderer='ow:templates/workout-map.pt')
239def workout_map(context, request):
240    """
241    Render a page that has only a map with tracking info.
242    This view requires no permission, as we access it from an non-authenticated
243    request in a separate job, to generate the static map screenshot.
244    """
245    start_point = {}
246    if context.has_gpx:
247        with context.tracking_file.open() as gpx_file:
248            gpx_contents = gpx_file.read()
249            gpx_contents = gpx_contents.decode('utf-8')
250            gpx = gpxpy.parse(gpx_contents)
251            if gpx.tracks:
252                track = gpx.tracks[0]
253                center_point = track.get_center()
254                start_point = {'latitude': center_point.latitude,
255                               'longitude': center_point.longitude,
256                               'elevation': center_point.elevation}
257    return {'start_point': start_point}
258
259
260@view_config(
261    context=Workout,
262    permission='edit',
263    name='map-shot')
264def workout_map_shot(context, request):
265    """
266    Ask for the screenshot of a map, creating one if it does not exist.
267    A json object is returned, containing the info for the needed screenshot
268    """
269    if context.map_screenshot is None:
270        save_map_screenshot(context, request)
271
272    info = {'url': request.static_url(context.map_screenshot)}
273    return Response(content_type='application/json',
274                    charset='utf-8',
275                    body=json.dumps(info))
Note: See TracBrowser for help on using the repository browser.