Changeset 2f8a48f in OpenWorkouts-current


Ignore:
Timestamp:
Jan 25, 2019, 12:38:55 AM (5 years ago)
Author:
borja <borja@…>
Branches:
current, feature/docs, master
Children:
421f05f
Parents:
6d1b54b
Message:

(#7) Added several methods to the User model to gather some stats (yearly,

monthly, weekly).

Added two new utilities:

  • timedelta_to_hms (so we can print timedelta objects properly in template code)
  • get_week_days (returns a list of datetime objects for the days in the same week as a given day)

Added a template_helpers module, containing code that affects template
rendering.

Added timedelta_to_hms as a global to the default template rendering context

Refactored some code in the Workout model so it uses timedelta_to_hms instead
of running the same code twice.

Location:
ow
Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • ow/models/user.py

    r6d1b54b r2f8a48f  
    1 
     1from decimal import Decimal
     2from datetime import datetime, timedelta, timezone
    23from uuid import uuid1
    34from operator import attrgetter
     
    89
    910from ow.catalog import get_catalog, reindex_object
     11from ow.utilities import get_week_days
    1012
    1113
     
    136138            tree[year][month][sport] += 1
    137139        return tree
     140
     141    def stats(self, year=None, month=None):
     142        year = year or datetime.now().year
     143        stats = {
     144            'workouts': 0,
     145            'time': timedelta(seconds=0),
     146            'distance': Decimal(0),
     147            'elevation': Decimal(0),
     148            'sports': {}
     149        }
     150
     151        for workout in self.workouts(year=year, month=month):
     152            stats['workouts'] += 1
     153            stats['time'] += workout.duration or timedelta(seconds=0)
     154            stats['distance'] += workout.distance or Decimal(0)
     155            stats['elevation'] += workout.uphill or Decimal(0)
     156
     157            if workout.sport not in stats['sports']:
     158                stats['sports'][workout.sport] = {
     159                    'workouts': 0,
     160                    'time': timedelta(seconds=0),
     161                    'distance': Decimal(0),
     162                    'elevation': Decimal(0),
     163                }
     164
     165            stats['sports'][workout.sport]['workouts'] += 1
     166            stats['sports'][workout.sport]['time'] += (
     167                workout.duration or timedelta(0))
     168            stats['sports'][workout.sport]['distance'] += (
     169                workout.distance or Decimal(0))
     170            stats['sports'][workout.sport]['elevation'] += (
     171                workout.uphill or Decimal(0))
     172
     173        return stats
     174
     175    def get_week_stats(self, day):
     176        """
     177        Return some stats for the week the given day is in.
     178        """
     179        week = get_week_days(day)
     180
     181        # filter workouts
     182        workouts = []
     183        for workout in self.workouts():
     184            if week[0].date() <= workout.start.date() <= week[-1].date():
     185                workouts.append(workout)
     186
     187        # build stats
     188        stats = {}
     189        for week_day in week:
     190            stats[week_day] = {
     191                'workouts': 0,
     192                'time': timedelta(0),
     193                'distance': Decimal(0),
     194                'elevation': Decimal(0),
     195                'sports': {}
     196            }
     197            for workout in workouts:
     198                if workout.start.date() == week_day.date():
     199                    day = stats[week_day]  # less typing, avoid long lines
     200                    day['workouts'] += 1
     201                    day['time'] += workout.duration or timedelta(seconds=0)
     202                    day['distance'] += workout.distance or Decimal(0)
     203                    day['elevation'] += workout.uphill or Decimal(0)
     204                    if workout.sport not in day['sports']:
     205                        day['sports'][workout.sport] = {
     206                            'workouts': 0,
     207                            'time': timedelta(seconds=0),
     208                            'distance': Decimal(0),
     209                            'elevation': Decimal(0),
     210                        }
     211                    day['sports'][workout.sport]['workouts'] += 1
     212                    day['sports'][workout.sport]['time'] += (
     213                        workout.duration or timedelta(0))
     214                    day['sports'][workout.sport]['distance'] += (
     215                        workout.distance or Decimal(0))
     216                    day['sports'][workout.sport]['elevation'] += (
     217                        workout.uphill or Decimal(0))
     218
     219        return stats
     220
     221    @property
     222    def week_stats(self):
     223        """
     224        Helper that returns the week stats for the current week
     225        """
     226        return self.get_week_stats(datetime.now(timezone.utc))
     227
     228    @property
     229    def week_totals(self):
     230        week_stats = self.week_stats
     231        return {
     232            'distance': sum([week_stats[t]['distance'] for t in week_stats]),
     233            'time': sum([week_stats[t]['time'] for t in week_stats],
     234                        timedelta())
     235        }
  • ow/models/workout.py

    r6d1b54b r2f8a48f  
    1313    create_blob,
    1414    mps_to_kmph,
    15     save_map_screenshot
     15    save_map_screenshot,
     16    timedelta_to_hms
    1617)
    1718
     
    110111
    111112    def split_duration(self):
    112         hours, remainder = divmod(int(self.duration.total_seconds()), 3600)
    113         minutes, seconds = divmod(remainder, 60)
    114         return hours, minutes, seconds
     113        return timedelta_to_hms(self.duration)
    115114
    116115    @property
  • ow/tests/models/test_user.py

    r6d1b54b r2f8a48f  
     1from decimal import Decimal
    12from datetime import datetime, timedelta, timezone
    23
     
    118119                   11: {'cycling': 1, 'running': 1}}
    119120        }
     121
     122    def test_stats(self, root):
     123        expected_no_stats = {
     124            'workouts': 0,
     125            'time': timedelta(seconds=0),
     126            'distance': Decimal(0),
     127            'elevation': Decimal(0),
     128            'sports': {}
     129        }
     130        # no stats
     131        assert root['john'].stats() == expected_no_stats
     132        # add a cycling workout
     133        workout = Workout(
     134            start=datetime(2018, 11, 25, 10, 00, tzinfo=timezone.utc),
     135            duration=timedelta(minutes=(60*4)),
     136            distance=115,
     137            sport='cycling')
     138        root['john'].add_workout(workout)
     139        # asking for a different year, future
     140        assert root['john'].stats(2019) == expected_no_stats
     141        # asking for a different year, past
     142        assert root['john'].stats(2016) == expected_no_stats
     143        # asking fot the year the workout is in
     144        assert root['john'].stats(2018) == {
     145            'workouts': 1,
     146            'time': timedelta(minutes=(60*4)),
     147            'distance': Decimal(115),
     148            'elevation': Decimal(0),
     149            'sports': {
     150                'cycling': {
     151                    'workouts': 1,
     152                    'time': timedelta(minutes=(60*4)),
     153                    'distance': Decimal(115),
     154                    'elevation': Decimal(0),
     155                }
     156            }
     157        }
     158        # add a second cycling workout
     159        workout = Workout(
     160            start=datetime(2018, 11, 26, 10, 00, tzinfo=timezone.utc),
     161            duration=timedelta(minutes=(60*3)),
     162            distance=100,
     163            sport='cycling')
     164        root['john'].add_workout(workout)
     165        assert root['john'].stats(2018) == {
     166            'workouts': 2,
     167            'time': timedelta(minutes=(60*7)),
     168            'distance': Decimal(215),
     169            'elevation': Decimal(0),
     170            'sports': {
     171                'cycling': {
     172                    'workouts': 2,
     173                    'time': timedelta(minutes=(60*7)),
     174                    'distance': Decimal(215),
     175                    'elevation': Decimal(0),
     176                }
     177            }
     178        }
     179        # add a running workout
     180        workout = Workout(
     181            start=datetime(2018, 11, 26, 16, 00, tzinfo=timezone.utc),
     182            duration=timedelta(minutes=(60)),
     183            distance=10,
     184            sport='running')
     185        root['john'].add_workout(workout)
     186        assert root['john'].stats(2018) == {
     187            'workouts': 3,
     188            'time': timedelta(minutes=(60*8)),
     189            'distance': Decimal(225),
     190            'elevation': Decimal(0),
     191            'sports': {
     192                'cycling': {
     193                    'workouts': 2,
     194                    'time': timedelta(minutes=(60*7)),
     195                    'distance': Decimal(215),
     196                    'elevation': Decimal(0),
     197                },
     198                'running': {
     199                    'workouts': 1,
     200                    'time': timedelta(minutes=(60)),
     201                    'distance': Decimal(10),
     202                    'elevation': Decimal(0),
     203                }
     204            }
     205        }
     206        # ensure the stats for future/past years did not change after
     207        # adding those workouts
     208        assert root['john'].stats(2019) == expected_no_stats
     209        assert root['john'].stats(2016) == expected_no_stats
     210
     211    def test_get_week_stats(self, root):
     212        expected_no_stats_per_day = {
     213            'workouts': 0,
     214            'time': timedelta(0),
     215            'distance': Decimal(0),
     216            'elevation': Decimal(0),
     217            'sports': {}
     218        }
     219
     220        expected_no_stats = {}
     221        for i in range(19, 26):
     222            day = datetime(2018, 11, i, 10, 00, tzinfo=timezone.utc)
     223            expected_no_stats[day] = expected_no_stats_per_day
     224
     225        day = datetime(2018, 11, 25, 10, 00, tzinfo=timezone.utc)
     226        assert root['john'].get_week_stats(day) == expected_no_stats
     227
     228        # add a cycling workout
     229        workout = Workout(
     230            start=datetime(2018, 11, 25, 10, 00, tzinfo=timezone.utc),
     231            duration=timedelta(minutes=(60*4)),
     232            distance=115,
     233            sport='cycling')
     234        root['john'].add_workout(workout)
     235
     236        # check a week in the future
     237        day = datetime(2019, 11, 25, 10, 00, tzinfo=timezone.utc)
     238        week_stats = root['john'].get_week_stats(day)
     239        for day in week_stats:
     240            assert week_stats[day] == expected_no_stats_per_day
     241
     242        # check a week in the past
     243        day = datetime(2017, 11, 25, 10, 00, tzinfo=timezone.utc)
     244        week_stats = root['john'].get_week_stats(day)
     245        for day in week_stats:
     246            assert week_stats[day] == expected_no_stats_per_day
     247
     248        # Check the week where the workout is
     249        day = datetime(2018, 11, 25, 10, 00, tzinfo=timezone.utc)
     250        week_stats = root['john'].get_week_stats(day)
     251        for day in week_stats:
     252            if day.day == 25:
     253                # this is the day where we have a workout
     254                assert week_stats[day] == {
     255                    'workouts': 1,
     256                    'time': timedelta(minutes=(60*4)),
     257                    'distance': Decimal(115),
     258                    'elevation': Decimal(0),
     259                    'sports': {
     260                        'cycling': {
     261                            'workouts': 1,
     262                            'time': timedelta(minutes=(60*4)),
     263                            'distance': Decimal(115),
     264                            'elevation': Decimal(0)
     265                        }
     266                    }
     267                }
     268            else:
     269                # day without workout
     270                assert week_stats[day] == expected_no_stats_per_day
     271
     272        # add a second cycling workout
     273        workout = Workout(
     274            start=datetime(2018, 11, 23, 10, 00, tzinfo=timezone.utc),
     275            duration=timedelta(minutes=(60*3)),
     276            distance=100,
     277            sport='cycling')
     278        root['john'].add_workout(workout)
     279        day = datetime(2018, 11, 25, 10, 00, tzinfo=timezone.utc)
     280        week_stats = root['john'].get_week_stats(day)
     281        for day in week_stats:
     282            if day.day == 25:
     283                # this is the day where we have a workout
     284                assert week_stats[day] == {
     285                    'workouts': 1,
     286                    'time': timedelta(minutes=(60*4)),
     287                    'distance': Decimal(115),
     288                    'elevation': Decimal(0),
     289                    'sports': {
     290                        'cycling': {
     291                            'workouts': 1,
     292                            'time': timedelta(minutes=(60*4)),
     293                            'distance': Decimal(115),
     294                            'elevation': Decimal(0)
     295                        }
     296                    }
     297                }
     298            elif day.day == 23:
     299                # this is the day where we have a workout
     300                assert week_stats[day] == {
     301                    'workouts': 1,
     302                    'time': timedelta(minutes=(60*3)),
     303                    'distance': Decimal(100),
     304                    'elevation': Decimal(0),
     305                    'sports': {
     306                        'cycling': {
     307                            'workouts': 1,
     308                            'time': timedelta(minutes=(60*3)),
     309                            'distance': Decimal(100),
     310                            'elevation': Decimal(0)
     311                        }
     312                    }
     313                }
     314            else:
     315                # day without workout
     316                assert week_stats[day] == expected_no_stats_per_day
     317
     318    def test_week_stats(self, root):
     319        expected_no_stats_per_day = {
     320            'workouts': 0,
     321            'time': timedelta(0),
     322            'distance': Decimal(0),
     323            'elevation': Decimal(0),
     324            'sports': {}
     325        }
     326
     327        # no workouts for the current week (this tests is for coverage
     328        # purposes mostly, as the main logic is tested in test_get_week_stats)
     329        day = datetime.now(timezone.utc)
     330        week_stats = root['john'].get_week_stats(day)
     331        for day in week_stats:
     332            assert week_stats[day] == expected_no_stats_per_day
     333
     334    def test_week_totals(self, root):
     335        # no data, empty totals
     336        assert root['john'].week_totals == {
     337            'distance': Decimal(0),
     338            'time': timedelta(0)
     339        }
  • ow/tests/test_utilities.py

    r6d1b54b r2f8a48f  
    11import os
    2 from datetime import timedelta
     2from datetime import timedelta, datetime
    33from unittest.mock import patch
    44from pyexpat import ExpatError
     
    2222    mps_to_kmph,
    2323    kmph_to_mps,
    24     save_map_screenshot
     24    save_map_screenshot,
     25    timedelta_to_hms,
     26    get_week_days
    2527)
    2628
     
    144146        assert not os.makedirs.called
    145147        subprocess.run.assert_called_once
     148
     149    def test_timedelta_to_hms(self):
     150        value = timedelta(seconds=0)
     151        assert timedelta_to_hms(value) == (0, 0, 0)
     152        value = timedelta(seconds=3600)
     153        assert timedelta_to_hms(value) == (1, 0, 0)
     154        value = timedelta(seconds=3900)
     155        assert timedelta_to_hms(value) == (1, 5, 0)
     156        value = timedelta(seconds=3940)
     157        assert timedelta_to_hms(value) == (1, 5, 40)
     158        value = timedelta(seconds=4)
     159        assert timedelta_to_hms(value) == (0, 0, 4)
     160        value = timedelta(seconds=150)
     161        assert timedelta_to_hms(value) == (0, 2, 30)
     162        # try now something that is not a timedelta
     163        with pytest.raises(AttributeError):
     164            timedelta_to_hms('not a timedelta')
     165
     166    def test_week_days(self):
     167        # get days from a monday, week starting on monday
     168        days = get_week_days(datetime(2019, 1, 21))
     169        assert len(days) == 7
     170        matches = [
     171            [days[0], datetime(2019, 1, 21)],
     172            [days[1], datetime(2019, 1, 22)],
     173            [days[2], datetime(2019, 1, 23)],
     174            [days[3], datetime(2019, 1, 24)],
     175            [days[4], datetime(2019, 1, 25)],
     176            [days[5], datetime(2019, 1, 26)],
     177            [days[6], datetime(2019, 1, 27)]
     178        ]
     179        for m in matches:
     180            assert m[0] == m[1]
     181        # get days from a wednesday, week starting on monday
     182        days = get_week_days(datetime(2019, 1, 23))
     183        assert len(days) == 7
     184        matches = [
     185            [days[0], datetime(2019, 1, 21)],
     186            [days[1], datetime(2019, 1, 22)],
     187            [days[2], datetime(2019, 1, 23)],
     188            [days[3], datetime(2019, 1, 24)],
     189            [days[4], datetime(2019, 1, 25)],
     190            [days[5], datetime(2019, 1, 26)],
     191            [days[6], datetime(2019, 1, 27)]
     192        ]
     193        for m in matches:
     194            assert m[0] == m[1]
     195        # get days from a monday, but week starting on sunday now
     196        days = get_week_days(datetime(2019, 1, 21), start_day=0)
     197        assert len(days) == 7
     198        matches = [
     199            [days[0], datetime(2019, 1, 20)],
     200            [days[1], datetime(2019, 1, 21)],
     201            [days[2], datetime(2019, 1, 22)],
     202            [days[3], datetime(2019, 1, 23)],
     203            [days[4], datetime(2019, 1, 24)],
     204            [days[5], datetime(2019, 1, 25)],
     205            [days[6], datetime(2019, 1, 26)]
     206        ]
     207        for m in matches:
     208            assert m[0] == m[1]
    146209
    147210
  • ow/utilities.py

    r6d1b54b r2f8a48f  
    33import logging
    44import subprocess
    5 from datetime import datetime
     5from datetime import datetime, timedelta
    66from decimal import Decimal
    77from shutil import copyfileobj
     
    212212
    213213    return False
     214
     215
     216def timedelta_to_hms(value):
     217    """
     218    Return hours, minutes, seconds from a timedelta object
     219    """
     220    hours, remainder = divmod(int(value.total_seconds()), 3600)
     221    minutes, seconds = divmod(remainder, 60)
     222    return hours, minutes, seconds
     223
     224
     225def get_week_days(day, start_day=1):
     226    """
     227    Return a list of datetime objects for the days of the week "day" is in.
     228
     229    day is a datetime object (like in datetime.now() for "today")
     230
     231    start_day can be used to set if week starts on monday (1) or sunday (0)
     232    """
     233    first_day = day - timedelta(days=day.isoweekday() - start_day)
     234    week_days = [first_day + timedelta(days=i) for i in range(7)]
     235    return week_days
Note: See TracChangeset for help on using the changeset viewer.