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

current
Last change on this file was 42baca4, checked in by Borja Lopez <borja@…>, 5 years ago

(#39) Do not allow duplicated workouts by default when uploading track files.
We still allow users to add duplicates if they want, by checking a checkbox
we show in the upload workout form when we find a possible duplicate.

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