26 Commits

Author SHA1 Message Date
Lavikainen Joel 5e6b4153e6 Add data endpoint and create infoscreen view for coffee stats 2017-11-19 17:40:04 +02:00
Lavikainen Joel 66e86ab621 Save brewing data as db model 2017-11-19 14:19:37 +02:00
Ilkka Oksanen e4f701711c Fix pep stuff 2017-11-16 13:23:09 +02:00
Ilkka Oksanen 2363362202 Add structure for new ilmotunkki 2017-11-16 12:50:04 +02:00
Jan Tuomi ab2682a0d3 Fix eslint offenses 2017-11-08 18:09:45 +02:00
Jan Tuomi 6678c691dd Add error to coffee display when there is a connection problem
Resolves #87
2017-11-08 17:55:56 +02:00
Jan Tuomi aaf773c600 Gitlab runner is being funky so I'm grasping at straws 2017-11-05 17:21:29 +02:00
Jan Tuomi 417083b050 Do not prune docker volumes on dev deploy 2017-11-05 11:48:09 +02:00
Jan Tuomi 08e675f698 Add missing script parameter 2017-11-05 10:47:30 +02:00
Jan Tuomi efb1ee6182 Improve dev deployment 2017-11-05 10:38:39 +02:00
Jan Tuomi 99788e8d47 Use correct registry URL in docker-compose 2017-11-05 10:06:39 +02:00
Jan Tuomi 356038a622 Merge branch 'feature-excel-import-export' into 'develop'
Feature excel import export

See merge request vtmk/web2.0!97
2017-11-02 23:41:36 +02:00
Jan Tuomi 31e324e478 Remove obsolete setting in base.py 2017-11-02 23:20:57 +02:00
Jan Tuomi e5c00a47e8 Add missing pyexcel dependency 2017-11-02 23:11:59 +02:00
Jan Tuomi 1fdc9e9ff8 Write translations 2017-11-02 23:10:24 +02:00
Jan Tuomi cf33d81d69 Write some unit tests for excel stuff 2017-11-02 22:56:34 +02:00
Jan Tuomi 29070165eb Merge develop to feature-excel-import-export 2017-11-02 15:15:41 +02:00
Jan Tuomi b9e9cdb2b0 Add new payment event for new members 2017-11-02 15:12:32 +02:00
Ilkka Oksanen e0e73976db Add question marks to coffee in case of few errors 2017-11-01 13:39:46 +02:00
Jan Tuomi 755abe5647 Add missing pip dependency 2017-11-01 13:17:31 +02:00
Jan Tuomi 2f80159144 Merge branch 'feature-coffee-upgrade' into 'develop'
Feature coffee upgrade

See merge request vtmk/web2.0!92
2017-11-01 13:14:44 +02:00
Jan Tuomi e709570f22 Change stuff to use excel files 2017-10-31 22:36:31 +02:00
Juhana Luomanen e63f8d5418 Fix #91 bug with HSL timetable sorting 2017-10-31 22:34:39 +02:00
Ilkka Oksanen b8fd237918 Mute a few eslint bloopers 2017-10-31 18:10:08 +02:00
Ilkka Oksanen 6c153e7ab0 Add fancier animations 2017-10-31 17:56:18 +02:00
okalintu 8f74c87df5 Reimplement serverside coffee scale 2017-10-29 23:46:20 +02:00
71 changed files with 1604 additions and 1213 deletions
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
echo "Deploying to development."
set -e
set -x
pushd deployment
docker-compose down
docker pull "$1"
docker-compose up -d
popd
set +x
set +e
+2 -1
View File
@@ -76,7 +76,8 @@ deploy_dev:
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
script:
- scp docker-compose.yml $DEV_SSH_USER@$DEV_SSH_HOST:~/deployment/docker-compose.yml
- ssh $DEV_SSH_USER@$DEV_SSH_HOST "cd deployment && docker-compose down && docker pull \"$IMAGE_NAME\" && docker-compose up -d && docker image prune -f"
- scp .deploy_dev.sh $DEV_SSH_USER@$DEV_SSH_HOST:~/deployment/deploy_dev.sh
- ssh $DEV_SSH_USER@$DEV_SSH_HOST "bash ~/deployment/deploy_dev.sh \"$IMAGE_NAME\""
deploy_production:
stage: deploy
-23
View File
@@ -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))
@@ -0,0 +1,74 @@
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.conf import settings
from coffee_scale.models import Brewing
import paho.mqtt.client as mqtt
import random
brewing = False
lastbrew = timezone.now()
lastcups = 0
def on_connect(client, userdata, flags, rc):
if rc != 0:
print("Failed to connect with result code: {}".format(rc))
print("Connected with result code: {}".format(rc))
client.subscribe(settings.MQTT_SETTINGS.HOST.TOPICS.CUPS)
client.subscribe(settings.MQTT_SETTINGS.HOST.TOPICS.BREWING)
client.subscribe(settings.MQTT_SETTINGS.HOST.TOPICS.BREW_TIME)
def on_message(client, userdata, message):
print("%s %s".format(message.topic, message.payload.decode()))
def on_message_cups(client, userdata, message):
cups = int(message.payload.decode())
print("cups: {}".format(cups))
print("{}".format(timezone.now()))
# checks if new coffee was brewed so we don't add the same brewing again to db
global lastcups # ;/ have to use global to store state instead of class
if cups > lastcups:
new_brew = Brewing(cups=cups, time=timezone.now())
print(new_brew.time)
new_brew.save()
lastcups = cups
def on_message_brewtime(client, userdata, message):
brewtime = datetime.fromtimestamp(int(message.payload.decode()))
print("brewtime: {}".format(brewtime))
def on_message_brewing(client, userdata, message):
brewing = bool(int(message.payload.decode()))
print("brewing: {}".format(brewing))
class Command(BaseCommand):
help = "Fetches coffee mqtt messages"
def add_arquments(self, parser):
pass
def handle(self, *args, **options):
self.username = "coffee-user-%d".format(random.randint(0, 100))
self.client = mqtt.Client("coffee")
self.client.username_pw_set(self.username, password=None)
self.client.tls_set()
# callbacks for different topics
self.client.message_callback_add(settings.MQTT_SETTINGS.HOST.TOPICS.BREW_TIME, on_message_brewtime)
self.client.message_callback_add(settings.MQTT_SETTINGS.HOST.TOPICS.CUPS, on_message_cups)
self.client.message_callback_add(settings.MQTT_SETTINGS.HOST.TOPICS.BREWING, on_message_brewing)
# self.client.connect("localhost", port=1883) # used for local testing
self.client.connect(settings.MQTT_SETTINGS.HOST, port=settings.MQTT_SETTINGS.PORT)
self.client.on_message = on_message
self.client.on_connect = on_connect
while True:
self.client.loop()
+24
View File
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-11-19 10:55
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Brewing',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cups', models.PositiveSmallIntegerField()),
('time', models.DateTimeField()),
],
),
]
+4 -1
View File
@@ -1,3 +1,6 @@
from django.db import models
# Create your models here.
class Brewing(models.Model):
cups = models.PositiveSmallIntegerField()
time = models.DateTimeField()
-58
View File
@@ -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
+7 -4
View File
@@ -47,7 +47,7 @@ body {
background: green;
border-radius: 10px;
}
#brewtime{
.brewtime{
text-align:right;
position:absolute;
right:0px;
@@ -62,10 +62,13 @@ body {
font-size:4vw;
color: #333;
}
.layertwo{
display: None;
}
noscript{
color:red;
}
#text{
.text{
color:green;
position:absolute;
top:50%;
@@ -102,9 +105,9 @@ noscript{
}
@keyframes coffeeready {
0% {background-color:white;}
25% {background-color:green;}
25% {background-color:rgb(100, 255, 100);}
50% {background-color:white;}
75% {background-color:green;}
75% {background-color:rgb(100, 255, 100);}
100% {background-color:white;}
}
@keyframes unknown {
+166 -113
View File
@@ -1,130 +1,183 @@
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();
// eslint-disable-next-line no-undef
var client = new Paho.MQTT.Client("sika.sahkoinsinoorikilta.fi", 9001, username);
client.onMessageArrived = function (message) {
// eslint-disable-next-line no-console
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); // eslint-disable-line no-console
setTimeout(function(){
client.connect({onSuccess:onConnect, useSSL:true, onFailure: reconnect});
}, backoff);
}
else if (status == "error"){
handleError();
}
function onConnect() {
console.log("MQTT connected"); // eslint-disable-line no-console
//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)
function makeEvent(cups) {
return (String(cups) !== '-1.0')
? new CustomEvent("cupsChanged", {'detail': cups})
: new CustomEvent("cupsError", {'detail': 'Error: unable to fetch cups :('});
}
}
window.dispatchEvent(makeEvent(cups));
}
function updateCups(ev){
$("#text").text(ev.detail);
}
function showCupsError(ev) {
$('#text').text(ev.detail);
$('#text').css({
'font-size': '7vh',
'left': '0',
'top': '40%',
'width': '100%',
'text-align': 'center',
'color': 'red',
});
$('#lower').css({'background-image': 'none'});
}
function updateScale(ev){
$("#scale2").css({width: Math.min(ev.detail/9*100,100) + '%'});
}
$.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 tick(){
var ev = new CustomEvent("tick", {'detail': new Date()});
window.dispatchEvent(ev);
}
function handleError() {
setData("?", 0, 0, Number.MAX_VALUE, Number.MAX_VALUE);
}
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 = "&infin;";
$("#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(){
$("body").addClass("coffeeready");
// autoclear animation class in 10s
setTimeout(function(){$("body").removeClass("coffeeready");}, 10000);
}
function hotEffect(ev){
var opa = Math.max(100 - ev.detail / 90000,0);
$("#upper").css({opacity: opa/100});
}
function brewAnimStart(){
$(".text").addClass("brewing");
$(".layerone").hide();
$(".layertwo").show();
}
function brewAnimEnd(){
$(".text").removeClass("brewing");
$(".layertwo").hide();
$(".layerone").show();
}
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(){
var now = new Date();
var timeDiff = Math.max(now.getTime() - lastBrew.getTime(), 0);
var eve = new CustomEvent("dtUpdate", {'detail': timeDiff});
window.dispatchEvent(eve);
}
function updateBrewTime(ev){
var timeDiff = ev.detail;
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);
}
// 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("cupsError", showCupsError);
window.addEventListener("cupsError", coffeeLowEffect);
window.addEventListener("cupsError", updateScale);
window.addEventListener("brewStart", brewAnimStart);
window.addEventListener("brewEnd", brewAnimEnd);
window.addEventListener("brewEnd", coffeeReadyEffect);
window.addEventListener("tick", updateTime);
window.addEventListener("tick", updateBrewDiff);
window.addEventListener("dtUpdate", updateBrewTime);
window.addEventListener("dtUpdate", hotEffect);
//start time based events
setInterval(tick, 100);
tick();
});
$(window).resize(resize);
+6 -2
View File
@@ -10,13 +10,16 @@
<meta http-equiv="pragma" content="no-cache" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.2/mqttws31.js"
type="text/javascript"></script>
<link rel="stylesheet" href="/static/css/coffee.css" />
<script src="/static/js/coffee.js"></script>
</head>
<body>
<div id="container">
<span id="brewtime"></span>
<span id="brewtime" class="brewtime layerone"></span>
<span class="brewtime layertwo">:)</span>
<span id="address">
ka.dy.fi
<noscript><br>This page uses JavaScript!</noscript>
@@ -26,7 +29,8 @@
</div>
<!--Kahvinkeitin on rikki. Varakeittimellä keitettyä kahvia saattaa olla.-->
<div id="lower" class="normal">
<div id="text"></div>
<div id="text" class="text layerone">???</div>
<div class="text layertwo">&nbsp;+</div>
<div id="scale"><div id="scale2"></div></div>
</div>
</div>
-28
View File
@@ -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)
+1 -3
View File
@@ -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),
]
-16
View File
@@ -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)
+1 -1
View File
@@ -5,7 +5,7 @@ services:
image: postgres
web:
build: .
image: 86.50.143.82:5000/web20
image: git.sahkoinsinoorikilta.fi:4567/vtmk/web2.0
command: ["bash", "-c", "cd /code && ./wait-for-it.sh db:5432 -- bash setup.sh --no-input --no-npm && python manage.py runserver 0.0.0.0:8080"]
ports:
- "8080:8080"
+45
View File
@@ -0,0 +1,45 @@
# Ilmotunkki
## Terms
- Signup, Form with collection of questions
- Response, One answer to some signup
- Quota, Amount of people allowed to respond with some option selected.
- In generic case there is no option and quota is just max number of people.
## Requirements
- Officials may generate signups forms
- Officials may see results from signups
- Officials may see some stats from their signups
- for example distributions of multiple choice answers
- Officials should be able to edit signups wherever possible
- Propably not possible to edit after first response
- Officials should be able to delete responses
- Officials should be able to embed payment information to the signup?
- TODO: is there need for unique reference numbers for every response?
- Officials should be able to save a signup to a reusable template.
- Signup may be attached to an event
- multiple signups to a single event?
- Signup should support custom quotas
- Atleast quotas from multiple choices and checkboxes
- Text quotas are risky (typos everywhere!!)
- Signup should have start and end times
- signup should support atleast following questiontypes
- Text
- multiple choice (select one)
- checkbox (boolean yes/no)
- Signup should support reserve slots.
TODO: quota based reserves or generic? or both?
- Responding should send confirm email
- Response should be editable by responder and only by the responder until the closing of the signup
- TODO: is there need to custom edit period or disable?
- Responders should see amount of quotas left.
- Responders should see some information about other responses
- TODO: names? should this be editable by officials?
- NOTE: Quota related info is exposed if any info is printed
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class IlmotunkkiConfig(AppConfig):
name = 'ilmotunkki'
View File
+16
View File
@@ -0,0 +1,16 @@
from django.db import models
from django.utils import timezone
class Signup(models.Model):
start = models.DateTimeField()
end = models.DateTimeField()
class Question(models.Model):
pass
class Answer(models.Model):
signup = models.ForeignKey(Signup, on_delete=models.CASCADE)
question = models.ForeignKey(Question, on_delete=models.PROTECT)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
+2
View File
@@ -5,6 +5,7 @@ from infoscreen.models import Rotation, InfoItem, InfoInstance
from infoscreen.models import ImageInfoItem, ExternalImageInfoItem, ABBInfoItem
from infoscreen.models import ExternalWebsiteInfoItem
from infoscreen.models import VideoInfoItem
from infoscreen.models import CoffeeStatsInfoItem
# Register your models here.
admin.site.register(Rotation)
@@ -15,3 +16,4 @@ admin.site.register(ABBInfoItem)
admin.site.register(InfoInstance)
admin.site.register(ExternalWebsiteInfoItem)
admin.site.register(VideoInfoItem)
admin.site.register(CoffeeStatsInfoItem)
+2 -1
View File
@@ -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
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-11-19 14:41
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('infoscreen', '0006_delete_hsldatamodel'),
]
operations = [
migrations.CreateModel(
name='CoffeeStatsInfoItem',
fields=[
('infoitem_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='infoscreen.InfoItem')),
],
bases=('infoscreen.infoitem',),
),
]
+15
View File
@@ -269,6 +269,21 @@ class HslInfoItem(InfoItem):
return "/static/html/hsl_create.html"
class CoffeeStatsInfoItem(InfoItem):
"""Class for Coffee statistics Infoscreen item."""
display_name = _("Coffee statistics")
def get_template_url(self):
"""Return HSL infoitem template url."""
return "/static/html/coffee_stats.html"
@staticmethod
def get_create_template_url():
"""Call create HSL infoitem template url command."""
return "/static/html/coffee_stats_create.html"
class ExternalImageInfoItem(InfoItem):
"""Class for External Image Infoscreen item."""
+9
View File
@@ -0,0 +1,9 @@
<link rel="stylesheet" href="/static/css/hsl.css">
<link href="https://fonts.googleapis.com/css?family=Droid+Sans+Mono" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.19.1/locale/fi.js"></script>
<script type="text/javascript" src="dygraph.js"></script>
<link rel="stylesheet" src="dygraph.css" /></head>
<div class="container" ng-controller="CoffeeStatsController">
<div id="div_g"></div>
</div>
@@ -0,0 +1,11 @@
<div ng-controller="infoadmin_coffeestatsitem_create" style="margin-top:20px;">
<div>
Create new item to show coffee statistics. Name is used only as identifier
</div>
<div class="form-group">
<label>Name:</label>
<input type="text" ng-model="item.name"></input>
</div>
<input type="button" class="btn btn-success" ng-click="send()" value="create"></input>
</div>
<!--maybe later add option to choose data range, daily, monthly...etc.
+1 -1
View File
@@ -25,7 +25,7 @@
</tr>
</thead>
<tbody>
<tr class="repeat-item" ng-repeat="x in stoptimes | orderBy: ['timestamp'] | limitTo: 11">
<tr class="repeat-item" ng-repeat="x in stoptimes | orderBy: ['utc'] | limitTo: 11">
<td style="min-width: 300px">
{{x.timestamp}}
</td>
@@ -186,5 +186,6 @@ var simple_controllers = [
"hslitem",
"websiteitem",
"apyitem",
"coffeestatsitem",
];
_.each(simple_controllers, controllerGenerator);
+15 -1
View File
@@ -142,5 +142,19 @@ app.controller('timetableCtrl',
update_clock();
load();
});
app.controller('CoffeeStatsController', function($scope, $http) {
function load() {
$http.get('/infoscreen/coffee_data')
.then(function(response) {
const g = new Dygraph(document.getElementById('div_g'), response.data, {
drawPoints: true,
valueRange: [0, 10],
labels: ['Time', 'Cups']
});
});
}
);
load();
});
+4
View File
@@ -26,6 +26,8 @@ from infoscreen.views import CurrentHSLView
from infoscreen.views import createApyItem
from infoscreen.views import hsl_timetable_settings
from infoscreen.views import get_apy_json
from infoscreen.views import createCoffeeStatsItem
from infoscreen.views import CoffeeStatsView
urlpatterns = [
url(r'^$', default),
@@ -45,6 +47,7 @@ urlpatterns = [
url(r'^create_sossoitem$', createSossoItem),
url(r'^create_eventitem$', createEventItem),
url(r'^create_hslitem$', createHslItem),
url(r'^create_coffeestatsitem$', createCoffeeStatsItem),
url(r'^create_apyitem$', createApyItem),
url(r'^create_websiteitem$', createExternalWebsiteItem),
url(r'^create_rotation$', create_rotation),
@@ -52,5 +55,6 @@ urlpatterns = [
url(r'^hsl_data$', CurrentHSLView),
url(r'^hsl_data/settings$', hsl_timetable_settings),
url(r'^apyjson', get_apy_json),
url(r'^coffee_data$', CoffeeStatsView),
]
+2
View File
@@ -22,6 +22,7 @@ from infoscreen.models import ExternalWebsiteInfoItem
from infoscreen.models import ImageUploadForm
from infoscreen.models import ApyInfoItem
from infoscreen.models import VideoInfoItem
from infoscreen.models import CoffeeStatsInfoItem
@login_required(login_url='/login')
@@ -188,3 +189,4 @@ createExternalWebsiteItem = create_item_generator(ExternalWebsiteInfoItem)
createEventItem = create_item_generator(EventInfoItem)
createApyItem = create_item_generator(ApyInfoItem)
createVideoItem = create_item_generator(VideoInfoItem)
createCoffeeStatsItem = create_item_generator(CoffeeStatsInfoItem)
+15
View File
@@ -2,8 +2,11 @@ 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 django.utils import timezone
from django.db.models.functions import ExtractWeek
from infoscreen.models import Rotation, InfoItem, InfoInstance
from coffee_scale.models import Brewing
from infoscreen.hsl_fetcher import fetch as hsl_fetch
import json
@@ -99,3 +102,15 @@ def CurrentHSLView(request, *args, **kwargs):
return JsonResponse(error, status=200)
return JsonResponse(api_resp, status=200, safe=False)
@require_http_methods(["GET"])
def CoffeeStatsView(request, *args, **kwargs):
# stats_data = Brewing.objects.annotate(
# week=ExtractWeek('time')).values('week').get(time__week=timezone.now().isocalendar()[2])
# do filtering here based on the data we want to show
stats_data = {}
for entry in Brewing.objects.all():
stats_data[entry.id] = {'cups': entry.cups, 'time': entry.time}
return JsonResponse(stats_data, status=200, safe=False)
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -46,7 +46,7 @@ class MemberForm(forms.ModelForm):
@staticmethod
def csv_to_models(data, payment_source='AYY', delimiter=','):
clean_data = data.strip().split('\n')
clean_data = [row.rstrip(',') for row in clean_data]
clean_data = [row.rstrip(',').rstrip('\r').strip() for row in clean_data]
csv_reader = csv.DictReader(clean_data, fieldnames=MemberForm.Meta.fields, delimiter=delimiter, quoting=csv.QUOTE_NONE)
members = []
@@ -122,3 +122,7 @@ class ApplicationForm(forms.ModelForm):
self.fields['AYY'].label = _("I'm a member of AYY")
self.fields['jas'].label = _("I want to receive a weekly newsletter")
class UploadFileForm(forms.Form):
file = forms.FileField()
+26
View File
@@ -0,0 +1,26 @@
from import_export import resources
from .models import Member, Payment, Request
class MemberResource(resources.ModelResource):
class Meta:
model = Member
exclude = ['id', 'created']
class PaymentResource(resources.ModelResource):
member = resources.Field()
class Meta:
model = Payment
exclude = ['id']
def dehydrate_member(self, payment):
return '{} {}'.format(payment.member.first_name, payment.member.last_name)
class ApplicationResource(resources.ModelResource):
class Meta:
model = Request
exclude = ['id']
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+4
View File
@@ -15,6 +15,10 @@
{% endif %}
{{ table|safe }}
<div>
<a href="/members/export_applications" class="btn btn-info">{% trans "Download Excel" %}</a>
</div>
</div>
{% endblock content %}
+24 -17
View File
@@ -1,7 +1,7 @@
{% extends "members_base.html" %}
{% load i18n %}
{% load static %}
{% block content %}
<div>
<div>
@@ -11,24 +11,28 @@
<div>
<p>
{% blocktrans %}
Enter member information in CSV format, separate members on separate lines.
Enter member information in CSV format, separate members on separate lines.
If a new member already exists in the database, a new payment event will be created for that member instead.
{% endblocktrans %}
</p>
<p>
{% blocktrans %}
first_name, last_name, email_address and place_of_origin should be given string values.
ayy_member and jas_recipient should be given the value 0 (off) or 1 (on).
{% endblocktrans %}
</p>
<h4>{% trans "Syntax" %}</h4>
<pre>first_name, last_name, email_address, place_of_origin, ayy_member, jas_recipient</pre>
</div>
<form name="memberTextForm" action="/members/import_csv" method="POST">{% csrf_token %}
<div class="form-group">
<label>{% trans "Data" %}</label>
<textarea name="textfield" class="form-control large-textarea" placeholder="Teemu, Teekkari, teemu.teekkari@notmail.dom, Otaniemi, 0, 0"></textarea>
<div>
<label>{% trans "Format the member table like this:" %}</label>
<div>
<img src="{% static "img/excel_csv_save_example.png" %}">
</div>
<p>{% blocktrans %}Columns: First name, last name, email address, place of origin, AYY member, JAS recipient{% endblocktrans %}</p>
</div>
<div>
<label>{% trans "Save the file as CSV" %}</label>
<div><img src="{% static "img/excel_csv_save_tutorial.png" %}"></div>
</div>
<form name="memberTextForm" action="/members/import_csv" enctype="multipart/form-data" method="POST">{% csrf_token %}
<h3>{% trans "Upload file" %}</h3>
<input class="form-control-file" type="file" accept=".csv" name="csvFile" />
<div class="form-group">
<label>{% trans "Payment source" %}</label>
<select name="payment_source" class="form-control">
@@ -36,13 +40,16 @@
<option value="bank_transfer">{% trans "Bank transfer" %}</option>
<option value="cash">{% trans "Cash payment" %}</option>
</select>
<small class="form-text text-muted">
{% trans "This payment source will be used to create any payments for new members that already exist in the database." %}
</small>
</div>
<div class="form-group">
<label>{% trans "CSV delimiter" %}</label>
<input type="text" name="delimiter" class="form-control" value=";" />
<p class="form-text text-muted">
<small class="form-text text-muted">
{% blocktrans %}The symbol that is used to separate items in one line. Defaults to ';' (semicolon).{% endblocktrans %}
</p>
</small>
</div>
<div>
<button type="submit" class="btn btn-primary">{% trans "Send" %}</button>
+1 -1
View File
@@ -41,7 +41,7 @@
{{ table|safe }}
<div>
<a href="/members/export_csv" class="btn btn-info">{% trans "Download CSV" %}</a>
<a href="/members/export_members" class="btn btn-info">{% trans "Download Excel" %}</a>
</div>
</div>
{% endblock content %}
+4
View File
@@ -36,5 +36,9 @@
{% endif %}
{{ table|safe }}
<div>
<a href="/members/export_payments" class="btn btn-info">{% trans "Download Excel" %}</a>
</div>
</div>
{% endblock content %}
+10
View File
@@ -0,0 +1,10 @@
{% extends "members_base.html" %}
{% block content %}
<h1>{{ title }}</h1>
<h3>{{ header }}</h3>
<form method="POST" action="/members/import_excel" enctype="multipart/form-data">{% csrf_token %}
{{ form }}
<input type="submit" class="btn btn-primary">
</form>
{% endblock %}
@@ -0,0 +1,3 @@
Testi;Ukkeli;testi@ukkeli.fi;Espoo;1;0
Jäbä;Kakkeli;jaba@kakkeli.fi;Hamina;0;1
Kolmas;Kaveri;kolmas@kaveri.com;Mesta;1;1
1 Testi Ukkeli testi@ukkeli.fi Espoo 1 0
2 Jäbä Kakkeli jaba@kakkeli.fi Hamina 0 1
3 Kolmas Kaveri kolmas@kaveri.com Mesta 1 1
@@ -0,0 +1 @@
Testi;Ukkeli;testi@ukkeli.fi;Espoo;1;0
1 Testi Ukkeli testi@ukkeli.fi Espoo 1 0
+73 -7
View File
@@ -3,10 +3,12 @@
from django.test import TestCase, Client
from django.contrib.auth.models import User
from members.management.commands.createsahkopiikkiuser import Command as SahkopiikkiCommand
from members.models import Member
from members.models import Member, Payment, Request
from rest_framework.authtoken.models import Token
import logging
import os
import pyexcel
class MemberRegisterTestCase(TestCase):
@@ -14,7 +16,13 @@ class MemberRegisterTestCase(TestCase):
def setUp(self):
"""Setup testing environment by creating member and admin."""
memb = Member.objects.create(first_name="Tidus", last_name="Tester")
memb = Member.objects.create(first_name="Tidus", last_name="Tester", email="tidus@tester.fi")
payment = Payment.objects.create(member=memb, source='AYY')
appl = Request.objects.create(
first_name="Liisa", last_name="Mattila",
email="liisa.mattila@pylly.com", POR="Kouvola",
AYY=True, jas=False)
username, password = 'test_admin', 'password123'
test_admin = User.objects.create_superuser(
username, 'myemail@test.com', password)
@@ -31,16 +39,27 @@ class MemberRegisterTestCase(TestCase):
def test_import_csv_single_line(self):
"""Test csv import only with single line in csv file."""
data = 'Teppo, Tulppu, teppo@tulppu.fi, Ankkalinna, 0, 0'
response = self.c.post('/members/import_csv', {'textarea': data}, follow=True)
current_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(current_dir, 'test_resources', 'single_line_import.csv')) as csvFile:
response = self.c.post('/members/import_csv', {
'csvFile': csvFile,
'delimiter': ';',
'payment_source': 'AYY'
}, follow=True)
self.assertEqual(response.status_code, 200)
def test_import_csv_multi_line(self):
"""Test csv import with multilined csv."""
data = ('Teppo, Tulppu, teppo@tulppu.fi, Ankkalinna, 0, 0\n'
'Reiska, Remontti, remontti@reiska.fi, Värisilmä, 1, 1')
response = self.c.post('/members/import_csv', {'textarea': data}, follow=True)
current_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(current_dir, 'test_resources', 'multi_line_import.csv')) as csvFile:
response = self.c.post('/members/import_csv', {
'csvFile': csvFile,
'delimiter': ';',
'payment_source': 'AYY'
}, follow=True)
self.assertEqual(response.status_code, 200)
def test_autocomplete_search_found(self):
@@ -87,3 +106,50 @@ class MemberRegisterTestCase(TestCase):
response = self.c.get('/members/check?email={}'.format(email), follow=True)
self.assertEqual(response.status_code, 401)
def test_export_members_excel(self):
"""Test if the user can download an excel file of the member register"""
resp = self.c.get('/members/export_members')
content_type = 'application/vnd.ms-excel'
self.assertIn(content_type, resp['Content-Type'])
content = resp.content
arrays = pyexcel.get_array(file_content=content, file_type='xlsx')
tidus_array = ['Tidus', 'Tester', 'tidus@tester.fi', '', '0', '0']
self.assertIn(tidus_array, arrays)
def test_export_payments_excel(self):
"""Test if the user can download an excel file of the payment register"""
resp = self.c.get('/members/export_payments')
content_type = 'application/vnd.ms-excel'
self.assertIn(content_type, resp['Content-Type'])
content = resp.content
arrays = pyexcel.get_array(file_content=content, file_type='xlsx')
created = Payment.objects.get(member__email='tidus@tester.fi').date.strftime('%Y-%m-%d %H:%M:%S')
tidus_array = ['Tidus Tester', created, 'AYY']
self.assertIn(tidus_array, arrays)
def test_export_applications_excel(self):
"""Test if the user can download an excel file of the member application register"""
resp = self.c.get('/members/export_applications')
content_type = 'application/vnd.ms-excel'
self.assertIn(content_type, resp['Content-Type'])
content = resp.content
arrays = pyexcel.get_array(file_content=content, file_type='xlsx')
submitted = Request.objects.get(email='liisa.mattila@pylly.com').submitted.strftime('%Y-%m-%d %H:%M:%S')
liisa_array = ['Liisa', 'Mattila', 'liisa.mattila@pylly.com', 'Kouvola', '1', '0', submitted]
self.assertIn(liisa_array, arrays)
def test_submit_member_application(self):
"""Test if submitting a member application works"""
data = {
'first_name': 'Seppo', 'last_name': 'Saastamoinen',
'email': 'seppo@saastamoin.en', 'jas': 'on',
'POR': 'Dipolin viinibaari'
}
resp = self.c.post('/members/submit_application', data=data)
self.assertEqual(resp.status_code, 200)
self.assertTrue(Request.objects.filter(email='seppo@saastamoin.en').exists())
+8 -3
View File
@@ -7,7 +7,7 @@ from django.views.generic.base import RedirectView
# members
from members.views import member_list, payment_add, payment_submit
from members.views import application_delete_confirm, application_delete
from members.views import application_accept, import_csv, export_csv
from members.views import application_accept, import_csv
from members.views import settings_page, payment_edit
from members.views import payment_delete_confirm
from members.views import payment_delete, payment_update
@@ -20,6 +20,9 @@ from members.views import member_delete_confirm
from members.views import member_delete
from members.views import payment_list
from members.views import add_many_confirm
from members.views import export_members_excel
from members.views import export_payments_excel
from members.views import export_applications_excel
# autocomplete view
from members.views import MemberAutoComplete
@@ -108,8 +111,10 @@ urlpatterns = [
# send CSV member data by POST
url(r'^import_csv', import_csv),
# download CSV member data
url(r'^export_csv', export_csv),
# export members as excel file
url(r'export_members', export_members_excel),
url(r'export_payments', export_payments_excel),
url(r'export_applications', export_applications_excel),
# favourite icon
url(r'^favicon\.ico$', favicon_view),
+12 -25
View File
@@ -14,6 +14,7 @@ import html
from members.views.utils import *
from members.tables import RequestTable
from members.forms import ApplicationForm
from members.views import error_view
@ensure_csrf_cookie
@@ -47,8 +48,7 @@ def application_edit(request, *args, **kwargs):
"""Edit member request information."""
i = kwargs.pop('index', None)
if i is None:
return render(
request, 'error.html', {'error': _('No application id specified')})
return error_view(request, _('No application id specified'))
else:
application = Request.objects.get(id=i)
form = ApplicationForm(instance=application)
@@ -68,9 +68,7 @@ def application_accept(request, *args, **kwargs):
if id is not None:
application = Request.objects.get(id=id)
else:
return render(request,
'error.html',
{'error': _("Application missing 'id' field.")})
return error_view(request, _("Application missing 'id' field."))
form = ApplicationForm(request.POST, instance=application)
if form.is_valid():
@@ -78,9 +76,9 @@ def application_accept(request, *args, **kwargs):
application = form.save()
if Member.objects.filter(email=application.email).exists():
return render(request,
'error.html',
{'error': _('Email {} is already in use by a member. Application cannot be accepted.').format(application.email)})
return error_view(request, _(
'Email {} is already in use by a member. Application cannot be accepted.'
).format(application.email))
member = application.to_member()
member.save()
@@ -96,14 +94,10 @@ def application_accept(request, *args, **kwargs):
'/members/list?notification={}'.format(html.escape(notification)))
except Exception as ex:
logging.exception('Exception while accepting application')
return render(request,
'error.html',
{'error': str(ex)})
return error_view(request, str(ex))
else:
logging.info(form)
return render(request,
'error.html',
{'error': form.errors})
return error_view(request, form.errors)
@ensure_csrf_cookie
@@ -115,8 +109,7 @@ def application_delete(request, *args, **kwargs):
try:
id = request.POST['id']
except KeyError:
return render(
request, 'error.html', {'error': _('No application id specified')})
return error_view(request, _('No application id specified'))
try:
application = Request.objects.get(id=id)
@@ -130,9 +123,7 @@ def application_delete(request, *args, **kwargs):
'/members/applications?notification={}'
.format(html.escape(notification)))
except:
return render(request,
'error.html',
{'error': _('Could not delete application object')})
return error_view(request, _('Could not delete application object'))
@ensure_csrf_cookie
@@ -143,9 +134,7 @@ def application_delete_confirm(request, *args, **kwargs):
"""Confirm application deletion."""
i = kwargs.pop('index', None)
if i is None:
return render(request,
'error.html',
{'error': _('No application id specified')})
return error_view(request, _('No application id specified'))
else:
application = Request.objects.get(id=i)
form = ApplicationForm(instance=application)
@@ -172,6 +161,4 @@ def application_submit(request, *args, **kwargs):
form.save()
return render(request, 'application_success.html', {})
else:
return render(request,
'error.html',
{'error': form.errors})
return error_view(request, form.errors)
+11 -18
View File
@@ -70,8 +70,7 @@ def member_delete_confirm(request, *args, **kwargs):
"""Render member deletion confirmation page."""
i = kwargs.pop('index', None)
if i is None:
return render(request, 'error.html',
{'error': _('No member id specified')})
return error_view(request, _('No member id specified'))
else:
member = Member.objects.get(id=i)
form = MemberForm(instance=member)
@@ -94,11 +93,15 @@ def member_add_many(request, *args, **kwargs):
@permission_required('members.add_member', raise_exception=True)
def add_many_confirm(request, *args, **kwargs):
models = request.session['models']
payment_source = request.session['payment_source']
try:
members, payments = models.members, models.payments
for member in members:
member.save()
if not member.payments.exists(): # create payment for new members
payment = Payment.objects.create(member=member, source=payment_source)
for payment in payments:
payment.save()
@@ -129,7 +132,7 @@ def member_submit(request, *args, **kwargs):
return HttpResponseRedirect(
'/members/list?notification={}'.format(html.escape(notification)))
else:
return render(request, 'error.html', {'error': form.errors})
return error_view(request, form.errors)
@ensure_csrf_cookie
@@ -143,10 +146,7 @@ def member_update(request, *args, **kwargs):
if id is not None:
member = Member.objects.get(id=id)
else:
return render(request,
'error.html',
{'error': _("Member missing 'id' field.")})
logging.debug(member)
return error_view(request, _("Member missing 'id' field."))
form = MemberForm(request.POST, instance=member)
if form.is_valid():
@@ -160,10 +160,7 @@ def member_update(request, *args, **kwargs):
return HttpResponseRedirect(
'/members/list?notification={}'.format(html.escape(notification)))
else:
return render(
request,
'error.html',
{'error': form.errors})
return error_view(request, form.errors)
@ensure_csrf_cookie
@@ -175,8 +172,7 @@ def member_delete(request, *args, **kwargs):
try:
id = request.POST['id']
except KeyError:
return render(request,
'error.html', {'error': _('No member id specified')})
return error_view(request, _('No member id specified'))
try:
member = Member.objects.get(id=id)
@@ -189,9 +185,7 @@ def member_delete(request, *args, **kwargs):
return HttpResponseRedirect(
'/members/list?notification={}'.format(html.escape(notification)))
except:
return render(request,
'error.html',
{'error': _('Could not delete member object')})
return error_view(request, _('Could not delete member object'))
@ensure_csrf_cookie
@@ -202,8 +196,7 @@ def member_edit(request, *args, **kwargs):
"""Edit member information."""
i = kwargs.pop('index', None)
if i is None:
return render(
request, 'error.html', {'error': _('No member id specified')})
return error_view(request, _('No member id specified'))
else:
member = Member.objects.get(id=i)
form = MemberForm(instance=member)
+7 -16
View File
@@ -14,6 +14,7 @@ import html
from members.views.utils import *
from members.tables import PaymentTable
from members.forms import PaymentForm
from members.views import error_view
@ensure_csrf_cookie
@@ -73,7 +74,7 @@ def payment_submit(request, *args, **kwargs):
'/members/payments?notification={}'
.format(html.escape(notification)))
else:
return render(request, 'error.html', {'error': form.errors})
return error_view(request, form.errors)
@ensure_csrf_cookie
@@ -84,9 +85,7 @@ def payment_edit(request, *args, **kwargs):
"""Edit payment."""
i = kwargs.pop('index', None)
if i is None:
return render(request,
'error.html',
{'error': _('No payment id specified')})
return error_view(request, _('No payment id specified'))
else:
payment = Payment.objects.get(id=i)
form = PaymentForm(instance=payment)
@@ -103,9 +102,7 @@ def payment_delete_confirm(request, *args, **kwargs):
"""Render payment delete confirmation page."""
i = kwargs.pop('index', None)
if i is None:
return render(request,
'error.html',
{'error': _('No payment id specified')})
return error_view(request, _('No payment id specified'))
else:
payment = Payment.objects.get(id=i)
form = PaymentForm(instance=payment)
@@ -123,9 +120,7 @@ def payment_delete(request, *args, **kwargs):
try:
id = request.POST['id']
except KeyError:
return render(request,
'error.html',
{'error': _('No payment id specified')})
return error_view(request, _('No payment id specified'))
try:
payment = Payment.objects.get(id=id)
@@ -138,9 +133,7 @@ def payment_delete(request, *args, **kwargs):
'/members/payments?notification={}'
.format(html.escape(notification)))
except:
return render(request,
'error.html',
{'error': _('Could not delete payment object')})
return error_view(request, _('Could not delete payment object'))
@ensure_csrf_cookie
@@ -165,6 +158,4 @@ def payment_update(request, *args, **kwargs):
'/members/payments?notification={}'
.format(html.escape(notification)))
else:
return render(request,
'error.html',
{'error': _('Could not update payment object')})
return error_view(request, _('Could not update payment object'))
+35 -30
View File
@@ -2,7 +2,7 @@ from django.shortcuts import render
from django.contrib.auth.decorators import permission_required, login_required
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import ensure_csrf_cookie
from django.http import HttpResponse, HttpResponseRedirect
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest
from django.core.mail import send_mail
from django.conf import settings
from django.utils.translation import ugettext as _
@@ -20,8 +20,9 @@ from rest_framework import generics
from rest_framework import permissions
from members.models import Member, Request, Payment
from members.forms import MemberForm, PaymentForm, ApplicationForm, CSVValidationError
from members.forms import MemberForm, PaymentForm, ApplicationForm, CSVValidationError, UploadFileForm
from members.tables import MemberTable, PaymentTable, RequestTable
from members.resources import MemberResource, PaymentResource, ApplicationResource
# Can be used to retrieve single member information via REST API
@@ -34,8 +35,8 @@ class MemberDetail(generics.RetrieveAPIView):
throttle_classes = (BurstRateThrottle, SustainedRateThrottle, )
def error_view(request, message):
return render(request, 'error.html', {'error': str(message)})
def error_view(request, message, status=400):
return render(request, 'error.html', {'error': message}, status=400)
def validate_recaptcha(response):
@@ -108,13 +109,14 @@ def settings_page(request, *args, **kwargs):
def import_csv(request, *args, **kwargs):
"""Get csv data imported to page and create members based on that."""
try:
data = request.POST['textfield']
csv_in_memory_file = request.FILES.get('csvFile')
csv_file = csv_in_memory_file.file
data = csv_file.read().decode('utf-8')
delimiter = request.POST.get('delimiter', ',')
payment_source = request.POST['payment_source']
except:
return render(request,
'error.html',
{'error': _('Missing "textfield" POST request field')})
return error_view(request, _('Missing CSV file'))
try:
result = MemberForm.csv_to_models(data, payment_source=payment_source, delimiter=delimiter)
@@ -123,7 +125,7 @@ def import_csv(request, *args, **kwargs):
return error_view(request, ex.form_errors)
except Exception as ex:
logging.exception('Other error in CSV import')
return error_view(request, ex)
return error_view(request, str(ex))
member_table = MemberTable(result.members,
request=request,
@@ -142,6 +144,7 @@ def import_csv(request, *args, **kwargs):
payment_table_html = convert_table_to_html(payment_table, request)
request.session['models'] = result
request.session['payment_source'] = payment_source
context = {
'members': member_table_html,
'payments': payment_table_html
@@ -149,27 +152,6 @@ def import_csv(request, *args, **kwargs):
return render(request, 'member_add_many_confirm.html', context)
@ensure_csrf_cookie
@require_http_methods(["GET"])
@permission_required('members.read_member', login_url='/login', raise_exception=True)
def export_csv(request, *args, **kwargs):
"""Export members as csv."""
response = HttpResponse()
response['Content-type'] = 'text/csv'
response['Accept'] = 'text/csv'
response['Content-Disposition'] = 'filename; filename=members.csv'
writer = csv.writer(response, csv.excel)
# BOM (optional...Excel needs it to open UTF-8 file properly)
response.write(u'\ufeff'.encode('utf8'))
for obj in Member.objects.all():
data = obj.as_array()
field_list = list(map(lambda d: str(d), data))
writer.writerow(field_list)
return response
def send_mail_wrapper(subject, message, email_to):
"""Send mail to default email."""
send_mail(subject,
@@ -177,3 +159,26 @@ def send_mail_wrapper(subject, message, email_to):
settings.DEFAULT_EMAIL_FROM,
[email_to],
fail_silently=False)
def make_excel_response(Resource):
res = Resource()
dataset = res.export()
response = HttpResponse(dataset.xlsx, content_type='application/vnd.ms-excel; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="export.xlsx"'
return response
@require_http_methods(['GET'])
def export_members_excel(request, *args, **kwargs):
return make_excel_response(MemberResource)
@require_http_methods(['GET'])
def export_payments_excel(request, *args, **kwargs):
return make_excel_response(PaymentResource)
@require_http_methods(['GET'])
def export_applications_excel(request, *args, **kwargs):
return make_excel_response(ApplicationResource)
+4 -1
View File
@@ -14,11 +14,14 @@
"author": "SIK ry",
"license": "ISC",
"dependencies": {
"dygraphs": "^2.0.0",
"eslint": "3.19.0",
"remark-cli": "^4.0.0",
"remark-preset-lint-recommended": "^3.0.1"
},
"remarkConfig": {
"plugins": ["remark-preset-lint-recommended"]
"plugins": [
"remark-preset-lint-recommended"
]
}
}
+4 -2
View File
@@ -19,16 +19,18 @@ django-nose==1.4.4
nose-exclude==0.5.0
psycopg2==2.7.1
django-bootstrap3==8.2.3
django-bootstrap4==0.0.4
django-tables2==1.6.1
pycodestyle==2.3.1
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
telepot==12.3
django-import-export==0.5.1
django-password-reset==1.0
pyexcel==0.5.6
pyexcel-xlsx==0.5.2
paho-mqtt==1.3.1
+4 -2
View File
@@ -78,17 +78,19 @@ INSTALLED_APPS = [
'webapp',
'members',
'infoscreen',
'coffee_scale.apps.CoffeeScaleConfig',
'coffee_scale',
'rest_framework',
'django_nose',
'bootstrap3',
'bootstrap4',
'django_tables2',
'auditlog',
'phonenumber_field',
'import_export',
'password_reset',
]
IMPORT_EXPORT_USE_TRANSACTIONS = True
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
NOSE_ARGS = [
+19 -3
View File
@@ -1,8 +1,24 @@
footer {
background-color: #f5f5f5;
margin-top: 1vh;
}
.ml-auto .nav-item {
padding: 1vh;
/*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;
}*/
+1 -1
View File
@@ -7,7 +7,7 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Aalto-yliopiston Sähköinsinöörikilta ry">
<meta name="author" content="Aalto-yliopiston Sähköinsinöörikilta ry">
+32 -4
View File
@@ -7,7 +7,7 @@
<div class="container">
<div class="d-flex align-items-center justify-content-end">
<div class="p-2">
<span><i class="fa fa-copyright"></i>{% trans "Aalto-yliopiston Sähköinsinöörikilta ry" %} {% now 'Y' %}</span>
<span>{% trans "Copyright Aalto-yliopiston Sähköinsinöörikilta ry" %} {% now 'Y' %}</span>
</div>
<div class="p-2">
<form class="lang-form form" action="{% url 'set_language' %}" method="post">{% csrf_token %}
@@ -28,16 +28,44 @@
</div>
<div class="ml-auto p-2">
<span class="nav-item">
<a href="/members"><i class="fa fa-group fa-2x"></i></a>
<a href="/members"><i class="fa fa-group"></i></a>
</span>
<span class="nav-item">
<a href="/infoscreen"><i class="fa fa-info fa-2x"></i></a>
<a href="/infoscreen"><i class="fa fa-info"></i></a>
</span>
<span class="nav-item">
<a href="/admin"><i class="fa fa-gears fa-2x"></i></a>
<a href="/admin"><i class="fa fa-gears"></i></a>
</span>
</div>
</div>
</div>
</footer>
<!-- <form class="lang-form form" action="{% url 'set_language' %}" method="post">{% csrf_token %}
<span>
<input name="next" type="hidden" value="{{ redirect_to }}" />
<select onchange="this.form.submit()" class="lang-select form-control" name="language">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected="selected"{% endif %}>
{{ language.name_local }} ({{ language.code }})
</option>
{% endfor %}
</select>
</span>
</form>
<span>{% trans "Copyright Aalto-yliopiston Sähköinsinöörikilta ry" %} {% now 'Y' %}</span>
-->
<!-- <span class="nav-item">
<a href="/members"><i class="fa fa-group"></i></a>
</span>
<span class="nav-item">
<a href="/infoscreen"><i class="fa fa-info"></i></a>
</span>
<span class="nav-item">
<a href="/admin"><i class="fa fa-gears"></i></a>
</span> -->
+4 -9
View File
@@ -1,13 +1,8 @@
{% extends "password_reset/base.html" %}{% load i18n %}
{% block content %}
<div class="container">
<div class="d-flex justify-content-center">
<p>{% trans "Your password has successfully been reset." %}</p>
</div>
<div class="d-flex justify-content-center">
<p><a href="/login">{% trans "Log in" %}</a></p>
</div>
</div>
{% block title %}{% trans "New password set" %}{% endblock %}
{% block content %}
<p>{% trans "Your password has successfully been reset. You can use it right now on the login page." %}</p>
<p><a href="/login">Log in</a></p>
{% endblock %}
@@ -1,14 +0,0 @@
{% autoescape off %}
{% load i18n %}
{% blocktrans %}You're receiving this e-mail because you requested a password reset for your user account at{% endblocktrans %} {{ site.domain }}.
{% blocktrans %}Please go to the following page and choose a new password:{% endblocktrans %}
{% block reset_link %}
{% if secure %}https {% else %}http{% endif %}://{{ site.domain }}{% url "password_reset_reset" token %}
{% endblock %}
{% blocktrans %}Your username, in case you've forgotten: {% endblocktrans %}{{ user.username }}
{% blocktrans %}If you don't want to reset your password, simply ignore this email and it will stay unchanged.{% endblocktrans %}
{% endautoescape %}
@@ -1 +0,0 @@
{% load i18n %}{% blocktrans %}Password recovery on{% endblocktrans %} {{ site.domain }}
+7 -13
View File
@@ -1,18 +1,12 @@
{% extends "password_reset/base.html" %}
{% load i18n %}
{% load bootstrap4 %}
{% block title %}{% trans "Password recovery" %}{% endblock %}
{% block content %}
<div class="container">
<div class="d-flex justify-content-center">
<form method="post" action="{{ url }}" class="form">{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">
{% trans "Send" %}
</button>
{% endbuttons %}
</form>
</div>
</div>
<form method="post" action="{{ url }}">
{% csrf_token %}
{{ form.as_p }}
<p><input type="submit" value="{% trans "Send" %}"></p>
</form>
{% endblock %}
+10 -24
View File
@@ -1,28 +1,14 @@
{% extends "password_reset/base.html" %}{% load i18n %}
{% load i18n %}
{% load bootstrap4 %}
{% block content %}
<div class="container">
{% if invalid %}{% url "password_reset_recover" as recovery_url %}
<div class="d-flex justify-content-center">
<p>{% blocktrans %}Sorry, this password reset link is invalid. You can still <a href="{{ recovery_url }}">request a new one</a>.{% endblocktrans %}</p>
</div>
{% else %}
<div class="d-flex justify-content-center">
<p>{% blocktrans %}Hi, <strong>{{ username }}</strong>. Please choose your new password.{% endblocktrans %}</p>
</div>
<div class="d-flex justify-content-center">
<form method="post" action="{% url "password_reset_reset" token %}" class="form">{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">
{% trans "Set new password" %}
</button>
{% endbuttons %}
</form>
</div>
{% endif %}
</div>
{% if invalid %}{% url "password_reset_recover" as recovery_url %}
<p>{% blocktrans %}Sorry, this password reset link is invalid. You can still <a href="{{ recovery_url }}">request a new one</a>.{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans %}Hi, <strong>{{ username }}</strong>. Please choose your new password.{% endblocktrans %}</p>
<form method="post" action="{% url "password_reset_reset" token %}">
{% csrf_token %}
{{ form.as_p }}
<p><input type="submit" value="{% trans "Set new password" %}"></p>
</form>
{% endif %}
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% autoescape off %}
You're receiving this e-mail because you requested a password reset for your user account at {{ site_name }}.
Please go to the following page and choose a new password:
{% block reset_link %}
{{ protocol }}://{{ domain }}{% url django.contrib.auth.views.password_reset_confirm uidb36=uid, token=token %}
{% endblock %}
Your username, in case you've forgotten: {{ user.username }}
Thanks for using our site!
The {{ site_name }} team.
{% endautoescape %}
+4 -5
View File
@@ -1,9 +1,8 @@
{% extends "password_reset/base.html" %}
{% load i18n %}
{% block title %}{% trans "Password recovery sent" %}{% endblock %}
{% block content %}
<div class="container">
<div class="d-flex justify-content-center">
<p>{% blocktrans with ago=timestamp|timesince %}An email was sent to <strong>{{ email }}</strong> {{ ago }} ago. Use the link in it to set a new password.{% endblocktrans %}</p>
</div>
</div>{% endblock %}
<p>{% blocktrans with ago=timestamp|timesince %}An email was sent to <strong>{{ email }}</strong> {{ ago }} ago. Use the link in it to set a new password.{% endblocktrans %}</p>
{% endblock %}
+3 -3
View File
@@ -1,3 +1,3 @@
nav {
margin-bottom: 2vh;
}
/*.main_navigation .navbar-border .navbar-nav .nav-item {
display: block;
}*/
-5
View File
@@ -14,11 +14,6 @@
<h5>Lisää vain vesi</h5>
</div>
</div>
<div class="row">
<div class="col">
{% lorem %}
</div>
</div>
</div>
</div>
{% endblock %}
+30 -36
View File
@@ -1,40 +1,34 @@
{% load i18n %}
<link rel="stylesheet" href="/static/css/main_nav.css">
<nav class="navbar navbar-toggleable-md navbar-light bg-faded">
<button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<!-- <p class="navbar-brand">Kökkö</p> -->
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav mx-auto">
<li class="nav-item text-center">
<a class="nav-link" href="/guild">{% trans "Guild" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-link" href="/freshmen">{% trans "Freshmen" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-link" href="/event_calendar">{% trans "Event calendar" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-link" href="/international">{% trans "International" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-link" href="/sosso">{% trans "Sössö" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-item nav-link" href="/contact">{% trans "Contact" %}</a>
</li>
<li class="nav-item dropdown text-center">
<a class="nav-link dropdown-toggle" id="corpDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{% trans "Corporate" %}
</a>
<div class="dropdown-menu text-center" aria-labelledby="corpDropdown">
<a class="dropdown-item" href="/jobs">{% trans "Jobs" %}</a>
<a class="dropdown-item" href="/">Yritysdadaa</a>
<a class="dropdown-item" href="/">Something else here</a>
</div>
</li>
</ul>
</div>
<ul class="navbar-nav mx-auto">
<li class="nav-item text-center">
<a class="nav-link" href="/guild">{% trans "Guild" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-link" href="/freshmen">{% trans "Freshmen" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-link" href="/event_calendar">{% trans "Event calendar" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-link" href="/international">{% trans "International" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-link" href="/sosso">{% trans "Sössö" %}</a>
</li>
<li class="nav-item text-center">
<a class="nav-item nav-link" href="/contact">{% trans "Contact" %}</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" id="corpDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{% trans "Corporate" %}
</a>
<div class="dropdown-menu" aria-labelledby="corpDropdown">
<a class="dropdown-item" href="/jobs">{% trans "Jobs" %}</a>
<a class="dropdown-item" href="/">Yritysdadaa</a>
<a class="dropdown-item" href="/">Something else here</a>
</div>
</li>
</ul>
</nav>