From 8f74c87df5a08451d98d0c2b595d0f7561a49440 Mon Sep 17 00:00:00 2001 From: okalintu Date: Sun, 29 Oct 2017 23:46:20 +0200 Subject: [PATCH 01/21] Reimplement serverside coffee scale --- coffee_scale/apps.py | 23 --- coffee_scale/mqtt.py | 58 -------- coffee_scale/static/js/coffee.js | 231 +++++++++++++++-------------- coffee_scale/templates/coffee.html | 2 + coffee_scale/tests.py | 28 ---- coffee_scale/urls.py | 4 +- coffee_scale/views.py | 16 -- requirements.txt | 1 - sikweb/base.py | 2 +- 9 files changed, 124 insertions(+), 241 deletions(-) delete mode 100644 coffee_scale/apps.py delete mode 100644 coffee_scale/mqtt.py diff --git a/coffee_scale/apps.py b/coffee_scale/apps.py deleted file mode 100644 index 57c4c5b..0000000 --- a/coffee_scale/apps.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.apps import AppConfig - -import logging -import sys - -from coffee_scale import mqtt - - -class CoffeeScaleConfig(AppConfig): - name = 'coffee_scale' - - def ready(self): - if ('makemigrations' in sys.argv or 'migrate' in sys.argv): - return - - try: - logging.info('Connecting to MQTT (coffee scale) at {}...'.format(mqtt.HOST)) - logging.info('If there is no confirmation, the MQTT connection has probably failed.') - mqtt.client.connect_async(mqtt.HOST, mqtt.PORT, 60) - mqtt.client.loop_start() - except Exception as ex: - logging.error(ex) - logging.error('Failed to connect to MQTT at {}'.format(mqtt.HOST)) diff --git a/coffee_scale/mqtt.py b/coffee_scale/mqtt.py deleted file mode 100644 index 47a4592..0000000 --- a/coffee_scale/mqtt.py +++ /dev/null @@ -1,58 +0,0 @@ -import paho.mqtt.client as mqtt -import logging -import datetime -from collections import deque - -from django.conf import settings - -HOST = settings.MQTT_SETTINGS['HOST'] -PORT = settings.MQTT_SETTINGS['PORT'] -TOPICS = settings.MQTT_SETTINGS['TOPICS'] -latest = {} - - -def on_connect(client, userdata, flags, rc): - logging.info('Connected successfully to MQTT.') - logging.info('Subscribing to all topics on {}.'.format(HOST)) - client.subscribe('sik/kiltahuone/kahvivaaka/#') - - -def update_latest(msg): - payload = msg.payload.decode('utf-8') - if msg.topic == TOPICS['WEIGHT']: - weight = float(payload) - latest['weight'] = weight - elif msg.topic == TOPICS['CUPS']: - cups = float(payload) - latest['cups'] = cups - elif msg.topic == TOPICS['BREWING']: - brewing = bool(int(payload)) - latest['brewing'] = brewing - elif msg.topic == TOPICS['BREW_TIME']: - brew_time = datetime.datetime.fromtimestamp(float(payload)) - latest['brew_time'] = brew_time - - -def on_message(client, userdata, msg): - try: - update_latest(msg) - except Exception as ex: - logging.exception('Failed to parse MQTT payload.') - - -def on_disconnect(client, userdata, rc): - if rc != 0: - logging.warning('MQTT unexpectedly disconnected.') - else: - client.loop_stop(force=False) - logging.warning('MQTT disconnected.') - - -def get_latest(): - return latest - - -client = mqtt.Client() -client.on_connect = on_connect -client.on_message = on_message -client.on_disconnect = on_disconnect diff --git a/coffee_scale/static/js/coffee.js b/coffee_scale/static/js/coffee.js index 6b9b4a6..95f338b 100644 --- a/coffee_scale/static/js/coffee.js +++ b/coffee_scale/static/js/coffee.js @@ -1,130 +1,139 @@ -var len = 0; -var lastBrew = "∞"; -var brewtext = ""; +//Inner state +var lastBrew = new Date(0); +var brewing = false; +var backoff = 2000; -$(document).ready(function(){ - $('#text').bind("DOMSubtreeModified", resize); - updateTime(); - setInterval(updateTime,1000); - formatBrewTime(); - setInterval(formatBrewTime,10000); -}); +//MQTT client config +var username = "coffee-user-"+ Math.random(); +var client = new Paho.MQTT.Client("sika.sahkoinsinoorikilta.fi", 9001, username); +client.onMessageArrived = function (message) { + console.log("Topic: "+message.destinationName+" msg: "+message.payloadString); + var ev = new CustomEvent(message.destinationName, {'detail': message.payloadString}); + window.dispatchEvent(ev); +} -$(window).resize(resize); - -function fetchdata(data, status){ - if (typeof status !== 'undefined'){ - if (status == "success"){ - parseData(data); +function reconnect(responseObject){ + if (responseObject.errorCode !== 0) { + console.log("connection lost! Reason: "+responseObject.errorMessage); + setTimeout(function(){ + client.connect({onSuccess:onConnect, useSSL:true, onFailure: reconnect}); + }, backoff); } - else if (status == "error"){ - handleError(); - } - } } -$.getJSON("/coffee/cups", fetchdata); -setInterval(function() { - $.getJSON("/coffee/cups", fetchdata); -}, 2000); - -function formatBrewTime(){ - if (!brewtext && lastBrew instanceof Date){ - var now = new Date(); - var timeDiff = Math.max(now.getTime() - lastBrew.getTime(), 0); - var tmp = (timeDiff < 3600000) - ? Math.round(timeDiff / 60000) + ' min' - : '~' + Math.round(timeDiff / 3600000 * 2) / 2 + ' h'; - - $("#brewtime").html(tmp); - } else { - $("#brewtime").html(brewtext); +function onConnect() { + console.log("MQTT connected"); + //set and reset reconnector + client.onConnectionLost = reconnect + // subscribe to topics + client.subscribe("sik/kiltahuone/kahvivaaka/cups"); + client.subscribe("sik/kiltahuone/kahvivaaka/brewing"); + client.subscribe("sik/kiltahuone/kahvivaaka/brewtime"); } + +// data update and parse functions +function parseCups(ev){ + var cups = parseFloat(ev.detail).toFixed(1) + var cupsEvent = new CustomEvent("cupsChanged", {'detail': cups}); + window.dispatchEvent(cupsEvent); +} +function updateCups(ev){ + $("#text").text(ev.detail); +} +function updateScale(ev){ + $("#scale2").css({width: Math.min(ev.detail/9*100,100) + '%'}); } -function handleError() { - setData("?", 0, 0, Number.MAX_VALUE, Number.MAX_VALUE); +function tick(){ + var ev = new CustomEvent("tick", {'detail': new Date()}); + window.dispatchEvent(ev); } -function parseData(data) { - if (data) { - var date = new Date(data.date); - lastBrew = new Date(data.last_brew); - var now = new Date(); - var cups = data.cups; - var brewing = data.brewing; - var timeDiff = Math.max(now.getTime() - lastBrew.getTime(),0); - var opa = Math.max(100 - timeDiff / 90000,0); - setData(cups, data.temp, opa,now.getTime()-date.getTime(), timeDiff, brewing); - } - else{ - handleError(); - } -} - -function setData(cups, temp, opa, timeFromUpdate, timeFromBrew, brewing){ - if (cups == 0) { - opa = 0; - } - brewtext = ""; - $("#upper").css({opacity: opa/100}); - $("#scale2").css({width: Math.min(cups/9*100,100) + '%'}); - $("#text,body").removeClass(); - - cups = Number(cups).toFixed(1); - - if(timeFromUpdate > 600000){ - cups = "?"; - brewtext = "∞"; - $("#text").addClass("unknown"); - } - else if(brewing){ - cups = "+"; - brewtext = ":)"; - $("#text").addClass("brewing"); - } - else if(cups <= 2){ - $("#text").addClass("hurry"); - } - formatBrewTime(); - if($("#text").html() == "+" && !brewing) - $("body").addClass("coffeeready"); - - var cupsString = cups.toString(); - len = cupsString.length; - $("#text").html(cups); -} - -function updateTime(){ - var now = new Date(); +function updateTime(ev){ + var now = ev.detail; $("#time").html(formatTime(now.getHours(),now.getMinutes(),now.getSeconds())); } -function formatTime(hours, minutes, seconds){ - var str = ""; - - if(hours < 10) - str += "0"; - str += hours; - - str += ":"; - - if(minutes < 10) - str += "0"; - str += minutes; - - str += ":"; - - if(seconds < 10) - str += "0"; - str += seconds; - - return str; +function coffeeLowEffect(ev){ + ev.detail <= 2 ? $("#text").addClass("hurry") : $("#text").removeClass("hurry"); } +function coffeeReadyEffect(ev){ + $("body").addClass("coffeeready"); + // autoclear animation class in 10s + setTimeout(() => {$("body").removeClass("coffeeready");}, 10000); +} +function brewAnimStart(ev){ + $("#text").addClass("brewing"); +} +function brewAnimEnd(ev){ + $("#text").removeClass("brewing"); +} +function brewNotifier(ev){ + var new_brewing = parseInt(ev.detail); + if (new_brewing == 1 && brewing == 0){ + window.dispatchEvent(new Event("brewStart")); + } else if (new_brewing == 0 && brewing == 1){ + window.dispatchEvent(new Event("brewEnd")); + } + brewing = new_brewing; +} +function brewTimeParser(ev){ + lastBrew = new Date(parseInt(ev.detail)*1000.0); +} +function updateBrewDiff(ev){ + var now = new Date(); + var timeDiff = Math.max(now.getTime() - lastBrew.getTime(), 0); + var timeStr = (timeDiff < 3600000) + ? Math.round(timeDiff / 60000) + ' min' + : '~' + Math.round(timeDiff / 3600000 * 2) / 2 + ' h'; + + $("#brewtime").html(timeStr); +} + +// Helpers + +function nToS(num){ + return num < 10 ? "0" + num : "" + num; +} + +function formatTime(hours, minutes, seconds){ + return nToS(hours)+":"+nToS(minutes)+":"+nToS(seconds) +} + function resize(){ var w = $("#container").width(); var h = $("#container").height(); var s = w > h ? h : w; - var font = s*0.8*0.38/Math.sqrt(len); - $("#text").css({ top: s*0.16-font/2 + 'px', fontSize: font + 'px', marginLeft: -font*len*3/10 + 'px'}); + var font = s * 0.8 * 0.38/Math.sqrt(3); + $("#text").css({ top: s*0.16-font/2 + 'px', + fontSize: font + 'px', + marginLeft: -font*3*3/10 + 'px'}); } + +// Init everything + +$(document).ready(function(){ + client.connect({onSuccess:onConnect, useSSL:true, onFailure:reconnect}); + + //connect MQTT event listeners + window.addEventListener("sik/kiltahuone/kahvivaaka/cups", parseCups); + window.addEventListener("sik/kiltahuone/kahvivaaka/brewing", brewNotifier); + window.addEventListener("sik/kiltahuone/kahvivaaka/brewtime", brewTimeParser); + + //connect other event listeners + window.addEventListener("cupsChanged", updateCups); + window.addEventListener("cupsChanged", coffeeLowEffect); + window.addEventListener("cupsChanged", updateScale); + window.addEventListener("cupsChanged", resize); + window.addEventListener("brewStart", brewAnimStart); + window.addEventListener("brewEnd", brewAnimEnd); + window.addEventListener("brewEnd", coffeeReadyEffect); + window.addEventListener("tick", updateTime); + window.addEventListener("tick", updateBrewDiff); + + //start time based events + setInterval(tick, 100); + tick(); + +}); +$(window).resize(resize); diff --git a/coffee_scale/templates/coffee.html b/coffee_scale/templates/coffee.html index 1c2fec9..faf215c 100644 --- a/coffee_scale/templates/coffee.html +++ b/coffee_scale/templates/coffee.html @@ -10,6 +10,8 @@ + diff --git a/coffee_scale/tests.py b/coffee_scale/tests.py index b1e5f9b..f9c051b 100644 --- a/coffee_scale/tests.py +++ b/coffee_scale/tests.py @@ -1,30 +1,2 @@ from django.test import TestCase, Client from django.conf import settings - -from coffee_scale.mqtt import on_message - -HOST = settings.MQTT_SETTINGS['HOST'] -PORT = settings.MQTT_SETTINGS['PORT'] -TOPICS = settings.MQTT_SETTINGS['TOPICS'] - - -class MQTTTestCase(TestCase): - """Tests MQTT functionality""" - - class MockMessage: - def __init__(self, payload, topic): - self.payload = payload - self.topic = topic - - def setUp(self): - payload = '10'.encode('utf-8') - topic = TOPICS['CUPS'] - msg = MQTTTestCase.MockMessage(payload, topic) - - on_message(None, None, msg) - self.c = Client() - - def test_receive_cups(self): - response = self.c.get('/coffee/cups') - payload = response.json() - self.assertEquals(payload['cups'], 10) diff --git a/coffee_scale/urls.py b/coffee_scale/urls.py index b9c54e6..f750576 100644 --- a/coffee_scale/urls.py +++ b/coffee_scale/urls.py @@ -1,7 +1,7 @@ from django.conf.urls import url from django.views.generic.base import RedirectView -from .views import coffee_view, cups_view +from .views import coffee_view favicon_view = RedirectView.as_view(url='static/img/favicon.ico', permanent=True) @@ -9,6 +9,4 @@ urlpatterns = [ # landing page url(r'^$', coffee_view), - url(r'^cups', cups_view), - ] diff --git a/coffee_scale/views.py b/coffee_scale/views.py index 9b8ae08..b973641 100644 --- a/coffee_scale/views.py +++ b/coffee_scale/views.py @@ -3,25 +3,9 @@ from django.http import JsonResponse from django.utils import timezone -from .mqtt import get_latest -import coffee_scale.mqtt # somehow this is needed - import logging from django.conf import settings def coffee_view(request): return render(request, 'coffee.html') - - -def cups_view(request): - now = timezone.now() - latest = get_latest() - data = { - 'date': now, - 'cups': latest.get('cups'), - 'last_brew': latest.get('brew_time'), - 'brewing': latest.get('brewing'), - 'weight': latest.get('weight') - } - return JsonResponse(data) diff --git a/requirements.txt b/requirements.txt index ef7a2fd..5e52874 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,6 @@ dealer==2.0.5 django-modeltranslation==0.12.1 django-auditlog==0.4.3 django-phonenumber-field==1.3.0 -paho-mqtt==1.3.0 django-autocomplete-light==3.2.10 six==1.10.0 django-suit==0.2.25 diff --git a/sikweb/base.py b/sikweb/base.py index c094344..527c645 100644 --- a/sikweb/base.py +++ b/sikweb/base.py @@ -78,7 +78,7 @@ INSTALLED_APPS = [ 'webapp', 'members', 'infoscreen', - 'coffee_scale.apps.CoffeeScaleConfig', + 'coffee_scale', 'rest_framework', 'django_nose', 'bootstrap3', From ea48cef206dc61dd496ddf2aa0dfc845e5e8fe3f Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Tue, 31 Oct 2017 15:54:09 +0200 Subject: [PATCH 02/21] Start using new HSL API --- infoscreen/hsl_fetcher.py | 106 +++++++------- infoscreen/hsl_stops.graphql | 71 ++++++++++ infoscreen/hsl_stops_variables.json | 10 ++ infoscreen/static/css/hsl.css | 6 +- infoscreen/static/html/hsl.html | 22 ++- .../static/js/infoscreen_controllers.js | 130 ++++-------------- infoscreen/views/public_views.py | 28 ++-- 7 files changed, 185 insertions(+), 188 deletions(-) create mode 100644 infoscreen/hsl_stops.graphql create mode 100644 infoscreen/hsl_stops_variables.json diff --git a/infoscreen/hsl_fetcher.py b/infoscreen/hsl_fetcher.py index d4d00ea..fe3be66 100644 --- a/infoscreen/hsl_fetcher.py +++ b/infoscreen/hsl_fetcher.py @@ -1,73 +1,75 @@ """File containing Infoscreen HSL data fetcher classes.""" -import urllib.request +import requests import json import logging +import os +import pytz + from datetime import timedelta, datetime -from django.utils import timezone +from django.utils import timezone, dateparse +from django.utils.dateformat import format from django.conf import settings from infoscreen.models import HSLDataModel +last_fetched = timezone.now() +INTERVAL = 1 # minutes +# logging.info( +# "Set up scheduled HSL API fetch every {} minutes".format(INTERVAL)) -class HSLFetcher: - """Main class of Infoscreen HSL fetcher.""" +with open(os.path.join(settings.BASE_DIR, 'infoscreen', 'hsl_stops.graphql')) as stops_file: + STOPS_QUERY = stops_file.read() - last_fetched = datetime.fromtimestamp(86400) # epoch - INTERVAL = 1 # minutes - # logging.info( - # "Set up scheduled HSL API fetch every {} minutes".format(INTERVAL)) +with open(os.path.join(settings.BASE_DIR, 'infoscreen', 'hsl_stops_variables.json')) as vars_file: + STOPS_VARS = json.loads(vars_file.read()) - def fetch_if_needed(self): - """Check if new fetch from HSL API is needed.""" - if (timezone.now() - HSLFetcher.last_fetched > - timedelta(minutes=HSLFetcher.INTERVAL)): - self.fetch() +API_URL = 'https://api.digitransit.fi/routing/v1/routers/hsl/index/graphql' +API_HEADERS = {'Content-Type': 'application/json'} - def fetch(self): - """Fetch data from HSL API.""" - location_coords = (2545565, 6675319) - src = urllib.request.urlopen( - ("https://api.reittiopas.fi/hsl/prod/?userhash={}" - "&request=stops_area¢er_coordinate={},{}") - .format(settings.HSL_USERHASH, location_coords[0], - location_coords[1]))\ - .read().decode("utf-8") - data = json.loads(src) +def fetch(): + """Fetch data from HSL API.""" - arr = [] + query_vars = STOPS_VARS.copy() + query_vars['startTime_6'] = format(timezone.now(), 'U') - time = (timezone.now() + - timedelta(minutes=settings.HSL_DEPARTURE_THRESHOLD)) - time = "{0:02d}{0:02d}".format(time.hour, time.minute) - for element in data: - src = urllib.request.urlopen( - ("https://api.reittiopas.fi/hsl/prod/?userhash={}" - "&request=stop&code={}&dep_limit=20&time={}") - .format(settings.HSL_USERHASH, element['code'], time) - ).read().decode("utf-8") + post_data = json.dumps({ + 'operationName': 'NearestRoutesContainer', + 'query': STOPS_QUERY, + 'variables': query_vars, + }) - parsed = json.loads(src)[0] - arr.append({ - "name": parsed['name_fi'], - "lines": parsed['lines'], - "dist": element['dist'], - "departures": parsed['departures']}) + resp = requests.post(API_URL, data=post_data, headers=API_HEADERS) - model_arr = HSLDataModel.objects.all() - count = len(model_arr) - json_dump = json.dumps(arr) + data = resp.json() - if count == 0: - HSLDataModel.objects.create(data=json_dump) - else: - obj = model_arr[count - 1] - obj.data = json_dump - obj.save() - now = timezone.now() - HSLFetcher.last_fetched = now + items = data['data']['viewer']['_nearest']['edges'] + places = map(lambda item: item['node']['place'], items) - logging.info( - "Fetched HSL timetable data with size {} bytes.".format(len(src))) + schedule = [] + for place in places: + route = place['pattern']['route']['shortName'] + stop_times = place['_stoptimes'] + for stop_time in stop_times: + timestamp = stop_time['serviceDay'] + stop_time['realtimeArrival'] + headsign = stop_time['stopHeadsign'] + stop_name = stop_time['stop']['name'] + + time_diff = (timestamp - timezone.now().timestamp()) / 60 # minutes + if time_diff < settings.HSL_DEPARTURE_THRESHOLD: + continue + elif time_diff < settings.HSL_HURRY_THRESHOLD: + time = '{} min'.format(int(time_diff)) + else: + time = datetime.utcfromtimestamp(timestamp).strftime('%H:%M') + + schedule.append({ + 'route': route, + 'headsign': headsign, + 'timestamp': time, + 'stop': stop_name, + }) + + return schedule diff --git a/infoscreen/hsl_stops.graphql b/infoscreen/hsl_stops.graphql new file mode 100644 index 0000000..6111303 --- /dev/null +++ b/infoscreen/hsl_stops.graphql @@ -0,0 +1,71 @@ +query NearestRoutesContainer($lat_0: Float!, $lon_1: Float!, $maxDistance_2: Int!, $maxResults_3: Int!, $timeRange_7: Int!, $numberOfDepartures_8: Int!, $filterByModes_4: [Mode]!, $filterByPlaceTypes_5: [FilterPlaceType]!, $startTime_6: Long!) { + viewer { + ...F5 + } +} + +fragment F0 on DepartureRow { + _stoptimes4caEfh: stoptimes(startTime: $startTime_6, timeRange: $timeRange_7, numberOfDepartures: $numberOfDepartures_8) { + pickupType + serviceDay + realtimeDeparture + } + id +} + +fragment F1 on DepartureRow { + pattern { + route { + shortName + } + } + _stoptimes: stoptimes(startTime: $startTime_6, timeRange: $timeRange_7, numberOfDepartures: $numberOfDepartures_8) { + realtimeArrival + serviceDay + stopHeadsign + stop { + name + } + } +} + +fragment F2 on BikeRentalStation { + id +} + +fragment F3 on placeAtDistance { + distance + place { + id + __typename + ...F1 + ...F2 + } + id +} + +fragment F4 on placeAtDistanceConnection { + edges { + node { + distance + place { + id + __typename + ...F0 + } + id + ...F3 + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + } +} + +fragment F5 on QueryType { + _nearest: nearest(lat: $lat_0, lon: $lon_1, maxDistance: $maxDistance_2, maxResults: $maxResults_3, first: $maxResults_3, filterByModes: $filterByModes_4, filterByPlaceTypes: $filterByPlaceTypes_5) { + ...F4 + } +} \ No newline at end of file diff --git a/infoscreen/hsl_stops_variables.json b/infoscreen/hsl_stops_variables.json new file mode 100644 index 0000000..bcc8e18 --- /dev/null +++ b/infoscreen/hsl_stops_variables.json @@ -0,0 +1,10 @@ +{ + "lat_0": 60.190480099999995, + "lon_1": 24.8275665, + "maxDistance_2": 1000, + "maxResults_3": 50, + "numberOfDepartures_8": 2, + "timeRange_7": 7200, + "filterByModes_4": ["BUS"], + "filterByPlaceTypes_5": ["DEPARTURE_ROW"] +} diff --git a/infoscreen/static/css/hsl.css b/infoscreen/static/css/hsl.css index 7f407a1..8753ab1 100644 --- a/infoscreen/static/css/hsl.css +++ b/infoscreen/static/css/hsl.css @@ -1,5 +1,5 @@ table { - font-size: 5vh; + font-size: 4vh; font-family: 'Droid Sans Mono', monospace; } .red { @@ -58,9 +58,9 @@ thead{ margin-left: 0; margin-right: 0; } + .repeat-item.ng-leave { - -webkit-transition:0.5s linear all; - transition:0.5s linear all; + } .repeat-item.ng-leave.ng-leave-active { diff --git a/infoscreen/static/html/hsl.html b/infoscreen/static/html/hsl.html index 48baab6..81edabb 100644 --- a/infoscreen/static/html/hsl.html +++ b/infoscreen/static/html/hsl.html @@ -1,12 +1,16 @@ +
HSL-Aikataulut
-

{{ clock | date:'HH:mm'}}

+

{{clock | date:'HH:mm'}}

- +

+ {{error}} +

+
- - - + -
@@ -18,25 +22,19 @@ Pysäkki - Päätepysäkki -
- {{x.timedelta < 10 ?x.timedelta + ' min' : x.time}} +
+ {{x.timestamp}} - {{x.bus}} + {{x.route}}, {{x.headsign}} {{x.stop}} - {{x.laststop}} -
diff --git a/infoscreen/static/js/infoscreen_controllers.js b/infoscreen/static/js/infoscreen_controllers.js index ec1825b..543ed65 100644 --- a/infoscreen/static/js/infoscreen_controllers.js +++ b/infoscreen/static/js/infoscreen_controllers.js @@ -1,4 +1,5 @@ var app = angular.module('infoApp', ['ngAnimate', 'ngRoute']); + app.controller('infoscreen_main', function($scope,$http,$timeout){ var templates = []; $scope.init = function(rot){ @@ -103,13 +104,22 @@ app.controller('EventController', function($scope, $http) { }) }); +app.filter('unixTimeToDifference', function() { + return function(input) { + var date = moment.unix(input); + var now = moment(); + var res = date.diff(now, 'minutes'); + return res; + } +}) + app.controller('timetableCtrl', function($scope, $http, $interval) { - function load(){ + function load() { $http.get('/infoscreen/hsl_data') .then(function(data, status, headers, config) { //eslint-disable-line no-unused-vars - $scope.arr=[]; - parse(data); + $scope.stoptimes = data.data; + $scope.error = data.data.error || null; }); $http.get('/infoscreen/hsl_data/settings') .then(function(data, status, headers, config) { //eslint-disable-line no-unused-vars @@ -117,112 +127,20 @@ app.controller('timetableCtrl', $scope.hurryThreshold = data.data['hurry_threshold']; }); } - $scope.$on('$destroy', function() { - $interval.cancel(inter1); - $interval.cancel(inter2); - $interval.cancel(inter3); - }); - var objects; - $scope.arr=[]; - var dict=[]; - function parse(data){ - objects=data['data']; - for(var objectIndex in objects){ - var stop = objects[objectIndex]; - var lineIndex; - for (lineIndex in stop['lines']){ - var elem=stop['lines'][lineIndex].split(":"); - dict[elem[0]]=elem[1]; - } - for (lineIndex in stop['departures']){ - var line = stop['departures'][lineIndex]; - var time = line['time']; - var date = line['date']; - var hours = Math.floor(time / 100); - var minutes = time % 100; - if (hours >= 24) { - hours -= 24; - date++; - } - var code = line['code'].substring(1, 5); - if (code.charAt(0) == '0') { - code = code.substring(1,4); - } - - var departure = { - "stop": stop['name'].split(",")[0], - "dist": stop['dist'], - "bus": code, - "date": date, - "time": pad(hours, 2) + ":" + pad(minutes, 2), - "laststop": dict[line['code']].split(",")[0].split(" l.")[0], - "hurry": false - }; - if(departure['laststop']=='Otaniemi') - break; - if(departure['stop']=='Alvar Aallon puisto') - departure['stop']="A. A. puisto" - var trigger = true; - for (var arrIndex = $scope.arr.length - 1; arrIndex >= 0; arrIndex--) { - if ($scope.arr[arrIndex]['bus'] == departure['bus'] && - $scope.arr[arrIndex]['laststop'] == departure['laststop']) { - - if ($scope.arr[arrIndex]['dist'] == departure['dist']){ - break; - } - else if ($scope.arr[arrIndex]['dist'] > departure['dist']){ - $scope.arr.splice(arrIndex, 1); - } - else { - trigger = false; - } - } - } - if (trigger) { - $scope.arr.push(departure); - } - } - } - - function pad(num, size) { - var s = num + ""; - while (s.length < size) { - s = "0" + s; - } - return s; - } - - delOld(); - } - function delOld(){ - var tooSoon = typeof($scope.departureThreshold) != 'undefined' ? $scope.departureThreshold: 0; - var hurry = typeof($scope.hurryThreshold) != 'undefined' ? $scope.hurryThreshold : 0; - var f = new Date(); - for (var a=$scope.arr.length-1; a>=0; a--) { - var time=$scope.arr[a]['time'].split(":"); - var date=$scope.arr[a]['date'].toString(); - var d = new Date(f); - d.setFullYear(date.substring(0,4), date.substring(4,6)-1, date.substring(6,8)); - d.setHours(time[0]); - d.setMinutes(time[1]); - var diff=(d.getTime()-f.getTime()); - $scope.arr[a]['timedelta']=Math.floor(diff/60000); - if(diff < tooSoon*60000) { - $scope.arr.splice(a,1); - } - else if (diff < hurry*60000) { - $scope.arr[a]['hurry']=true; - } - } - } - function updateTime(){ + function update_clock() { $scope.clock = Date.now(); } - $scope.clock = Date.now(); + + $scope.$on('$destroy', function() { + $interval.cancel(load_interval); + $interval.cancel(clock_interval); + }); + + var load_interval = $interval(load, 5000); + var clock_interval = $interval(update_clock, 1000); + + update_clock(); load(); - var inter1=$interval(delOld,2000); - var inter2=$interval(load,10000); - var inter3=$interval(updateTime, 1000); } ); diff --git a/infoscreen/views/public_views.py b/infoscreen/views/public_views.py index 461a547..9f78627 100644 --- a/infoscreen/views/public_views.py +++ b/infoscreen/views/public_views.py @@ -1,12 +1,14 @@ from django.shortcuts import render from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest from django.views.decorators.http import require_http_methods +from django.conf import settings -from infoscreen.models import Rotation, InfoItem, InfoInstance -from infoscreen.hsl_fetcher import HSLFetcher +from infoscreen.models import Rotation, InfoItem, InfoInstance, HSLDataModel +from infoscreen.hsl_fetcher import fetch as hsl_fetch import json import logging +import threading @require_http_methods(["GET"]) @@ -82,22 +84,18 @@ def hsl_timetable_settings(request, *args, **kwargs): """Set HSL timetable settings.""" d = {"departure_threshold": settings.HSL_DEPARTURE_THRESHOLD, "hurry_threshold": settings.HSL_HURRY_THRESHOLD} - resp = json.dumps(d) - return HttpResponse(resp, status=200) + + return JsonResponse(d, status=200) @require_http_methods(["GET"]) def CurrentHSLView(request, *args, **kwargs): """Get HSL data and return it.""" - fetcher = HSLFetcher() - fetcherThread = threading.Thread(target=fetcher.fetch_if_needed, args=[]) - fetcherThread.setDaemon(False) - fetcherThread.start() + try: + api_resp = hsl_fetch() + except Exception as ex: + logging.exception('Failed to fetch HSL timetables.') + error = {'error': 'Aikataulujen haku epäonnistui.'} + return JsonResponse(error, status=200) - data = HSLDataModel.objects.all() - if len(data) < 1: - return HttpResponse( - '{"error" : "Could not find timetables from database."}', - status=500) - - return HttpResponse(data[len(data) - 1].data, status=200) + return JsonResponse(api_resp, status=200, safe=False) From 96f630ed2fce582c7724e645d18c19b1f354b7f0 Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Tue, 31 Oct 2017 16:20:08 +0200 Subject: [PATCH 03/21] Fix pycodestyle offenses --- infoscreen/hsl_fetcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infoscreen/hsl_fetcher.py b/infoscreen/hsl_fetcher.py index fe3be66..031675d 100644 --- a/infoscreen/hsl_fetcher.py +++ b/infoscreen/hsl_fetcher.py @@ -56,8 +56,8 @@ def fetch(): timestamp = stop_time['serviceDay'] + stop_time['realtimeArrival'] headsign = stop_time['stopHeadsign'] stop_name = stop_time['stop']['name'] - time_diff = (timestamp - timezone.now().timestamp()) / 60 # minutes + if time_diff < settings.HSL_DEPARTURE_THRESHOLD: continue elif time_diff < settings.HSL_HURRY_THRESHOLD: From 480e2c42965021dd50424e2aef124f665e1b8272 Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Tue, 31 Oct 2017 16:57:56 +0200 Subject: [PATCH 04/21] Remove obsolete HSLDataModel --- infoscreen/hsl_fetcher.py | 6 ------ .../migrations/0006_delete_hsldatamodel.py | 18 ++++++++++++++++++ infoscreen/models.py | 5 ----- infoscreen/views/admin_views.py | 1 - infoscreen/views/public_views.py | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 infoscreen/migrations/0006_delete_hsldatamodel.py diff --git a/infoscreen/hsl_fetcher.py b/infoscreen/hsl_fetcher.py index 031675d..ce4e095 100644 --- a/infoscreen/hsl_fetcher.py +++ b/infoscreen/hsl_fetcher.py @@ -12,12 +12,6 @@ from django.utils.dateformat import format from django.conf import settings -from infoscreen.models import HSLDataModel - -last_fetched = timezone.now() -INTERVAL = 1 # minutes -# logging.info( -# "Set up scheduled HSL API fetch every {} minutes".format(INTERVAL)) with open(os.path.join(settings.BASE_DIR, 'infoscreen', 'hsl_stops.graphql')) as stops_file: STOPS_QUERY = stops_file.read() diff --git a/infoscreen/migrations/0006_delete_hsldatamodel.py b/infoscreen/migrations/0006_delete_hsldatamodel.py new file mode 100644 index 0000000..c660813 --- /dev/null +++ b/infoscreen/migrations/0006_delete_hsldatamodel.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-10-31 14:56 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('infoscreen', '0005_auto_20170913_1841'), + ] + + operations = [ + migrations.DeleteModel( + name='HSLDataModel', + ), + ] diff --git a/infoscreen/models.py b/infoscreen/models.py index 6805637..73fd5a9 100644 --- a/infoscreen/models.py +++ b/infoscreen/models.py @@ -407,8 +407,3 @@ class UploadFileForm(forms.Form): name = forms.CharField() video = forms.FileField() - -class HSLDataModel(models.Model): - """Model representing HSL data.""" - - data = models.TextField(default="", editable=False) diff --git a/infoscreen/views/admin_views.py b/infoscreen/views/admin_views.py index 152859a..ff0bab6 100644 --- a/infoscreen/views/admin_views.py +++ b/infoscreen/views/admin_views.py @@ -20,7 +20,6 @@ from infoscreen.models import (ABBInfoItem, ExternalImageInfoItem, from infoscreen.models import EventInfoItem from infoscreen.models import ExternalWebsiteInfoItem from infoscreen.models import ImageUploadForm -from infoscreen.models import HSLDataModel from infoscreen.models import ApyInfoItem from infoscreen.models import VideoInfoItem diff --git a/infoscreen/views/public_views.py b/infoscreen/views/public_views.py index 9f78627..2ee3e34 100644 --- a/infoscreen/views/public_views.py +++ b/infoscreen/views/public_views.py @@ -3,7 +3,7 @@ from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest from django.views.decorators.http import require_http_methods from django.conf import settings -from infoscreen.models import Rotation, InfoItem, InfoInstance, HSLDataModel +from infoscreen.models import Rotation, InfoItem, InfoInstance from infoscreen.hsl_fetcher import fetch as hsl_fetch import json From 60f167d77b93ce54215a8da25d756e6d226929d9 Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Tue, 31 Oct 2017 17:07:09 +0200 Subject: [PATCH 05/21] Move clock to left side --- infoscreen/static/html/hsl.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infoscreen/static/html/hsl.html b/infoscreen/static/html/hsl.html index 81edabb..bbc128e 100644 --- a/infoscreen/static/html/hsl.html +++ b/infoscreen/static/html/hsl.html @@ -3,9 +3,9 @@
-
+

{{clock | date:'HH:mm'}}

HSL-Aikataulut
-

{{clock | date:'HH:mm'}}

+

{{error}} From 7fb984df83991764b57415d33d58a69fdf1ccfd4 Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Tue, 31 Oct 2017 17:14:20 +0200 Subject: [PATCH 06/21] Fix pycodestyle again --- infoscreen/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/infoscreen/models.py b/infoscreen/models.py index 73fd5a9..ff1613d 100644 --- a/infoscreen/models.py +++ b/infoscreen/models.py @@ -406,4 +406,3 @@ class UploadFileForm(forms.Form): name = forms.CharField() video = forms.FileField() - From f867f25bc371520ee9b51797fbfb2f71939fb5bd Mon Sep 17 00:00:00 2001 From: henu Date: Tue, 31 Oct 2017 17:22:38 +0200 Subject: [PATCH 07/21] Isolate kaehmy from other webapp --- webapp/static/css/kaehmy_footer.css | 34 ++++++++++++++++++ webapp/static/css/kaehmy_header.css | 37 +++++++++++++++++++ webapp/static/css/webapp_kaehmy.css | 26 ++++++++++++++ webapp/templates/kaehmy_base.html | 56 +++++++++++++++++++++++------ webapp/templates/kaehmy_footer.html | 25 +++++++++++++ webapp/templates/kaehmy_header.html | 2 +- 6 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 webapp/static/css/kaehmy_footer.css create mode 100644 webapp/static/css/kaehmy_header.css create mode 100644 webapp/static/css/webapp_kaehmy.css create mode 100644 webapp/templates/kaehmy_footer.html diff --git a/webapp/static/css/kaehmy_footer.css b/webapp/static/css/kaehmy_footer.css new file mode 100644 index 0000000..bf63819 --- /dev/null +++ b/webapp/static/css/kaehmy_footer.css @@ -0,0 +1,34 @@ +#footer-div { + height:10vh; +} + +footer { + /* position: absolute; */ + bottom: 0; + width: 100%; + height: 60px; /* Set the fixed height of the footer here */ + /* line-height: 60px; /* Vertically center the text there */ */ + margin-top: 2rem; + margin-bottom: 1rem; +} + +footer .container .col .nav .nav-item { + display: inline-block; + margin-right: 3vh; +} + +.lang-button { + height: 4vh; + width: 6vh; + margin-left: 1vh; + margin-right: 1vh; +} + +.lang-select { + width: 10rem; + display: inline-block; +} + +footer .lang-form { + margin: 1rem auto 0; +} diff --git a/webapp/static/css/kaehmy_header.css b/webapp/static/css/kaehmy_header.css new file mode 100644 index 0000000..4e20d7e --- /dev/null +++ b/webapp/static/css/kaehmy_header.css @@ -0,0 +1,37 @@ +.header-content { + +} + +.header-content .logo { + +} + +.header-content .logo img { + display: block; + height: auto; + margin: auto; +} + +.kaehmy-banner { + max-width: 1000px; + margin-left: auto; + margin-right: auto; +} + +@media screen and (min-width: 1000px) { + .kaehmy_header-content { + position: absolute; + left: 0; + top: 0; + background-color: #052f5f; + width: 100%; + } + + .kaehmy_header { + margin-bottom: 331px; + } +} + +.kaehmy-banner-image { + width: 100%; +} diff --git a/webapp/static/css/webapp_kaehmy.css b/webapp/static/css/webapp_kaehmy.css new file mode 100644 index 0000000..635e63f --- /dev/null +++ b/webapp/static/css/webapp_kaehmy.css @@ -0,0 +1,26 @@ +.page-content { + margin-top: 1vh; + width: 90%; + margin-left: auto; + margin-right: auto; +} + +body { + max-width: 1000px; + margin-left: auto !important; + margin-right: auto !important; +} + +div.tooltip-inner { + max-width: 25rem; +} + +.tooltip { + margin-left: 1rem; +} + +.role-filter-form { + max-width: 30rem; + width: auto; + margin-bottom: 1rem; +} diff --git a/webapp/templates/kaehmy_base.html b/webapp/templates/kaehmy_base.html index fc177cb..8df04f7 100644 --- a/webapp/templates/kaehmy_base.html +++ b/webapp/templates/kaehmy_base.html @@ -1,14 +1,50 @@ -{% extends "base.html" %} + -{% load static %} {% load i18n %} +{% load static %} +{% load staticfiles %} -{% block navigation %} - {% include "kaehmy_navigation.html" %} -{% endblock %} + + + + + + + + {% trans "Aalto-yliopiston Sähköinsinöörikilta ry" %} -{% block header %} -
- {% include "kaehmy_header.html" %} -
-{% endblock header %} + + + + + + + + + + + + {% block header %} +
+ {% include "kaehmy_header.html" %} +
+ {% endblock header %} + + {% block navigation %} + {% include "kaehmy_navigation.html" %} + {% endblock %} + + {% block content %} + {% endblock %} + + + + diff --git a/webapp/templates/kaehmy_footer.html b/webapp/templates/kaehmy_footer.html new file mode 100644 index 0000000..c5e725c --- /dev/null +++ b/webapp/templates/kaehmy_footer.html @@ -0,0 +1,25 @@ +{% load i18n %} +{% load static %} +{% load staticfiles %} + + +
+
+
{% csrf_token %} + + + + +
+ {% trans "Copyright Aalto-yliopiston Sähköinsinöörikilta ry" %} {% now 'Y' %} +
+
diff --git a/webapp/templates/kaehmy_header.html b/webapp/templates/kaehmy_header.html index f536c5a..59cb76a 100644 --- a/webapp/templates/kaehmy_header.html +++ b/webapp/templates/kaehmy_header.html @@ -1,5 +1,5 @@ {% load i18n %} - +
diff --git a/webapp/templates/ohlhafv.html b/webapp/templates/ohlhafv.html index 05decc5..c4f658b 100644 --- a/webapp/templates/ohlhafv.html +++ b/webapp/templates/ohlhafv.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "webapp_base.html" %} {% load bootstrap3 %} {% load i18n %} diff --git a/webapp/templates/ohlhafv_list.html b/webapp/templates/ohlhafv_list.html index 057ce06..a693237 100644 --- a/webapp/templates/ohlhafv_list.html +++ b/webapp/templates/ohlhafv_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "webapp_base.html" %} {% load static %} {% load i18n %} diff --git a/webapp/templates/sosso.html b/webapp/templates/sosso.html index f96a7ff..cd3bdea 100644 --- a/webapp/templates/sosso.html +++ b/webapp/templates/sosso.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "webapp_base.html" %} {% block content %} {% load i18n %} diff --git a/webapp/templates/webapp_base.html b/webapp/templates/webapp_base.html new file mode 100644 index 0000000..6e9dfd7 --- /dev/null +++ b/webapp/templates/webapp_base.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load static %} +{% load staticfiles %} + + +
+
+
+ {% block header %} + {% include "sik_header.html" %} + {% endblock %} +
+
+
+
+ {% block navigation %} + {% include "navigation.html" %} + {% endblock %} +
+
+
+
+ {% block content %} + {% endblock %} +
+
+
+
+ {% block footer %} + {% include "footer.html" %} + {% endblock footer %} +
+
+
From 6d18e4043100048f1dd3ef69ec4d379fdbf0cdbe Mon Sep 17 00:00:00 2001 From: henu Date: Tue, 31 Oct 2017 21:01:22 +0200 Subject: [PATCH 19/21] Fix login form styles --- static/css/footer.css | 2 -- templates/base.html | 5 +++ templates/login.html | 52 ++++++++++++++----------------- templates/login_base.html | 50 ++++++++++------------------- webapp/templates/webapp_base.html | 2 ++ 5 files changed, 48 insertions(+), 63 deletions(-) diff --git a/static/css/footer.css b/static/css/footer.css index 61bca94..23fbc04 100644 --- a/static/css/footer.css +++ b/static/css/footer.css @@ -1,6 +1,4 @@ footer { - position: absolute; - bottom: 0; background-color: #f5f5f5; } diff --git a/templates/base.html b/templates/base.html index 88ac035..5307718 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,4 +1,9 @@ + +{% load i18n %} +{% load static %} +{% load staticfiles %} + diff --git a/templates/login.html b/templates/login.html index 948c0f8..463ea05 100644 --- a/templates/login.html +++ b/templates/login.html @@ -2,34 +2,30 @@ {% load i18n %} {% block content %} -

SIK Admin

-
{% csrf_token %} -
- -
- -
+
+
+

SIK Admin

-
- -
- -
+
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+
{{ error }}
+
+
+ +
+
- -
-
-
{{ error }}
-
-
-
-
- -
-
- +
{% endblock content %} diff --git a/templates/login_base.html b/templates/login_base.html index 1aa3b9e..d0b0a8f 100644 --- a/templates/login_base.html +++ b/templates/login_base.html @@ -1,38 +1,22 @@ - +{% extends "base.html" %} {% load i18n %} {% load static %} - - - SIK - Login - +{% block body %} +{% block header %} +
+ {% include "sik_header.html" %} +
+{% endblock %} - - - - - - - - - - - {% block header %} -
- {% include "sik_header.html" %} -
- {% endblock %} - -
- {% block content %} - {% endblock %} -
- - - - \ No newline at end of file +
+ {% block content %} + {% endblock %} +
+ +{% endblock %} diff --git a/webapp/templates/webapp_base.html b/webapp/templates/webapp_base.html index 6e9dfd7..1744f55 100644 --- a/webapp/templates/webapp_base.html +++ b/webapp/templates/webapp_base.html @@ -4,6 +4,7 @@ {% load static %} {% load staticfiles %} +{% block body %}
@@ -34,3 +35,4 @@
+{% endblock %} From e63f8d54182ad357c7133cea605b1adcacfed193 Mon Sep 17 00:00:00 2001 From: Juhana Luomanen Date: Tue, 31 Oct 2017 22:34:39 +0200 Subject: [PATCH 20/21] Fix #91 bug with HSL timetable sorting --- infoscreen/hsl_fetcher.py | 3 ++- infoscreen/static/html/hsl.html | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/infoscreen/hsl_fetcher.py b/infoscreen/hsl_fetcher.py index 9df913a..6a3835c 100644 --- a/infoscreen/hsl_fetcher.py +++ b/infoscreen/hsl_fetcher.py @@ -47,7 +47,7 @@ def fetch(): route = place['pattern']['route']['shortName'] stop_times = place['_stoptimes'] for stop_time in stop_times: - timestamp = stop_time['serviceDay'] + stop_time['realtimeArrival'] + timestamp = time_utc = stop_time['serviceDay'] + stop_time['realtimeArrival'] headsign = stop_time['stopHeadsign'] stop_name = stop_time['stop']['name'] time_diff = (timestamp - timezone.now().timestamp()) / 60 # minutes @@ -64,6 +64,7 @@ def fetch(): 'headsign': headsign, 'timestamp': time, 'stop': stop_name, + 'utc': time_utc, }) return schedule diff --git a/infoscreen/static/html/hsl.html b/infoscreen/static/html/hsl.html index bbc128e..288fa4c 100644 --- a/infoscreen/static/html/hsl.html +++ b/infoscreen/static/html/hsl.html @@ -25,7 +25,7 @@ - + {{x.timestamp}} From e0e73976dbd6b97ef362d4922bdeae1ff1f478c9 Mon Sep 17 00:00:00 2001 From: Ilkka Oksanen Date: Wed, 1 Nov 2017 13:39:46 +0200 Subject: [PATCH 21/21] Add question marks to coffee in case of few errors --- coffee_scale/static/js/coffee.js | 12 ++++++++---- coffee_scale/templates/coffee.html | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/coffee_scale/static/js/coffee.js b/coffee_scale/static/js/coffee.js index 32134a0..f885408 100644 --- a/coffee_scale/static/js/coffee.js +++ b/coffee_scale/static/js/coffee.js @@ -99,10 +99,14 @@ function updateBrewDiff(){ } function updateBrewTime(ev){ var timeDiff = ev.detail; - var timeStr = (timeDiff < 3600000) - ? Math.round(timeDiff / 60000) + ' min' - : '~' + Math.round(timeDiff / 3600000 * 2) / 2 + ' h'; - + var timeStr; + if (timeDiff < 3600000){ + timeStr = Math.round(timeDiff / 60000) + ' min' + } else if (timeDiff < 10000* 3600 * 1000){ // 1000h + timeStr = '~' + Math.round(timeDiff / 3600000 * 2) / 2 + ' h'; + } else { + timeStr = "???" + } $("#brewtime").html(timeStr); } diff --git a/coffee_scale/templates/coffee.html b/coffee_scale/templates/coffee.html index 5484f97..b09a25d 100644 --- a/coffee_scale/templates/coffee.html +++ b/coffee_scale/templates/coffee.html @@ -29,7 +29,7 @@
-
+
???
 +