Changes in / [bf01534:6993c72] in OpenWorkouts-current
- Files:
-
- 11 edited
Legend:
- Unmodified
- Added
- Removed
-
README.txt
rbf01534 r6993c72 2 2 ============ 3 3 4 Getting 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 4 TBW, if you look for installation instructions, take a look at the script 5 bin/install -
bin/install
rbf01534 r6993c72 58 58 install_openworkouts() { 59 59 . ${env_path}/bin/activate 60 yes | pip install --upgrade - -process-dependency-links -e ${current}[testing]60 yes | pip install --upgrade -e ${current}[testing] 61 61 deactivate 62 62 } -
ow/models/user.py
rbf01534 r6993c72 9 9 10 10 from ow.catalog import get_catalog, reindex_object 11 from ow.utilities import get_week_days 11 from ow.utilities import get_week_days, get_month_week_number 12 12 13 13 … … 77 77 reindex_object(catalog, workout) 78 78 79 def workouts(self, year=None, month=None ):79 def workouts(self, year=None, month=None, week=None): 80 80 """ 81 81 Return this user workouts, sorted by date, from newer to older … … 84 84 if year: 85 85 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] 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] 88 92 workouts = sorted(workouts, key=attrgetter('start')) 89 93 workouts.reverse() … … 288 292 289 293 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
rbf01534 r6993c72 1006 1006 <a class="login-remember" href="#">Forgot your password?</a> 1007 1007 */ 1008 .user-profile img { 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 { 1009 1022 width: 140px; 1010 1023 height: 140px; … … 1013 1026 margin-bottom: 0.5em; 1014 1027 } 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 }1028 1028 .user-profile-account h2 { 1029 1029 margin: 0 0 0.15em 0; … … 1040 1040 color: #959595; 1041 1041 } 1042 . user-profile-account .workouts {1043 font-size: 1 8px;1044 font-size: 1.125rem;1042 .total-workouts { 1043 font-size: 13px; 1044 font-size: 0.8125rem; 1045 1045 font-weight: bold; 1046 1046 } -
ow/static/js/ow.js
rbf01534 r6993c72 244 244 var chart_selector = spec.chart_selector, 245 245 filters_selector = spec.filters_selector, 246 url = spec.url, 246 switcher_selector = spec.switcher_selector, 247 urls = spec.urls, 247 248 current_month = spec.current_month, 248 y_axis_labels = spec.y_axis_labels; 249 current_week = spec.current_week, 250 y_axis_labels = spec.y_axis_labels, 251 filter_by = spec.filter_by, 252 url = spec.url; 249 253 250 254 // Helpers … … 264 268 }; 265 269 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 266 279 // Methods 267 280 var filters_setup = function filters_setup() { 268 281 $(filters_selector).on('click', function(e) { 269 var filter_by = 'distance';270 282 e.preventDefault(); 271 283 filter_by = $(this).attr('class').split('-')[1] 272 284 var chart = d3.select(chart_selector); 273 285 chart.selectAll("*").remove(); 274 render(filter_by); 275 }); 276 }; 277 278 var render = function render(filter_by) { 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) { 279 301 /* 280 302 Build a d3 bar chart, populated with data from the given url. … … 288 310 y = d3.scaleLinear().rangeRound([height, 0]); 289 311 290 d3.json(url ).then(function (data) {312 d3.json(urls[url]).then(function (data) { 291 313 x.domain(data.map(function (d) { 292 return d.name; 314 return get_name_for_x(d); 315 // return d.name; 293 316 })); 294 317 … … 316 339 .enter().append("rect") 317 340 .attr("class", function(d) { 318 if (d.id == current_month){ 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 */ 319 344 select_x_axis_label(d).attr('style', "font-weight: bold;"); 320 return 'bar current' 345 return 'bar current'; 321 346 } 322 347 else { 323 return 'bar' 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 324 362 } 325 363 }) 326 364 .attr("x", function (d) { 327 return x( d.name);365 return x(get_name_for_x(d)); 328 366 }) 329 367 .attr("y", function (d) { … … 343 381 select_x_axis_label(d).attr('style', "font-weight: regular;"); 344 382 } 383 }) 384 .on('click', function(d) { 385 window.location.href = d.url; 345 386 }); 346 387 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 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 } 373 430 }); 374 431 }; … … 376 433 var that = {} 377 434 that.filters_setup = filters_setup; 435 that.switcher_setup = switcher_setup; 378 436 that.render = render; 379 437 return that -
ow/static/less/pages/profile.less
rbf01534 r6993c72 1 1 .user-profile { 2 img {3 width: 140px;4 height: 140px;5 object-fit: cover;6 border-radius: 50%;7 margin-bottom: .5em;8 }9 2 .workout-options { 10 3 .font-size(13); … … 17 10 @media (min-width: @screen-s){ 18 11 padding: 2em 6em; 12 } 13 img { 14 width: 140px; 15 height: 140px; 16 object-fit: cover; 17 border-radius: 50%; 18 margin-bottom: .5em; 19 19 } 20 20 h2 { … … 30 30 } 31 31 } 32 .workouts { 33 .font-size(18); 34 font-weight: bold;35 }32 } 33 .total-workouts { 34 .font-size(13); 35 font-weight: bold; 36 36 } 37 37 -
ow/templates/profile.pt
rbf01534 r6993c72 26 26 <tal:has-nickname tal:condition="context.nickname"> 27 27 <tal:nickname tal:content="context.nickname"></tal:nickname> 28 </tal:has-nickname> | 28 </tal:has-nickname> | 29 29 <span><tal:email tal:content="context.email"></tal:email></span> 30 30 </p> … … 60 60 i18n:translate="">change password</a></li> 61 61 </ul> 62 </div> 62 63 63 <div class="workouts"> 64 <tal:w tal:replace="context.num_workouts"></tal:w> 65 <tal:t i18n:translate="">Workouts</tal:t> 66 </div> 64 <div class="total-workouts"> 65 <tal:w tal:replace="context.num_workouts"></tal:w> 66 <tal:t i18n:translate="">workouts</tal:t> 67 67 </div> 68 68 69 69 <div class="month-stats js-month-stats"> 70 70 <div class="svg-cotent"> 71 <svg width=" 600" height="300" viewBox="0 0 600 300"></svg>71 <svg width="800" height="180" viewBox="0 0 800 180"></svg> 72 72 </div> 73 73 <ul class="workout-options filters js-filters"> … … 76 76 <li><a href="#" class="js-elevation" i18n:translate="">elevation</a></li> 77 77 </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> 78 83 </div> 79 84 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" 85 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" 86 95 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> 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> 93 139 </div> 94 </tal:r>95 </div>96 140 141 </article> 142 143 </tal:r> 97 144 </div> 98 145 … … 114 161 chart_selector: '.js-month-stats svg', 115 162 filters_selector: '.js-month-stats .js-filters a', 116 url: "${request.resource_url(context, 'yearly')}", 163 switcher_selector: '.js-month-stats .js-switcher a', 164 urls: {"monthly": "${request.resource_url(context, 'monthly')}", 165 "weekly": "${request.resource_url(context, 'weekly')}"}, 117 166 current_month: "${current_month}", 167 current_week: "${current_week}", 118 168 y_axis_labels: y_axis_labels, 169 filter_by: "distance", 170 url: "${'monthly' if current_week is None else 'weekly'}", 119 171 }); 120 year_chart.render("distance" );172 year_chart.render("distance", "${'monthly' if current_week is None else 'weekly'}"); 121 173 year_chart.filters_setup(); 174 year_chart.switcher_setup(); 122 175 </script> 123 176 -
ow/tests/views/test_user.py
rbf01534 r6993c72 267 267 """ 268 268 request = dummy_request 269 # profile page for the current day (no workouts avalable) 269 270 response = user_views.profile(john, request) 270 assert len(response.keys()) == 1271 assert len(response.keys()) == 3 271 272 current_month = datetime.now(timezone.utc).strftime('%Y-%m') 272 273 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) 273 302 274 303 def test_login_get(self, dummy_request): -
ow/utilities.py
rbf01534 r6993c72 2 2 import os 3 3 import logging 4 import calendar 4 5 import subprocess 5 6 from datetime import datetime, timedelta … … 234 235 week_days = [first_day + timedelta(days=i) for i in range(7)] 235 236 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
rbf01534 r6993c72 166 166 """ 167 167 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) 168 171 return { 169 'current_month': now.strftime('%Y-%m') 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 170 176 } 171 177 … … 258 264 context=User, 259 265 permission='view', 260 name=' yearly')266 name='monthly') 261 267 def last_months_stats(context, request): 262 268 """ … … 280 286 'distance': int(round(stats[month]['distance'])), 281 287 'elevation': int(stats[month]['elevation']), 282 'workouts': stats[month]['workouts'] 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') 283 293 } 284 294 json_stats.append(month_stats) … … 286 296 charset='utf-8', 287 297 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
rbf01534 r6993c72 21 21 'waitress', 22 22 'repoze.folder', 23 'repoze.catalog==0.8.4', 23 'repoze.catalog @ git+https://github.com/WuShell/repoze.catalog.git@0.8.4' 24 '#egg=repoze.catalog-0.8.4', 24 25 'bcrypt', 25 26 'FormEncode', … … 39 40 'pytest-xdist', 40 41 'pytest-codestyle', 41 ]42 43 dependency_links = [44 'git+https://github.com/WuShell/repoze.catalog.git@0.8.4'45 '#egg=repoze.catalog-0.8.4'46 42 ] 47 43 … … 64 60 include_package_data=True, 65 61 zip_safe=False, 66 dependency_links=dependency_links,67 62 extras_require={ 68 63 'testing': tests_require,
Note: See TracChangeset
for help on using the changeset viewer.