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
RevLine 
[5bdfbfb]1from decimal import Decimal
[5ec3a0b]2from datetime import datetime, timedelta, time, timezone
[b3374f6]3import json
[5ec3a0b]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
[b3374f6]20from ..utilities import slugify, save_map_screenshot
[5ec3a0b]21from ..catalog import get_catalog, reindex_object, remove_from_catalog
22
23
24@view_config(
25    context=User,
[78af3d1]26    permission='edit',
[5ec3a0b]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,
[78af3d1]63    permission='edit',
[5ec3a0b]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    """
[74b9c4d]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
[5ec3a0b]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,
[78af3d1]98    permission='edit',
[5ec3a0b]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
[5bdfbfb]128        # ensure distance is a decimal
129        context.distance = Decimal(context.distance)
[5ec3a0b]130        catalog = get_catalog(context)
131        reindex_object(catalog, context)
132        return HTTPFound(location=request.resource_url(context))
133
[fb327e1]134    # round some values before rendering
135    if form.data['distance']:
136        form.data['distance'] = round(form.data['distance'], 2)
137
[5ec3a0b]138    return {
139        'form': FormRenderer(form)
140    }
141
142
143@view_config(
144    context=Workout,
[78af3d1]145    permission='edit',
[5ec3a0b]146    name='update-from-file',
147    renderer='ow:templates/update_workout_from_file.pt')
148def update_workout_from_file(context, request):
[74b9c4d]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
[5ec3a0b]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,
[78af3d1]174    permission='delete',
[5ec3a0b]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,
[78af3d1]192    permission='view',
[5ec3a0b]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.
[78af3d1]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.
[5ec3a0b]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())
[d1c4782]233
234
235@view_config(
236    context=Workout,
237    name='map',
238    renderer='ow:templates/workout-map.pt')
239def workout_map(context, request):
240    """
[78af3d1]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.
[d1c4782]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}
[b3374f6]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.