Changes in / [6993c72:bf01534] in OpenWorkouts-current


Ignore:
Files:
11 edited

Legend:

Unmodified
Added
Removed
  • README.txt

    r6993c72 rbf01534  
    22============
    33
    4 TBW, if you look for installation instructions, take a look at the script
    5 bin/install
     4Getting Started
     5---------------
     6
     7- Change directory into your newly created project.
     8
     9    cd ow
     10
     11- Create a Python virtual environment.
     12
     13    python3 -m venv env
     14
     15- Upgrade packaging tools.
     16
     17    env/bin/pip install --upgrade pip setuptools
     18
     19- Install the project in editable mode with its testing requirements.
     20
     21    env/bin/pip install -e ".[testing]"
     22
     23- Run your project's tests.
     24
     25    env/bin/pytest
     26
     27- Run your project.
     28
     29    env/bin/pserve development.ini
  • bin/install

    r6993c72 rbf01534  
    5858install_openworkouts() {
    5959    . ${env_path}/bin/activate
    60     yes | pip install --upgrade -e ${current}[testing]
     60    yes | pip install --upgrade --process-dependency-links -e ${current}[testing]
    6161    deactivate
    6262}
  • ow/models/user.py

    r6993c72 rbf01534  
    99
    1010from ow.catalog import get_catalog, reindex_object
    11 from ow.utilities import get_week_days, get_month_week_number
     11from ow.utilities import get_week_days
    1212
    1313
     
    7777        reindex_object(catalog, workout)
    7878
    79     def workouts(self, year=None, month=None, week=None):
     79    def workouts(self, year=None, month=None):
    8080        """
    8181        Return this user workouts, sorted by date, from newer to older
     
    8484        if year:
    8585            workouts = [w for w in workouts if w.start.year == year]
    86             if month:
    87                 workouts = [w for w in workouts if w.start.month == month]
    88             if week:
    89                 week = int(week)
    90                 workouts = [
    91                     w for w in workouts if w.start.isocalendar()[1] == week]
     86        if month:
     87            workouts = [w for w in workouts if w.start.month == month]
    9288        workouts = sorted(workouts, key=attrgetter('start'))
    9389        workouts.reverse()
     
    292288
    293289        return stats
    294 
    295     @property
    296     def weekly_year_stats(self):
    297         """
    298         Return per-week stats for the last 12 months
    299         """
    300         # set the boundaries for looking for workouts afterwards,
    301         # we need the current date as the "end date" and one year
    302         # ago from that date. Then we set the start at the first
    303         # day of that month.
    304         end = datetime.now(timezone.utc)
    305         start = (end - timedelta(days=365)).replace(day=1)
    306 
    307         stats = {}
    308 
    309         # first initialize the stats dict
    310         for days in range((end - start).days):
    311             day = (start + timedelta(days=days)).date()
    312             week = day.isocalendar()[1]
    313             month_week = get_month_week_number(day)
    314             key = (day.year, day.month, week, month_week)
    315             if key not in stats.keys():
    316                 stats[key] = {
    317                     'workouts': 0,
    318                     'time': timedelta(0),
    319                     'distance': Decimal(0),
    320                     'elevation': Decimal(0),
    321                     'sports': {}
    322                 }
    323 
    324         # now loop over the workouts, filtering and then adding stats
    325         # to the proper place
    326         for workout in self.workouts():
    327             if start.date() <= workout.start.date() <= end.date():
    328                 # less typing, avoid long lines
    329                 start_date = workout.start.date()
    330                 week = start_date.isocalendar()[1]
    331                 month_week = get_month_week_number(start_date)
    332                 week = stats[(start_date.year,
    333                               start_date.month,
    334                               week,
    335                               month_week)]
    336 
    337                 week['workouts'] += 1
    338                 week['time'] += workout.duration or timedelta(seconds=0)
    339                 week['distance'] += workout.distance or Decimal(0)
    340                 week['elevation'] += workout.uphill or Decimal(0)
    341                 if workout.sport not in week['sports']:
    342                     week['sports'][workout.sport] = {
    343                         'workouts': 0,
    344                         'time': timedelta(seconds=0),
    345                         'distance': Decimal(0),
    346                         'elevation': Decimal(0),
    347                     }
    348                 week['sports'][workout.sport]['workouts'] += 1
    349                 week['sports'][workout.sport]['time'] += (
    350                     workout.duration or timedelta(0))
    351                 week['sports'][workout.sport]['distance'] += (
    352                     workout.distance or Decimal(0))
    353                 week['sports'][workout.sport]['elevation'] += (
    354                     workout.uphill or Decimal(0))
    355 
    356         return stats
  • ow/static/css/main.css

    r6993c72 rbf01534  
    10061006<a class="login-remember" href="#">Forgot your password?</a>
    10071007*/
    1008 .user-profile .workout-options {
    1009   font-size: 13px;
    1010   font-size: 0.8125rem;
    1011 }
    1012 .user-profile-account {
    1013   background-color: #fbfbfb;
    1014   padding: 2em  1em;
    1015 }
    1016 @media (min-width: 480px) {
    1017   .user-profile-account {
    1018     padding: 2em 6em;
    1019   }
    1020 }
    1021 .user-profile-account img {
     1008.user-profile img {
    10221009  width: 140px;
    10231010  height: 140px;
     
    10261013  margin-bottom: 0.5em;
    10271014}
     1015.user-profile .workout-options {
     1016  font-size: 13px;
     1017  font-size: 0.8125rem;
     1018}
     1019.user-profile-account {
     1020  background-color: #fbfbfb;
     1021  padding: 2em  1em;
     1022}
     1023@media (min-width: 480px) {
     1024  .user-profile-account {
     1025    padding: 2em 6em;
     1026  }
     1027}
    10281028.user-profile-account h2 {
    10291029  margin: 0 0 0.15em 0;
     
    10401040  color: #959595;
    10411041}
    1042 .total-workouts {
    1043   font-size: 13px;
    1044   font-size: 0.8125rem;
     1042.user-profile-account .workouts {
     1043  font-size: 18px;
     1044  font-size: 1.125rem;
    10451045  font-weight: bold;
    10461046}
  • ow/static/js/ow.js

    r6993c72 rbf01534  
    244244    var chart_selector = spec.chart_selector,
    245245        filters_selector = spec.filters_selector,
    246         switcher_selector = spec.switcher_selector,
    247         urls = spec.urls,
     246        url = spec.url,
    248247        current_month = spec.current_month,
    249         current_week = spec.current_week,
    250         y_axis_labels = spec.y_axis_labels,
    251         filter_by = spec.filter_by,
    252         url = spec.url;
     248        y_axis_labels = spec.y_axis_labels;
    253249
    254250    // Helpers
     
    268264    };
    269265
    270     function get_name_for_x(d) {
    271         if (d.week == undefined || d.week == 0) {
    272             return d.name;
    273         }
    274         else {
    275             return d.id.split('-')[2];
    276         }
    277     }
    278 
    279266    // Methods
    280267    var filters_setup = function filters_setup() {
    281268        $(filters_selector).on('click', function(e) {
     269            var filter_by = 'distance';
    282270            e.preventDefault();
    283271            filter_by = $(this).attr('class').split('-')[1]
    284272            var chart = d3.select(chart_selector);
    285273            chart.selectAll("*").remove();
    286             render(filter_by, url);
    287         });
    288     };
    289 
    290     var switcher_setup = function switcher_setup() {
    291         $(switcher_selector).on('click', function(e) {
    292             e.preventDefault();
    293             url = $(this).attr('class').split('-')[1]
    294             var chart = d3.select(chart_selector);
    295             chart.selectAll("*").remove();
    296             render(filter_by, url);
    297         });
    298     };
    299 
    300     var render = function render(filter_by, url) {
     274            render(filter_by);
     275        });
     276    };
     277
     278    var render = function render(filter_by) {
    301279        /*
    302280          Build a d3 bar chart, populated with data from the given url.
     
    310288            y = d3.scaleLinear().rangeRound([height, 0]);
    311289
    312         d3.json(urls[url]).then(function (data) {
     290        d3.json(url).then(function (data) {
    313291            x.domain(data.map(function (d) {
    314                 return get_name_for_x(d);
    315                 // return d.name;
     292                return d.name;
    316293            }));
    317294
     
    339316                .enter().append("rect")
    340317                .attr("class", function(d) {
    341                     var sel_week = current_month + '-' + current_week;
    342                     if (d.id == current_month || d.id == sel_week){
    343                         /* Bar for the currently selected month or week */
     318                    if (d.id == current_month){
    344319                        select_x_axis_label(d).attr('style', "font-weight: bold;");
    345                         return 'bar current';
     320                        return 'bar current'
    346321                    }
    347322                    else {
    348                         if (!current_week && d.id.indexOf(current_month) >=0 ) {
    349                             /*
    350                                User selected a month, then switched to weekly
    351                                view, we do highlight all the bars for weeks in
    352                                that month
    353                             */
    354                             select_x_axis_label(d).attr('style', "font-weight: bold;");
    355                             return 'bar current';
    356                         }
    357                         else {
    358                             /* Non-selected bar */
    359                             return 'bar';
    360                         }
    361 
     323                        return 'bar'
    362324                    }
    363325                })
    364326                .attr("x", function (d) {
    365                     return x(get_name_for_x(d));
     327                    return x(d.name);
    366328                })
    367329                .attr("y", function (d) {
     
    381343                        select_x_axis_label(d).attr('style', "font-weight: regular;");
    382344                    }
    383                 })
    384                 .on('click', function(d) {
    385                     window.location.href = d.url;
    386345                });
    387346
    388             if (url == 'monthly') {
    389                 g.selectAll(".text")
    390                     .data(data)
    391                     .enter()
    392                     .append("text")
    393                     .attr("class","label")
    394                     .attr("x", function (d) {
    395                         return x(get_name_for_x(d)) + x.bandwidth()/2;
    396                     })
    397                     .attr("y", function (d) {
    398                         /*
    399                           Get the value for the current bar, then get the maximum
    400                           value to be displayed in the bar, which is used to
    401                           calculate the proper position of the label for this bar,
    402                           relatively to its height (1% above the bar)
    403                         */
    404                         var value = get_y_value(d, filter_by);
    405                         var max = y.domain()[1];
    406                         return y(value + y.domain()[1] * 0.01);
    407                     })
    408                     .text(function(d) {
    409                         var value = get_y_value(d, filter_by)
    410                         if ( value > 0) {
    411                             return value;
    412                         }
    413                     });
    414             }
    415 
    416             if (url == 'weekly') {
    417                 g.selectAll(".tick")
    418                     .each(function (d, i) {
    419                         /*
    420                           Remove from the x-axis tickets those without letters
    421                           on them (useful for the weekly chart)
    422                         */
    423                         if (d !== parseInt(d, 10)) {
    424                             if(!d.match(/[a-z]/i)) {
    425                                 this.remove();
    426                             }
    427                         }
    428                     });
    429             }
     347            g.selectAll(".text")
     348                .data(data)
     349                .enter()
     350                .append("text")
     351                .attr("class","label")
     352                .attr("x", function (d) {
     353                    return x(d.name) + x.bandwidth()/2;
     354                })
     355                .attr("y", function (d) {
     356                    /*
     357                      Get the value for the current bar, then get the maximum
     358                      value to be displayed in the bar, which is used to
     359                      calculate the proper position of the label for this bar,
     360                      relatively to its height (1% above the bar)
     361                     */
     362                    var value = get_y_value(d, filter_by);
     363                    var max = y.domain()[1];
     364                    return y(value + y.domain()[1] * 0.01);
     365                })
     366                .text(function(d) {
     367                    var value = get_y_value(d, filter_by)
     368                    if ( value > 0) {
     369                        return value;
     370                    }
     371                });
     372
    430373        });
    431374    };
     
    433376    var that = {}
    434377    that.filters_setup = filters_setup;
    435     that.switcher_setup = switcher_setup;
    436378    that.render = render;
    437379    return that
  • ow/static/less/pages/profile.less

    r6993c72 rbf01534  
    11.user-profile {
     2    img {
     3        width: 140px;
     4        height: 140px;
     5        object-fit: cover;
     6        border-radius: 50%;
     7        margin-bottom: .5em;
     8    }
    29    .workout-options {
    310        .font-size(13);
     
    1017    @media (min-width: @screen-s){
    1118        padding: 2em 6em;
    12     }
    13     img {
    14         width: 140px;
    15         height: 140px;
    16         object-fit: cover;
    17         border-radius: 50%;
    18         margin-bottom: .5em;
    1919    }
    2020        h2 {
     
    3030                }
    3131        }
    32 }
    33 .total-workouts {
    34         .font-size(13);
    35         font-weight: bold;
     32        .workouts {
     33                .font-size(18);
     34                font-weight: bold;
     35        }
    3636}
    3737
  • ow/templates/profile.pt

    r6993c72 rbf01534  
    2626          <tal:has-nickname tal:condition="context.nickname">
    2727              <tal:nickname tal:content="context.nickname"></tal:nickname>
    28           </tal:has-nickname> |
     28          </tal:has-nickname> | 
    2929          <span><tal:email tal:content="context.email"></tal:email></span>
    3030        </p>
     
    6060             i18n:translate="">change password</a></li>
    6161        </ul>
    62       </div>
    6362
    64       <div class="total-workouts">
    65         <tal:w tal:replace="context.num_workouts"></tal:w>
    66         <tal:t i18n:translate="">workouts</tal:t>
     63        <div class="workouts">
     64          <tal:w tal:replace="context.num_workouts"></tal:w>
     65          <tal:t i18n:translate="">Workouts</tal:t>
     66        </div>
    6767      </div>
    6868
    6969      <div class="month-stats js-month-stats">
    7070        <div class="svg-cotent">
    71           <svg width="800" height="180" viewBox="0 0 800 180"></svg>
     71          <svg width="600" height="300" viewBox="0 0 600 300"></svg>
    7272        </div>
    7373        <ul class="workout-options filters js-filters">
     
    7676          <li><a href="#" class="js-elevation" i18n:translate="">elevation</a></li>
    7777        </ul>
    78 
    79         <ul class="workout-options switcher js-switcher">
    80           <li><a href="#" class="js-weekly" i18n:translate="">weekly</a></li>
    81           <li><a href="#" class="js-monthly is-active" i18n:translate="">monthly</a></li>
    82         </ul>
    8378      </div>
    8479
     80      <div class="latest-workouts">
     81        <h3 i18n:translate="">Latest workouts</h3>
     82        <tal:r tal:repeat="workout context.workouts()[:5]">
     83          <div class="workout">
     84            <h4>
     85              <a href="" tal:content="workout.title"
     86               tal:attributes="href request.resource_url(workout)"></a>
     87            </h4>
     88            <span><tal:c tal:content="workout.sport"></tal:c></span>
     89            <p><tal:c tal:content="workout.start"></tal:c>,
     90            <tal:c tal:content="workout.duration"></tal:c>,
     91            <tal:c tal:content="workout.rounded_distance"></tal:c> km
     92            </p>
     93          </div>
     94        </tal:r>
     95      </div>
    8596
    86       <tal:r tal:repeat="workout workouts">
    87 
    88         <a name="workouts"></a>
    89 
    90 
    91         <article class="workout-resume">
    92 
    93           <h2 class="workout-title">
    94             <a href="" tal:content="workout.title"
    95                tal:attributes="href request.resource_url(workout)"></a>
    96           </h2>
    97 
    98           <ul class="workout-info">
    99             <li>
    100               <tal:c tal:content="workout.start_in_timezone(context.timezone)"></tal:c>
    101             </li>
    102             <li>
    103               <!--! use the properly formatted duration instead of the timedelta object -->
    104               <tal:c tal:content="workout._duration"></tal:c>
    105             </li>
    106             <li tal:condition="workout.distance">
    107               <tal:c tal:content="workout.rounded_distance"></tal:c> km
    108             </li>
    109           </ul>
    110 
    111           <ul class="workout-info" tal:define="hr workout.hr; cad workout.cad">
    112             <li tal:condition="hr">
    113               <span i18n:translate="">HR (bpm)</span>:
    114               <tal:c tal:content="hr['min']"></tal:c>
    115               <tal:t i18n:translate="">Min.</tal:t>,
    116               <tal:c tal:content="hr['avg']"></tal:c>
    117               <tal:t i18n:translate="">Avg.</tal:t>,
    118               <tal:c tal:content="hr['max']"></tal:c>
    119               <tal:t i18n:translate="">Max.</tal:t>
    120             </li>
    121             <li tal:condition="cad">
    122               <span i18n:translate="">Cad</span>:
    123               <tal:c tal:content="cad['min']"></tal:c>
    124               <tal:t i18n:translate="">Min.</tal:t>,
    125               <tal:c tal:content="cad['avg']"></tal:c>
    126               <tal:t i18n:translate="">Avg.</tal:t>,
    127               <tal:c tal:content="cad['max']"></tal:c>
    128               <tal:t i18n:translate="">Max.</tal:t>
    129             </li>
    130           </ul>
    131 
    132           <div class="workout-intro" tal:content="workout.notes"></div>
    133 
    134           <div class="workout-map" tal:condition="workout.has_gpx">
    135             <a href="" tal:attributes="href request.resource_url(workout)">
    136               <img src="" tal:attributes="src request.static_url(workout.map_screenshot);
    137                         alt workout.title; title workout.title">
    138             </a>
    139           </div>
    140 
    141         </article>
    142 
    143       </tal:r>
    14497    </div>
    14598
     
    161114         chart_selector: '.js-month-stats svg',
    162115         filters_selector: '.js-month-stats .js-filters a',
    163          switcher_selector: '.js-month-stats .js-switcher a',
    164          urls: {"monthly": "${request.resource_url(context, 'monthly')}",
    165                 "weekly": "${request.resource_url(context, 'weekly')}"},
     116         url: "${request.resource_url(context, 'yearly')}",
    166117         current_month: "${current_month}",
    167          current_week: "${current_week}",
    168118         y_axis_labels: y_axis_labels,
    169          filter_by: "distance",
    170          url: "${'monthly' if current_week is None else 'weekly'}",
    171119     });
    172      year_chart.render("distance", "${'monthly' if current_week is None else 'weekly'}");
     120     year_chart.render("distance");
    173121     year_chart.filters_setup();
    174      year_chart.switcher_setup();
    175122    </script>
    176123
  • ow/tests/views/test_user.py

    r6993c72 rbf01534  
    267267        """
    268268        request = dummy_request
    269         # profile page for the current day (no workouts avalable)
    270269        response = user_views.profile(john, request)
    271         assert len(response.keys()) == 3
     270        assert len(response.keys()) == 1
    272271        current_month = datetime.now(timezone.utc).strftime('%Y-%m')
    273272        assert response['current_month'] == current_month
    274         assert response['current_week'] is None
    275         assert response['workouts'] == []
    276         # profile page for a previous date, that has workouts
    277         request.GET['year'] = 2015
    278         request.GET['month'] = 8
    279         response = user_views.profile(john, request)
    280         assert len(response.keys()) == 3
    281         assert response['current_month'] == '2015-08'
    282         assert response['current_week'] is None
    283         assert response['workouts'] == john.workouts(2015, 8)
    284         # same, passing a week, first on a week without workouts
    285         request.GET['year'] = 2015
    286         request.GET['month'] = 8
    287         request.GET['week'] = 25
    288         response = user_views.profile(john, request)
    289         assert len(response.keys()) == 3
    290         assert response['current_month'] == '2015-08'
    291         assert response['current_week'] is 25
    292         assert response['workouts'] == []
    293         # now in a week with workoutss
    294         request.GET['year'] = 2015
    295         request.GET['month'] = 8
    296         request.GET['week'] = 26
    297         response = user_views.profile(john, request)
    298         assert len(response.keys()) == 3
    299         assert response['current_month'] == '2015-08'
    300         assert response['current_week'] is 26
    301         assert response['workouts'] == john.workouts(2015, 8)
    302273
    303274    def test_login_get(self, dummy_request):
  • ow/utilities.py

    r6993c72 rbf01534  
    22import os
    33import logging
    4 import calendar
    54import subprocess
    65from datetime import datetime, timedelta
     
    235234    week_days = [first_day + timedelta(days=i) for i in range(7)]
    236235    return week_days
    237 
    238 
    239 def get_month_week_number(day):
    240     """
    241     Given a datetime object (day), return the number of week the day is
    242     in the current month (week 1, 2, 3, etc)
    243     """
    244     weeks = calendar.monthcalendar(day.year, day.month)
    245     for week in weeks:
    246         if day.day in week:
    247             return weeks.index(week)
    248     return None
  • ow/views/user.py

    r6993c72 rbf01534  
    166166    """
    167167    now = datetime.now(timezone.utc)
    168     year = int(request.GET.get('year', now.year))
    169     month = int(request.GET.get('month', now.month))
    170     week = request.GET.get('week', None)
    171     return {
    172         'workouts': context.workouts(year, month, week),
    173         'current_month': '{year}-{month}'.format(
    174             year=str(year), month=str(month).zfill(2)),
    175         'current_week': week
     168    return {
     169        'current_month': now.strftime('%Y-%m')
    176170    }
    177171
     
    264258    context=User,
    265259    permission='view',
    266     name='monthly')
     260    name='yearly')
    267261def last_months_stats(context, request):
    268262    """
     
    286280            'distance': int(round(stats[month]['distance'])),
    287281            'elevation': int(stats[month]['elevation']),
    288             'workouts': stats[month]['workouts'],
    289             'url': request.resource_url(
    290                 context, 'profile',
    291                 query={'year': str(month[0]), 'month': str(month[1])},
    292                 anchor='workouts')
     282            'workouts': stats[month]['workouts']
    293283        }
    294284        json_stats.append(month_stats)
     
    296286                    charset='utf-8',
    297287                    body=json.dumps(json_stats))
    298 
    299 
    300 @view_config(
    301     context=User,
    302     permission='view',
    303     name='weekly')
    304 def last_weeks_stats(context, request):
    305     """
    306     Return a json-encoded stream with statistics for the last 12-months, but
    307     in a per-week basis
    308     """
    309     stats = context.weekly_year_stats
    310     # this sets which month is 2 times in the stats, once this year, once
    311     # the previous year. We will show it a bit different in the UI (showing
    312     # the year too to prevent confusion)
    313     repeated_month = datetime.now(timezone.utc).date().month
    314     json_stats = []
    315     for week in stats:
    316         hms = timedelta_to_hms(stats[week]['time'])
    317         name = month_name[week[1]][:3]
    318         if week[1] == repeated_month:
    319             name += ' ' + str(week[0])
    320         week_stats = {
    321             'id': '-'.join(
    322                 [str(week[0]), str(week[1]).zfill(2), str(week[2])]),
    323             'week': str(week[3]),  # the number of week in the current month
    324             'name': name,
    325             'time': str(hms[0]).zfill(2),
    326             'distance': int(round(stats[week]['distance'])),
    327             'elevation': int(stats[week]['elevation']),
    328             'workouts': stats[week]['workouts'],
    329             'url': request.resource_url(
    330                 context, 'profile',
    331                 query={'year': str(week[0]),
    332                        'month': str(week[1]),
    333                        'week': str(week[2])})
    334         }
    335         json_stats.append(week_stats)
    336     return Response(content_type='application/json',
    337                     charset='utf-8',
    338                     body=json.dumps(json_stats))
  • setup.py

    r6993c72 rbf01534  
    2121    'waitress',
    2222    'repoze.folder',
    23     'repoze.catalog @ git+https://github.com/WuShell/repoze.catalog.git@0.8.4'
    24     '#egg=repoze.catalog-0.8.4',
     23    'repoze.catalog==0.8.4',
    2524    'bcrypt',
    2625    'FormEncode',
     
    4039    'pytest-xdist',
    4140    'pytest-codestyle',
     41]
     42
     43dependency_links = [
     44    'git+https://github.com/WuShell/repoze.catalog.git@0.8.4'
     45    '#egg=repoze.catalog-0.8.4'
    4246]
    4347
     
    6064    include_package_data=True,
    6165    zip_safe=False,
     66    dependency_links=dependency_links,
    6267    extras_require={
    6368        'testing': tests_require,
Note: See TracChangeset for help on using the changeset viewer.